diff --git a/.github/actions/cleanup-test-env/action.yml b/.github/actions/cleanup-test-env/action.yml index d5bf4d78..9b5a26d3 100644 --- a/.github/actions/cleanup-test-env/action.yml +++ b/.github/actions/cleanup-test-env/action.yml @@ -1,53 +1,15 @@ name: 'Cleanup Test Environment' -description: 'Stop all test processes (Kora RPC, Solana validator, etc.)' +description: 'Kill any remaining test processes (safety net for test runner failures)' runs: using: 'composite' steps: - - name: Stop test processes + - name: Kill remaining processes shell: bash if: always() run: | - echo "๐Ÿงน Cleaning up test environment..." - - # Stop Kora RPC server using saved PID - if [ -f /tmp/kora_pid ]; then - KORA_PID=$(cat /tmp/kora_pid) - if [ ! -z "$KORA_PID" ]; then - echo "Stopping Kora RPC server (PID: $KORA_PID)" - kill $KORA_PID 2>/dev/null || true - fi - rm -f /tmp/kora_pid - fi - - # Stop using environment variable as fallback - if [ ! -z "$KORA_PID" ]; then - echo "Stopping Kora RPC server (ENV PID: $KORA_PID)" - kill $KORA_PID 2>/dev/null || true - fi - - # Stop Solana validator using saved PID - if [ -f /tmp/validator_pid ]; then - VALIDATOR_PID=$(cat /tmp/validator_pid) - if [ ! -z "$VALIDATOR_PID" ]; then - echo "Stopping Solana validator (PID: $VALIDATOR_PID)" - kill $VALIDATOR_PID 2>/dev/null || true - fi - rm -f /tmp/validator_pid - fi - - # Stop using environment variable as fallback - if [ ! -z "$VALIDATOR_PID" ]; then - echo "Stopping Solana validator (ENV PID: $VALIDATOR_PID)" - kill $VALIDATOR_PID 2>/dev/null || true - fi - - # Kill any remaining processes by name (nuclear option) - echo "Killing any remaining test processes..." + echo "๐Ÿงน Safety cleanup of any remaining processes..." pkill -f "solana-test-validator" 2>/dev/null || true pkill -f "kora" 2>/dev/null || true - - # Wait a moment for processes to stop - sleep 2 - + sleep 1 echo "โœ… Cleanup completed" \ No newline at end of file diff --git a/.github/actions/run-test-runner/action.yml b/.github/actions/run-test-runner/action.yml new file mode 100644 index 00000000..f18394de --- /dev/null +++ b/.github/actions/run-test-runner/action.yml @@ -0,0 +1,45 @@ +name: "Run Test Runner" +description: "Execute Kora integration tests using the test runner with specified filters" + +inputs: + filters: + description: "Test filters to apply (e.g., '--filter regular --filter auth')" + required: true + verbose: + description: "Enable verbose output" + required: false + default: "true" + rpc-url: + description: "Solana RPC URL to use" + required: false + default: "http://127.0.0.1:8899" + force-refresh: + description: "Force refresh of test accounts" + required: false + default: "false" + +runs: + using: "composite" + steps: + - name: Run integration tests with test runner + shell: bash + run: | + echo "๐Ÿงช Running integration tests with filters: ${{ inputs.filters }}" + + # Build command arguments + ARGS="" + if [ "${{ inputs.verbose }}" = "true" ]; then + ARGS="$ARGS --verbose" + fi + if [ "${{ inputs.force-refresh }}" = "true" ]; then + ARGS="$ARGS --force-refresh" + fi + if [ "${{ inputs.rpc-url }}" != "http://127.0.0.1:8899" ]; then + ARGS="$ARGS --rpc-url ${{ inputs.rpc-url }}" + fi + + # Add filters + ARGS="$ARGS ${{ inputs.filters }}" + + # Run the test runner + cargo run -p tests --bin test_runner -- $ARGS \ No newline at end of file diff --git a/.github/actions/setup-kora-rpc/action.yml b/.github/actions/setup-kora-rpc/action.yml deleted file mode 100644 index 445ff017..00000000 --- a/.github/actions/setup-kora-rpc/action.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: "Setup Kora RPC Server" -description: "Build and start the Kora RPC server with health check" - -inputs: - config-file: - description: "Kora config file path" - required: false - default: "tests/src/common/fixtures/kora-test.toml" - signers-config: - description: "Signers config file path" - required: false - default: "tests/src/common/fixtures/signers.toml" - rpc-url: - description: "Solana RPC URL" - required: false - default: "http://127.0.0.1:8899" - port: - description: "Kora RPC server port" - required: false - default: "8080" - timeout: - description: "Timeout in seconds to wait for server startup" - required: false - default: "30" - test-server-url: - description: "Test server URL for health checks" - required: false - default: "http://127.0.0.1:8080" - initialize-atas: - description: "Whether to initialize payment ATAs after starting server" - required: false - default: "false" - -outputs: - kora-pid: - description: "Process ID of the started Kora RPC server" - value: ${{ steps.start-server.outputs.kora-pid }} - -runs: - using: "composite" - steps: - - name: Build Kora RPC server - shell: bash - run: make build - - - name: Start Kora RPC server - id: start-server - shell: bash - run: | - echo "๐Ÿš€ Starting Kora RPC server..." - - # Set environment variables for signers (always required now) - export KORA_PRIVATE_KEY="$(cat tests/src/common/local-keys/fee-payer-local.json)" - - # Set second signer if it exists (for multi-signer configs) - if [ -f "tests/src/common/local-keys/signer2-local.json" ]; then - export KORA_PRIVATE_KEY_2="$(cat tests/src/common/local-keys/signer2-local.json)" - fi - - cargo run -p kora-cli --bin kora -- \ - --config ${{ inputs.config-file }} \ - --rpc-url ${{ inputs.rpc-url }} \ - rpc start \ - --signers-config ${{ inputs.signers-config }} \ - --port ${{ inputs.port }} \ - & - KORA_PID=$! - echo "KORA_PID=$KORA_PID" >> $GITHUB_ENV - echo "kora-pid=$KORA_PID" >> $GITHUB_OUTPUT - - # Wait for Kora RPC server to be ready - echo "โณ Waiting for Kora RPC server to be ready..." - timeout=${{ inputs.timeout }} - counter=0 - while [ $counter -lt $timeout ]; do - if curl -s ${{ inputs.test-server-url }}/health >/dev/null 2>&1; then - echo "โœ… Kora RPC server health check passed!" - break - fi - sleep 1 - counter=$((counter + 1)) - done - - if [ $counter -eq $timeout ]; then - echo "โŒ Kora RPC server failed to start within $timeout seconds" - jobs - exit 1 - fi - - - name: Initialize payment ATAs - if: ${{ inputs.initialize-atas == 'true' }} - shell: bash - run: | - echo "๐Ÿ”ง Initializing payment ATAs..." - - # Set environment variables for signers - export KORA_PRIVATE_KEY="$(cat tests/src/common/local-keys/fee-payer-local.json)" - - # Set second signer if it exists (for multi-signer configs) - if [ -f "tests/src/common/local-keys/signer2-local.json" ]; then - export KORA_PRIVATE_KEY_2="$(cat tests/src/common/local-keys/signer2-local.json)" - fi - - cargo run -p kora-cli --bin kora -- \ - --config ${{ inputs.config-file }} \ - --rpc-url ${{ inputs.rpc-url }} \ - rpc initialize-atas \ - --signers-config ${{ inputs.signers-config }} - - # Additional wait to ensure server is fully initialized - echo "โณ Ensuring RPC server is fully initialized..." - sleep 3 \ No newline at end of file diff --git a/.github/actions/setup-solana-validator/action.yml b/.github/actions/setup-solana-validator/action.yml deleted file mode 100644 index 8fadd3aa..00000000 --- a/.github/actions/setup-solana-validator/action.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: "Setup Solana Validator" -description: "Start Solana test validator with health check" - -inputs: - rpc-url: - description: "Solana RPC URL" - required: false - default: "http://127.0.0.1:8899" - timeout: - description: "Timeout in seconds to wait for validator" - required: false - default: "60" - -outputs: - rpc-url: - description: "Solana RPC URL" - value: ${{ inputs.rpc-url }} - -runs: - using: "composite" - steps: - - name: Start Solana test validator - shell: bash - run: | - echo "๐Ÿš€ Starting Solana test validator..." - - - # Start validator with transfer hook program loaded - solana-test-validator --reset --quiet \ - --bpf-program Bcdikjss8HWzKEuj6gEQoFq9TCnGnk6v3kUnRU1gb6hA tests/src/common/transfer-hook-example/transfer_hook_example.so & - VALIDATOR_PID=$! - echo "VALIDATOR_PID=$VALIDATOR_PID" >> $GITHUB_ENV - - # Save PID to file for cleanup action - echo $VALIDATOR_PID > /tmp/validator_pid - - # Wait for validator to be ready - echo "โณ Waiting for validator to be ready..." - timeout=${{ inputs.timeout }} - counter=0 - - while [ $counter -lt $timeout ]; do - if solana cluster-version --url ${{ inputs.rpc-url }} >/dev/null 2>&1; then - echo "โœ… Solana validator ready at ${{ inputs.rpc-url }}!" - break - fi - sleep 1 - counter=$((counter + 1)) - done - - if [ $counter -eq $timeout ]; then - echo "โŒ Solana validator timeout after $timeout seconds" - echo "Current processes:" - ps aux | grep solana || true - exit 1 - fi diff --git a/.github/workflows/build-rust.yml b/.github/workflows/build-rust.yml new file mode 100644 index 00000000..f8d0207a --- /dev/null +++ b/.github/workflows/build-rust.yml @@ -0,0 +1,39 @@ +name: Build Rust Artifacts + +on: + workflow_call: + inputs: + cache-key: + description: "Cache key suffix for rust cache" + required: true + type: string + artifact-name: + description: "Name for the uploaded artifact" + required: true + type: string + +jobs: + build: + name: Build Rust Artifacts + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ${{ inputs.cache-key }} + + - name: Build workspace + run: make build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: | + target/debug/kora + target/debug/test_runner + retention-days: 1 \ No newline at end of file diff --git a/.github/workflows/rust-unit.yml b/.github/workflows/rust-unit.yml new file mode 100644 index 00000000..b22ba6e6 --- /dev/null +++ b/.github/workflows/rust-unit.yml @@ -0,0 +1,128 @@ +name: Rust Unit Tests + +on: + push: + branches: [main, "release/*"] + paths: + - "crates/**" + - "Cargo.*" + - "Makefile" + - ".github/workflows/rust-unit.yml" + pull_request: + branches: [main, "release/*"] + paths: + - "crates/**" + - "Cargo.*" + - "Makefile" + - ".github/workflows/rust-unit.yml" + +env: + CARGO_TERM_COLOR: always + RUST_LOG: info + CI: true + +jobs: + test: + name: Rust Unit Tests & Coverage + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy, llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: make check + - name: Run clippy + run: make lint + - name: Build + run: make build + + - name: Setup Solana CLI + uses: ./.github/actions/setup-solana + + - name: Install cargo-llvm-cov for coverage + run: cargo install cargo-llvm-cov + + - name: Run unit tests with coverage + run: | + echo "๐Ÿงช Running unit tests with coverage instrumentation..." + cargo llvm-cov clean --workspace + cargo llvm-cov test --no-report --workspace --lib + + - name: Generate coverage reports + run: | + echo "๐Ÿ“Š Generating coverage reports..." + mkdir -p coverage + cargo llvm-cov report --lcov --output-path coverage/lcov.info + + - name: Display coverage summary + run: | + echo "๐Ÿ“Š Coverage Summary:" + if [ -f "coverage/lcov.info" ]; then + echo "โœ… Coverage report generated successfully" + echo "๐Ÿ“„ Generated: coverage/lcov.info" + else + echo "โŒ Coverage report not found" + fi + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + with: + name: rust-unit-coverage-report + path: coverage/ + retention-days: 30 + + - name: Update PR description with coverage badge + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v7 + with: + script: | + // Extract coverage percentage from lcov.info + const fs = require('fs'); + let coverage = '0'; + + try { + const lcov = fs.readFileSync('coverage/lcov.info', 'utf8'); + const linesFound = lcov.match(/^LF:(\d+)$/gm)?.reduce((sum, line) => sum + parseInt(line.split(':')[1]), 0) || 0; + const linesHit = lcov.match(/^LH:(\d+)$/gm)?.reduce((sum, line) => sum + parseInt(line.split(':')[1]), 0) || 0; + coverage = linesFound > 0 ? ((linesHit / linesFound) * 100).toFixed(1) : '0'; + } catch (error) { + console.log('Error reading coverage:', error); + } + + // Determine badge color + let color = 'red'; + if (parseFloat(coverage) >= 80) color = 'green'; + else if (parseFloat(coverage) >= 60) color = 'yellow'; + + // Get current PR + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + + // Create coverage badge section + const coverageBadge = `![Coverage](https://img.shields.io/badge/coverage-${coverage}%25-${color})`; + const coverageSection = `\n\n## ๐Ÿ“Š Unit Test Coverage\n${coverageBadge}\n\n**Unit Test Coverage: ${coverage}%**\n\n[View Detailed Coverage Report](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; + + // Update PR body + let newBody = pr.body || ''; + + // Remove existing coverage section if present + newBody = newBody.replace(/\n## ๐Ÿ“Š Unit Test Coverage[\s\S]*?(?=\n## |\n$|$)/g, ''); + + // Add new coverage section + newBody += coverageSection; + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + body: newBody + }); \ No newline at end of file diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 04ef52d6..ed799864 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,20 +1,28 @@ -name: Rust CI +name: Rust Integration Tests on: push: branches: [main, "release/*"] paths: - "crates/**" + - "tests/**" - "Cargo.*" - "Makefile" + - "makefiles/**" - ".github/workflows/rust.yml" + - ".github/workflows/build-rust.yml" + - ".github/actions/run-test-runner/**" pull_request: branches: [main, "release/*"] paths: - "crates/**" + - "tests/**" - "Cargo.*" - "Makefile" + - "makefiles/**" - ".github/workflows/rust.yml" + - ".github/workflows/build-rust.yml" + - ".github/actions/run-test-runner/**" env: CARGO_TERM_COLOR: always @@ -22,208 +30,47 @@ env: CI: true jobs: - test: - name: Rust Tests & Coverage + build: + name: Build Artifacts + uses: ./.github/workflows/build-rust.yml + with: + cache-key: "rust-integration-build" + artifact-name: "rust-binaries" + + integration: + name: Integration Tests runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - pull-requests: write + needs: build + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + test-group: [regular, auth, payment_address, multi_signer] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy, llvm-tools-preview - - uses: Swatinem/rust-cache@v2 - - name: Check formatting - run: make check - - name: Run clippy - run: make lint - - name: Build - run: make build - - - name: Setup Solana CLI - uses: ./.github/actions/setup-solana - - - name: Install cargo-llvm-cov for coverage - run: cargo install cargo-llvm-cov - - - name: Run unit tests with coverage - run: | - echo "๐Ÿงช Running unit tests with coverage instrumentation..." - cargo llvm-cov clean --workspace - cargo llvm-cov test --no-report --workspace --lib - - - name: Setup Solana test validator - uses: ./.github/actions/setup-solana-validator - - - name: Setup test environment - run: | - echo "๐Ÿ”ง Setting up test environment..." - # Create second signer key for multi-signer tests - if [ ! -f "tests/src/common/local-keys/signer2-local.json" ]; then - echo "Creating second signer key..." - solana-keygen new --outfile tests/src/common/local-keys/signer2-local.json --no-bip39-passphrase --silent - fi - KORA_PRIVATE_KEY="$(cat tests/src/common/local-keys/fee-payer-local.json)" cargo run -p tests --bin setup_test_env - - - name: Setup Kora RPC server (regular config) - uses: ./.github/actions/setup-kora-rpc - with: - config-file: "tests/src/common/fixtures/kora-test.toml" - - - name: Run RPC integration tests - run: | - echo "๐Ÿงช Running RPC integration tests..." - cargo llvm-cov test --no-report -p tests --test rpc - - - name: Build transfer hook program for Token 2022 tests - run: | - echo "๐Ÿ”ง Building transfer hook program..." - make build-transfer-hook - - - name: Run token integration tests - run: | - echo "๐Ÿงช Running token integration tests..." - cargo llvm-cov test --no-report -p tests --test tokens - - - name: Run external integration tests - run: | - echo "๐Ÿงช Running external integration tests..." - cargo llvm-cov test --no-report -p tests --test external - - - name: Stop Kora RPC server - run: | - if [ ! -z "$KORA_PID" ]; then - kill $KORA_PID || true - fi - sleep 2 - - - name: Setup Kora RPC server (auth config) - uses: ./.github/actions/setup-kora-rpc - with: - config-file: "tests/src/common/fixtures/auth-test.toml" - - name: Run auth integration tests - run: | - echo "๐Ÿงช Running auth integration tests..." - cargo llvm-cov test --no-report -p tests --test auth - - - name: Stop Kora RPC server - run: | - if [ ! -z "$KORA_PID" ]; then - kill $KORA_PID || true - fi - sleep 2 + - uses: dtolnay/rust-toolchain@stable - - name: Setup Kora RPC server (payment address config) - uses: ./.github/actions/setup-kora-rpc + - uses: Swatinem/rust-cache@v2 with: - config-file: "tests/src/common/fixtures/paymaster-address-test.toml" - initialize-atas: "true" - - - name: Run payment address integration tests - run: | - echo "๐Ÿงช Running payment address integration tests..." - cargo llvm-cov test --no-report -p tests --test payment_address - - - name: Stop Kora RPC server - run: | - if [ ! -z "$KORA_PID" ]; then - kill $KORA_PID || true - fi - sleep 2 + shared-key: "rust-integration" - - name: Setup multi-signer test environment - run: | - echo "๐Ÿ”ง Setting up multi-signer test environment..." - export KORA_PRIVATE_KEY="$(cat tests/src/common/local-keys/fee-payer-local.json)" - export KORA_PRIVATE_KEY_2="$(cat tests/src/common/local-keys/signer2-local.json)" - cargo run -p tests --bin setup_test_env - - - name: Setup Kora RPC server (multi-signer config) - uses: ./.github/actions/setup-kora-rpc + - name: Download build artifacts + uses: actions/download-artifact@v4 with: - config-file: "tests/src/common/fixtures/kora-test.toml" - signers-config: "tests/src/common/fixtures/multi-signers.toml" - - - name: Run multi-signer integration tests - run: | - echo "๐Ÿงช Running multi-signer integration tests..." - cargo llvm-cov test --no-report -p tests --test multi_signer + name: rust-binaries + path: target/debug/ - - name: Generate coverage reports - run: | - echo "๐Ÿ“Š Generating coverage reports..." - mkdir -p coverage - cargo llvm-cov report --lcov --output-path coverage/lcov.info + - name: Make binaries executable + run: chmod +x target/debug/kora target/debug/test_runner - - name: Display coverage summary - run: | - echo "๐Ÿ“Š Coverage Summary:" - if [ -f "coverage/lcov.info" ]; then - echo "โœ… Coverage report generated successfully" - echo "๐Ÿ“„ Generated: coverage/lcov.info" - else - echo "โŒ Coverage report not found" - fi - - - name: Upload coverage artifacts - uses: actions/upload-artifact@v4 - with: - name: rust-coverage-report - path: coverage/ - retention-days: 30 + - name: Setup Solana CLI + uses: ./.github/actions/setup-solana - - name: Update PR description with coverage badge - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - uses: actions/github-script@v7 + - name: Run integration tests + uses: ./.github/actions/run-test-runner with: - script: | - // Extract coverage percentage from lcov.info - const fs = require('fs'); - let coverage = '0'; - - try { - const lcov = fs.readFileSync('coverage/lcov.info', 'utf8'); - const linesFound = lcov.match(/^LF:(\d+)$/gm)?.reduce((sum, line) => sum + parseInt(line.split(':')[1]), 0) || 0; - const linesHit = lcov.match(/^LH:(\d+)$/gm)?.reduce((sum, line) => sum + parseInt(line.split(':')[1]), 0) || 0; - coverage = linesFound > 0 ? ((linesHit / linesFound) * 100).toFixed(1) : '0'; - } catch (error) { - console.log('Error reading coverage:', error); - } - - // Determine badge color - let color = 'red'; - if (parseFloat(coverage) >= 80) color = 'green'; - else if (parseFloat(coverage) >= 60) color = 'yellow'; - - // Get current PR - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - }); - - // Create coverage badge section - const coverageBadge = `![Coverage](https://img.shields.io/badge/coverage-${coverage}%25-${color})`; - const coverageSection = `\n\n## ๐Ÿ“Š Test Coverage\n${coverageBadge}\n\n**Coverage: ${coverage}%**\n\n[View Detailed Coverage Report](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; - - // Update PR body - let newBody = pr.body || ''; - - // Remove existing coverage section if present - newBody = newBody.replace(/\n## ๐Ÿ“Š Test Coverage[\s\S]*?(?=\n## |\n$|$)/g, ''); - - // Add new coverage section - newBody += coverageSection; - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - body: newBody - }); + filters: "--filter ${{ matrix.test-group }}" - name: Cleanup test environment if: always() @@ -233,4 +80,4 @@ jobs: if: failure() uses: ./.github/actions/show-failure-logs with: - test-type: "Rust integration" + test-type: "Rust integration (${{ matrix.test-group }})" \ No newline at end of file diff --git a/.github/workflows/typescript-integration.yml b/.github/workflows/typescript-integration.yml index 4e2f11b8..6643bc98 100644 --- a/.github/workflows/typescript-integration.yml +++ b/.github/workflows/typescript-integration.yml @@ -10,9 +10,13 @@ on: - "sdks/ts/**" # Backend changes that affect TS integration - "crates/**" + - "tests/**" - "Cargo.*" - "Makefile" + - "makefiles/**" - ".github/workflows/typescript-integration.yml" + - ".github/workflows/build-rust.yml" + - ".github/actions/run-test-runner/**" pull_request: branches: [main, "release/*"] @@ -21,38 +25,56 @@ on: - "sdks/ts/**" # Backend changes that affect TS integration - "crates/**" + - "tests/**" - "Cargo.*" - "Makefile" + - "makefiles/**" - ".github/workflows/typescript-integration.yml" + - ".github/workflows/build-rust.yml" + - ".github/actions/run-test-runner/**" env: CARGO_TERM_COLOR: always RUST_LOG: info jobs: + build: + name: Build Rust Backend + uses: ./.github/workflows/build-rust.yml + with: + cache-key: "typescript-integration-build" + artifact-name: "rust-binaries-ts" + typescript-integration: name: TypeScript SDK Integration Tests runs-on: ubuntu-latest - timeout-minutes: 25 + needs: build + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + test-group: [typescript_basic, typescript_auth, typescript_turnkey, typescript_privy] steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: "typescript-integration-${{ matrix.test-group }}" - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 + - name: Download build artifacts + uses: actions/download-artifact@v4 with: - shared-key: "typescript-integration" - cache-on-failure: true + name: rust-binaries-ts + path: target/debug/ + + - name: Make binaries executable + run: chmod +x target/debug/kora target/debug/test_runner - name: Setup Solana CLI uses: ./.github/actions/setup-solana - - name: Setup Kora RPC server - uses: ./.github/actions/setup-kora-rpc - - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -84,34 +106,16 @@ jobs: run: pnpm build - name: Run TypeScript integration tests - working-directory: sdks/ts - run: | - echo "๐Ÿงช Running TypeScript SDK integration tests..." - # The test script handles starting/stopping its own validator - pnpm test:ci:integration - - - name: Stop Kora RPC server - run: | - if [ ! -z "$KORA_PID" ]; then - kill $KORA_PID || true - fi - sleep 2 - - - name: Setup Kora RPC server (auth config) - uses: ./.github/actions/setup-kora-rpc + uses: ./.github/actions/run-test-runner with: - config-file: "tests/src/common/fixtures/auth-test.toml" - - - name: Run TypeScript auth integration tests - working-directory: sdks/ts - run: | - echo "๐Ÿงช Running TypeScript SDK auth integration tests..." - pnpm test:ci:integration:auth + filters: "--filter ${{ matrix.test-group }}" - name: Cleanup test environment + if: always() uses: ./.github/actions/cleanup-test-env - name: Show failure logs + if: failure() uses: ./.github/actions/show-failure-logs with: - test-type: "TypeScript integration" + test-type: "TypeScript integration (${{ matrix.test-group }})" diff --git a/.gitignore b/.gitignore index a64ecf6c..db3b5603 100644 --- a/.gitignore +++ b/.gitignore @@ -20,11 +20,11 @@ coverage/ .DS_Store # Make test files -.validator.pid -.kora.pid +*.pid test-ledger/ .local/ tests/src/common/fixtures/lookup_tables.json +tests/src/common/fixtures/test-accounts/ # Docs sdks/ts/docs-html/ @@ -32,4 +32,4 @@ sdks/ts/docs-md/ docs/api-reference-generated/ # AI -.claude/ \ No newline at end of file +.claude/ diff --git a/Cargo.lock b/Cargo.lock index 6865d444..487edc2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7996,6 +7996,7 @@ dependencies = [ "base64 0.22.1", "bincode", "bs58 0.5.1", + "chrono", "clap", "colored", "dotenv", diff --git a/Makefile b/Makefile index ffd9cef1..9d7bdffd 100644 --- a/Makefile +++ b/Makefile @@ -13,5 +13,5 @@ include makefiles/METRICS.makefile # Default target all: check test build -# Run all tests (unit + integration + TypeScript) -test-all: test test-integration test-ts \ No newline at end of file +# Run all tests (unit + TypeScript + integration) +test-all: test test-ts test-integration \ No newline at end of file diff --git a/crates/lib/src/rpc_server/method/transfer_transaction.rs b/crates/lib/src/rpc_server/method/transfer_transaction.rs index 5d81ee04..93c6f5ab 100644 --- a/crates/lib/src/rpc_server/method/transfer_transaction.rs +++ b/crates/lib/src/rpc_server/method/transfer_transaction.rs @@ -112,7 +112,7 @@ pub async fn transfer_transaction( } let blockhash = - rpc_client.get_latest_blockhash_with_commitment(CommitmentConfig::finalized()).await?; + rpc_client.get_latest_blockhash_with_commitment(CommitmentConfig::confirmed()).await?; let message = VersionedMessage::Legacy(Message::new_with_blockhash( &instructions, diff --git a/crates/lib/src/transaction/versioned_transaction.rs b/crates/lib/src/transaction/versioned_transaction.rs index bbc9b58a..7fe819f9 100644 --- a/crates/lib/src/transaction/versioned_transaction.rs +++ b/crates/lib/src/transaction/versioned_transaction.rs @@ -242,7 +242,7 @@ impl VersionedTransactionOps for VersionedTransactionResolved { if transaction.signatures.is_empty() { let blockhash = rpc_client - .get_latest_blockhash_with_commitment(CommitmentConfig::finalized()) + .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed()) .await?; transaction.message.set_recent_blockhash(blockhash.0); } diff --git a/crates/lib/src/validator/config_validator.rs b/crates/lib/src/validator/config_validator.rs index d33d069a..fbfeee1b 100644 --- a/crates/lib/src/validator/config_validator.rs +++ b/crates/lib/src/validator/config_validator.rs @@ -355,6 +355,7 @@ mod tests { }, }; use serial_test::serial; + use solana_commitment_config::CommitmentConfig; use super::*; @@ -385,7 +386,10 @@ mod tests { config.validation.allowed_tokens = vec![]; let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate(&rpc_client).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), KoraError::InternalServerError(_))); @@ -419,7 +423,10 @@ mod tests { // Initialize global config let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_ok()); let warnings = result.unwrap(); @@ -467,7 +474,10 @@ mod tests { // Initialize global config let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_ok()); let warnings = result.unwrap(); @@ -504,7 +514,10 @@ mod tests { // Initialize global config let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_ok()); let warnings = result.unwrap(); @@ -539,7 +552,10 @@ mod tests { let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_err()); let errors = result.unwrap_err(); @@ -579,7 +595,10 @@ mod tests { let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_err()); let errors = result.unwrap_err(); @@ -616,7 +635,10 @@ mod tests { let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_err()); let errors = result.unwrap_err(); @@ -661,7 +683,10 @@ mod tests { let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_ok()); let warnings = result.unwrap(); @@ -693,7 +718,10 @@ mod tests { let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_err()); let errors = result.unwrap_err(); @@ -980,7 +1008,10 @@ mod tests { let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_ok()); } @@ -1011,7 +1042,10 @@ mod tests { let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_err()); let errors = result.unwrap_err(); @@ -1046,7 +1080,10 @@ mod tests { let _ = update_config(config); - let rpc_client = RpcClient::new("http://localhost:8899".to_string()); + let rpc_client = RpcClient::new_with_commitment( + "http://localhost:8899".to_string(), + CommitmentConfig::confirmed(), + ); let result = ConfigValidator::validate_with_result(&rpc_client, true).await; assert!(result.is_err()); let errors = result.unwrap_err(); diff --git a/makefiles/BUILD.makefile b/makefiles/BUILD.makefile index a9e9cc7c..7638df05 100644 --- a/makefiles/BUILD.makefile +++ b/makefiles/BUILD.makefile @@ -1,6 +1,6 @@ # install install: - cargo install --path crates/cli + cargo install --path crates/cli --bin kora # Check code formatting check: @@ -28,14 +28,6 @@ build-lib: build-cli: cargo build -p kora-cli -# Build tk-rs -build-tk: - cargo build -p tk-rs - -# Run presigned release binary -run-presigned: - cargo run --bin presigned - # Run with default configuration run: cargo run -p kora-cli --bin kora -- --config kora.toml --rpc-url http://127.0.0.1:8899 rpc start --signers-config $(TEST_SIGNERS_CONFIG) diff --git a/makefiles/CLIENT.makefile b/makefiles/CLIENT.makefile index 8600a5ac..cc0ebbfb 100644 --- a/makefiles/CLIENT.makefile +++ b/makefiles/CLIENT.makefile @@ -1,8 +1,6 @@ # Generate TypeScript client -gen-ts-client: - @echo "๐Ÿ”ง Generating OpenAPI spec with docs feature..." - cargo run -p kora-cli --bin kora --features docs -- openapi -o openapi.json +gen-ts-client: openapi @echo "๐Ÿš€ Generating TypeScript client..." docker run --rm -v "${PWD}:/local" openapitools/openapi-generator-cli generate \ -i /local/crates/lib/src/rpc_server/openapi/spec/combined_api.json \ diff --git a/makefiles/METRICS.makefile b/makefiles/METRICS.makefile index f9bb769f..0131ff83 100644 --- a/makefiles/METRICS.makefile +++ b/makefiles/METRICS.makefile @@ -12,6 +12,4 @@ update-metrics-config: # Run metrics (Prometheus + Grafana) - automatically updates config first run-metrics: update-metrics-config cd crates/lib/src/metrics && docker compose -f docker-compose.metrics.yml down - cd crates/lib/src/metrics && docker compose -f docker-compose.metrics.yml up - -# install ts sdk \ No newline at end of file + cd crates/lib/src/metrics && docker compose -f docker-compose.metrics.yml up \ No newline at end of file diff --git a/makefiles/RUST_TESTS.makefile b/makefiles/RUST_TESTS.makefile index 5e6decfb..e1e3a68e 100644 --- a/makefiles/RUST_TESTS.makefile +++ b/makefiles/RUST_TESTS.makefile @@ -5,62 +5,27 @@ test: # Build transfer hook program (is checked in, so only need to build if changes are made) build-transfer-hook: $(call print_header,BUILDING TRANSFER HOOK PROGRAM) - $(call print_step,Building transfer hook program...) cd tests/src/common/transfer-hook-example && \ chmod +x build.sh && \ ./build.sh $(call print_success,Transfer hook program built at tests/src/common/transfer-hook-example/target/deploy/) -# Run all integration tests with clean output +# Run all integration tests using new config-driven test runner test-integration: $(call print_header,KORA INTEGRATION TEST SUITE) - $(call print_step,Initializing test infrastructure) - @$(call start_solana_validator) - $(call print_substep,Setting up base test environment...) - @KORA_PRIVATE_KEY="$$(cat tests/src/common/local-keys/fee-payer-local.json)" cargo run -p tests --bin setup_test_env $(QUIET_OUTPUT) - $(call print_success,Infrastructure ready) - - @$(call run_integration_phase,1,RPC tests,$(REGULAR_CONFIG),,--test rpc,) - @$(call run_integration_phase,2,token tests,$(REGULAR_CONFIG),,--test tokens,) - @$(call run_integration_phase,3,external tests,$(REGULAR_CONFIG),,--test external,) - @$(call run_integration_phase,4,auth tests,$(AUTH_CONFIG),,--test auth,) - @$(call run_integration_phase,5,payment address tests,$(PAYMENT_ADDRESS_CONFIG),,--test payment_address,true) - @$(call run_multi_signer_phase,6,multi-signer tests,$(REGULAR_CONFIG),$(MULTI_SIGNERS_CONFIG),--test multi_signer) - - $(call print_header,TEST SUITE COMPLETE) - @$(call stop_solana_validator) - -# Individual test targets for development -test-regular: - $(call print_header,REGULAR TESTS) - @$(call start_solana_validator) - @cargo run -p tests --bin setup_test_env $(QUIET_OUTPUT) - $(call run_integration_phase,1,Regular Tests,$(REGULAR_CONFIG),,--test rpc,) - @$(call stop_solana_validator) - -test-token: - $(call print_header,TOKEN TESTS) - @$(call start_solana_validator) - @cargo run -p tests --bin setup_test_env $(QUIET_OUTPUT) - $(call run_integration_phase,1,Tokens Tests,$(REGULAR_CONFIG),,--test tokens,) - @$(call stop_solana_validator) - -test-auth: - $(call print_header,AUTHENTICATION TESTS) - @$(call start_solana_validator) - @cargo run -p tests --bin setup_test_env $(QUIET_OUTPUT) - $(call run_integration_phase,1,Authentication Tests,$(AUTH_CONFIG),,--test auth,) - @$(call stop_solana_validator) - -test-payment: - $(call print_header,PAYMENT ADDRESS TESTS) - @$(call start_solana_validator) - @cargo run -p tests --bin setup_test_env $(QUIET_OUTPUT) - $(call run_integration_phase,1,Payment Address Tests,$(PAYMENT_ADDRESS_CONFIG),,--test payment_address,true) - @$(call stop_solana_validator) - -test-multi-signer: - $(call print_header,MULTI-SIGNER TESTS) - @$(call start_solana_validator) - $(call run_multi_signer_phase,1,Multi-Signer Tests,$(REGULAR_CONFIG),$(MULTI_SIGNERS_CONFIG),--test multi-signers) - @$(call stop_solana_validator) \ No newline at end of file + @cargo run -p tests --bin test_runner + +# Verbose integration tests (shows detailed output) +test-integration-verbose: + $(call print_header,KORA INTEGRATION TEST SUITE - VERBOSE) + @cargo run -p tests --bin test_runner -- --verbose + +# Force refresh test accounts (ignore cached) +test-integration-fresh: + $(call print_header,KORA INTEGRATION TEST SUITE - FRESH SETUP) + @cargo run -p tests --bin test_runner -- --force-refresh + +# Run specific test phases with filters (for CI) +test-integration-filtered: + $(call print_header,KORA INTEGRATION TEST SUITE - FILTERED) + @cargo run -p tests --bin test_runner -- $(FILTERS) \ No newline at end of file diff --git a/makefiles/TYPESCRIPT_TESTS.makefile b/makefiles/TYPESCRIPT_TESTS.makefile index 16aeb1e8..789b6717 100644 --- a/makefiles/TYPESCRIPT_TESTS.makefile +++ b/makefiles/TYPESCRIPT_TESTS.makefile @@ -1,46 +1,9 @@ -# Run TypeScript SDK tests with local validator and Kora node +# TypeScript SDK Tests +# NOTE: TypeScript integration tests are now integrated into the main test runner +# Use 'make test-integration' to run all tests including TypeScript phases + test-ts-unit: - @printf "Running TypeScript SDK tests (unit tests)...\n" + @printf "Running TypeScript SDK unit tests...\n" -@cd sdks/ts && pnpm test:unit - -test-ts-integration-basic: - @$(call start_solana_validator) - @$(call start_kora_server,node for TS tests,cargo run,,$(REGULAR_CONFIG),) - @printf "Running TypeScript SDK tests (basic config)...\n" - -@cd sdks/ts && pnpm test:integration - @$(call stop_kora_server) - @$(call stop_solana_validator) - -# Run TypeScript auth tests specifically (using integration tests with auth enabled) -test-ts-integration-auth: - @$(call start_solana_validator) - @$(call start_kora_server,node for TS auth tests,cargo run,,$(AUTH_CONFIG),) - @printf "Running TypeScript SDK auth tests...\n" - -@cd sdks/ts && pnpm test:integration:auth - @$(call stop_kora_server) - @$(call stop_solana_validator) - -# Run TypeScript tests with Turnkey signer -test-ts-integration-turnkey: - @$(call start_solana_validator) - @$(call start_kora_server,node for TS Turnkey tests,cargo run,,$(REGULAR_CONFIG),,$(TEST_SIGNERS_TURNKEY_CONFIG)) - @printf "Running TypeScript SDK tests with Turnkey signer...\n" - -@cd sdks/ts && pnpm test:integration:turnkey - @$(call stop_kora_server) - @$(call stop_solana_validator) - -# Run TypeScript tests with Privy signer -test-ts-integration-privy: - @$(call start_solana_validator) - @$(call start_kora_server,node for TS Privy tests,cargo run,,$(REGULAR_CONFIG),,$(TEST_SIGNERS_PRIVY_CONFIG)) - @printf "Running TypeScript SDK tests with Privy signer...\n" - -@cd sdks/ts && pnpm test:integration:privy - @$(call stop_kora_server) - @$(call stop_solana_validator) - -# Run all signer tests -test-ts-signers: test-ts-integration-turnkey test-ts-integration-privy - -# Run all TypeScript SDK tests (no signers b/c api rate limits) -test-ts: test-ts-unit test-ts-integration-basic test-ts-integration-auth # test-ts-signers +test-ts: test-ts-unit diff --git a/makefiles/UTILS.makefile b/makefiles/UTILS.makefile index 83679183..4a1dc228 100644 --- a/makefiles/UTILS.makefile +++ b/makefiles/UTILS.makefile @@ -1,32 +1,11 @@ # Color codes for terminal output -RED := \033[0;31m GREEN := \033[0;32m -YELLOW := \033[1;33m BLUE := \033[0;34m -MAGENTA := \033[0;35m -CYAN := \033[0;36m BOLD := \033[1m RESET := \033[0m -# Common configuration -TEST_PORT := 8080 -TEST_RPC_URL := http://127.0.0.1:8899 -TEST_SIGNERS_CONFIG := tests/src/common/fixtures/signers.toml -TEST_SIGNERS_TURNKEY_CONFIG := tests/src/common/fixtures/signers-turnkey.toml -TEST_SIGNERS_PRIVY_CONFIG := tests/src/common/fixtures/signers-privy.toml -MULTI_SIGNERS_CONFIG := tests/src/common/fixtures/multi-signers.toml -REGULAR_CONFIG := tests/src/common/fixtures/kora-test.toml -AUTH_CONFIG := tests/src/common/fixtures/auth-test.toml -PAYMENT_ADDRESS_CONFIG := tests/src/common/fixtures/paymaster-address-test.toml -TRANSFER_HOOK_PROGRAM_ID := Bcdikjss8HWzKEuj6gEQoFq9TCnGnk6v3kUnRU1gb6hA - -# CI-aware timeouts -VALIDATOR_TIMEOUT := $(if $(CI),20,30) -SERVER_TIMEOUT := $(if $(CI),20,30) - # Output control patterns QUIET_OUTPUT := >/dev/null 2>&1 -TEST_OUTPUT_FILTER := 2>&1 | grep -E "(test |running |ok$$|FAILED|failed|error:|Error:|ERROR)" | grep -v "running 0 tests" || true # Clean structured output functions define print_header @@ -35,187 +14,10 @@ define print_header @printf "================================================================================\n$(RESET)" endef -define print_phase - @printf "\n$(BOLD)$(CYAN)[Phase $(1)] $(2)$(RESET)\n" - @printf "$(CYAN)--------------------------------------------------------------------------------$(RESET)\n" -endef - -define print_step - @printf " $(GREEN)โ†’$(RESET) $(1)\n" -endef - -define print_substep - @printf " $(YELLOW)โ€ข$(RESET) $(1)\n" -endef - define print_success @printf " $(GREEN)โœ“$(RESET) $(1)\n" endef -define print_error - @printf " $(RED)โœ—$(RESET) $(1)\n" -endef - - -# Solana validator lifecycle management functions -define start_solana_validator - $(call print_step,Starting Solana test validator...) - @pkill -f "solana-test-validator" 2>/dev/null || true - @sleep 2 - @rm -rf test-ledger 2>/dev/null || true - @if [ -f "tests/src/common/transfer-hook-example/transfer_hook_example.so" ]; then \ - printf " $(YELLOW)โ€ข$(RESET) Loading transfer hook program: $(TRANSFER_HOOK_PROGRAM_ID)\\n"; \ - printf " $(YELLOW)โ€ข$(RESET) Program file: tests/src/common/transfer-hook-example/transfer_hook_example.so\\n"; \ - solana-test-validator --reset --quiet --bpf-program $(TRANSFER_HOOK_PROGRAM_ID) tests/src/common/transfer-hook-example/transfer_hook_example.so $(QUIET_OUTPUT) & \ - else \ - printf " $(RED)โœ—$(RESET) Transfer hook program not found, starting validator without it\\n"; \ - solana-test-validator --reset --quiet $(QUIET_OUTPUT) & \ - fi - @echo $$! > .validator.pid - @counter=0; \ - while [ $$counter -lt $(VALIDATOR_TIMEOUT) ]; do \ - if solana cluster-version --url $(TEST_RPC_URL) >/dev/null 2>&1; then \ - break; \ - fi; \ - sleep 1; \ - counter=$$((counter + 1)); \ - done; \ - if [ $$counter -eq $(VALIDATOR_TIMEOUT) ]; then \ - printf " $(RED)โœ—$(RESET) Validator failed to start\n"; \ - exit 1; \ - fi - $(call print_substep,Validator ready on port 8899) -endef - -define stop_solana_validator - @if [ -f .validator.pid ]; then \ - PID=$$(cat .validator.pid); \ - if kill -0 $$PID 2>/dev/null; then \ - kill $$PID 2>/dev/null || true; \ - sleep 1; \ - kill -9 $$PID 2>/dev/null || true; \ - fi; \ - rm -f .validator.pid; \ - fi; \ - pkill -f "solana-test-validator" 2>/dev/null || true; \ - sleep 2; \ - rm -rf test-ledger 2>/dev/null || true -endef - -# Start Kora server with flexible configuration -# Usage: $(call start_kora_server,description,cargo_cmd,cargo_flags,config_file,setup_env,signers_config) -define start_kora_server - @$(call stop_kora_server) - @$(if $(5),\ - printf " $(YELLOW)โ€ข$(RESET) Setting up test environment...\n"; \ - KORA_PRIVATE_KEY="$$(cat tests/src/common/local-keys/fee-payer-local.json)" cargo run -p tests --bin setup_test_env $(QUIET_OUTPUT) || exit 1;) - $(call print_substep,Starting Kora server with $(1) configuration...) - @$(if $(2),\ - KORA_PRIVATE_KEY="$$(cat tests/src/common/local-keys/fee-payer-local.json)" $(2) -p kora-cli --bin kora $(3) -- --config $(4) --rpc-url $(TEST_RPC_URL) rpc start --signers-config $(or $(6),$(TEST_SIGNERS_CONFIG)) --port $(TEST_PORT) $(QUIET_OUTPUT) &,\ - make run $(QUIET_OUTPUT) &) - @echo $$! > .kora.pid - @counter=0; \ - while [ $$counter -lt $(SERVER_TIMEOUT) ]; do \ - if curl -s http://127.0.0.1:$(TEST_PORT)/liveness >/dev/null 2>&1; then \ - break; \ - fi; \ - sleep 1; \ - counter=$$((counter + 1)); \ - done; \ - if [ $$counter -eq $(SERVER_TIMEOUT) ]; then \ - printf " $(RED)โœ—$(RESET) Kora server failed to start\n"; \ - if [ -f .kora.pid ]; then \ - printf " $(YELLOW)โ€ข$(RESET) PID: $$(cat .kora.pid)\n"; \ - fi; \ - exit 1; \ - fi - $(call print_substep,Server ready on port $(TEST_PORT)) -endef - -# Server lifecycle management functions -define stop_kora_server - @if [ -f .kora.pid ]; then \ - PID=$$(cat .kora.pid); \ - if kill -0 $$PID 2>/dev/null; then \ - kill -TERM $$PID 2>/dev/null || true; \ - sleep 2; \ - if kill -0 $$PID 2>/dev/null; then \ - kill -9 $$PID 2>/dev/null || true; \ - fi; \ - fi; \ - rm -f .kora.pid; \ - fi; \ - pkill -f "kora.*rpc.*start" 2>/dev/null || true; \ - sleep 1; \ - lsof -ti:$(TEST_PORT) | xargs kill -9 2>/dev/null || true; \ - sleep 1 -endef - -define run_integration_phase - $(call print_phase,$(1),$(2)) - $(call print_step,Configuring test environment) - @$(call start_kora_server,$(2),cargo run,,$(3),$(4),$(7)) - @$(if $(6),\ - printf " $(YELLOW)โ€ข$(RESET) Initializing payment ATAs...\n"; \ - KORA_PRIVATE_KEY="$$(cat tests/src/common/local-keys/fee-payer-local.json)" cargo run -p kora-cli --bin kora -- --config $(3) --rpc-url $(TEST_RPC_URL) rpc initialize-atas --signers-config $(or $(7),$(TEST_SIGNERS_CONFIG)) $(QUIET_OUTPUT) || exit 1; \ - printf " $(YELLOW)โ€ข$(RESET) Payment ATAs ready\n";) - $(call print_step,Running tests...) - @cargo test -p tests --quiet $(5) -- --nocapture $(QUIET_OUTPUT) || exit 1 - @printf " $(GREEN)โœ“$(RESET) Tests passed\n" - @$(call stop_kora_server) - $(call print_success,Phase $(1) complete) -endef - -define run_multi_signer_phase - $(call print_phase,$(1),$(2)) - @$(call stop_kora_server) - @if [ ! -f "tests/src/common/local-keys/fee-payer-local.json" ]; then \ - $(call print_error,fee-payer-local.json not found); \ - exit 1; \ - fi - @if [ ! -f "tests/src/common/local-keys/signer2-local.json" ]; then \ - $(call print_error,signer2-local.json not found); \ - printf " Create it with: solana-keygen new --outfile tests/src/common/local-keys/signer2-local.json\n"; \ - exit 1; \ - fi - $(call print_step,Configuring multi-signer environment) - $(call print_substep,Setting up test accounts...) - @KORA_PRIVATE_KEY="$$(cat tests/src/common/local-keys/fee-payer-local.json)" \ - KORA_PRIVATE_KEY_2="$$(cat tests/src/common/local-keys/signer2-local.json)" \ - cargo run -p tests --bin setup_test_env $(QUIET_OUTPUT) || exit 1 - $(call print_substep,Starting server with multi-signer configuration...) - @KORA_PRIVATE_KEY="$$(cat tests/src/common/local-keys/fee-payer-local.json)" \ - KORA_PRIVATE_KEY_2="$$(cat tests/src/common/local-keys/signer2-local.json)" \ - cargo run -p kora-cli --bin kora -- --config $(3) --rpc-url $(TEST_RPC_URL) rpc start --signers-config $(4) --port $(TEST_PORT) $(QUIET_OUTPUT) & - @echo $$! > .kora.pid - @sleep 5 - $(call print_substep,Server ready on port $(TEST_PORT)) - $(call print_step,Running tests...) - @cargo test -p tests --quiet $(5) -- --nocapture $(QUIET_OUTPUT) || exit 1 - @printf " $(GREEN)โœ“$(RESET) Tests passed\n" - @$(call stop_kora_server) - $(call print_success,Phase $(1) complete) -endef - -# Setup test environment -setup-test-env: - $(call print_step,Setting up test environment...) - @KORA_PRIVATE_KEY="$$(cat tests/src/common/local-keys/fee-payer-local.json)" \ - cargo run -p tests --bin setup_test_env $(QUIET_OUTPUT) - $(call print_success,Test environment ready) - -# Clean up any running validators -clean-validator: - @$(call stop_solana_validator) - -# Clean up any running Kora nodes -clean-kora: - @$(call stop_kora_server) - -# Clean up both validator and Kora node -clean-test-env: clean-validator clean-kora - $(call print_success,Test environment cleaned up) - # Generate a random key that can be used as an API key or as an HMAC secret generate-key: @openssl rand -hex 32 \ No newline at end of file diff --git a/sdks/ts/src/client.ts b/sdks/ts/src/client.ts index 258daecc..6b4a65ce 100644 --- a/sdks/ts/src/client.ts +++ b/sdks/ts/src/client.ts @@ -97,12 +97,7 @@ export class KoraClient { const response = await fetch(this.rpcUrl, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method, - params, - } as RpcRequest), + body, }); const json = (await response.json()) as { error?: RpcError; result: T }; diff --git a/sdks/ts/test/auth-setup.ts b/sdks/ts/test/auth-setup.ts index cf189398..a60ae27b 100644 --- a/sdks/ts/test/auth-setup.ts +++ b/sdks/ts/test/auth-setup.ts @@ -1,10 +1,13 @@ import { KoraClient } from '../src/index.js'; +import { loadEnvironmentVariables } from './setup.js'; + +export function runAuthenticationTests() { + const { koraRpcUrl } = loadEnvironmentVariables(); -export function runAuthenticationTests(rpcUrl: string = 'http://localhost:8080/') { describe('Authentication', () => { it('should fail with incorrect API key', async () => { const client = new KoraClient({ - rpcUrl, + rpcUrl: koraRpcUrl, apiKey: 'WRONG-API-KEY', }); @@ -14,7 +17,7 @@ export function runAuthenticationTests(rpcUrl: string = 'http://localhost:8080/' it('should fail with incorrect HMAC secret', async () => { const client = new KoraClient({ - rpcUrl, + rpcUrl: koraRpcUrl, hmacSecret: 'WRONG-HMAC-SECRET', }); @@ -24,7 +27,7 @@ export function runAuthenticationTests(rpcUrl: string = 'http://localhost:8080/' it('should fail with both incorrect credentials', async () => { const client = new KoraClient({ - rpcUrl, + rpcUrl: koraRpcUrl, apiKey: 'WRONG-API-KEY', hmacSecret: 'WRONG-HMAC-SECRET', }); @@ -35,7 +38,7 @@ export function runAuthenticationTests(rpcUrl: string = 'http://localhost:8080/' it('should succeed with correct credentials', async () => { const client = new KoraClient({ - rpcUrl, + rpcUrl: koraRpcUrl, apiKey: 'test-api-key-123', hmacSecret: 'test-hmac-secret-456', }); @@ -49,7 +52,7 @@ export function runAuthenticationTests(rpcUrl: string = 'http://localhost:8080/' it('should fail when no credentials provided but auth is required', async () => { const client = new KoraClient({ - rpcUrl, + rpcUrl: koraRpcUrl, }); // No credentials should fail when auth is enabled diff --git a/sdks/ts/test/integration.test.ts b/sdks/ts/test/integration.test.ts index efa47c24..72c1f3b2 100644 --- a/sdks/ts/test/integration.test.ts +++ b/sdks/ts/test/integration.test.ts @@ -29,6 +29,7 @@ describe(`KoraClient Integration Tests (${AUTH_ENABLED ? 'with auth' : 'without let destinationAddress: Address; let usdcMint: Address; let koraAddress: Address; + let koraRpcUrl: string; beforeAll(async () => { const testSuite = await setupTestSuite(); @@ -38,11 +39,12 @@ describe(`KoraClient Integration Tests (${AUTH_ENABLED ? 'with auth' : 'without destinationAddress = testSuite.destinationAddress; usdcMint = testSuite.usdcMint; koraAddress = testSuite.koraAddress; + koraRpcUrl = testSuite.koraRpcUrl; }, 90000); // allow adequate time for airdrops and token initialization // Run authentication tests only when auth is enabled if (AUTH_ENABLED) { - runAuthenticationTests('http://localhost:8080/'); + runAuthenticationTests(); } describe('Configuration and Setup', () => { diff --git a/sdks/ts/test/setup.ts b/sdks/ts/test/setup.ts index 3fd17175..eb9ea144 100644 --- a/sdks/ts/test/setup.ts +++ b/sdks/ts/test/setup.ts @@ -60,7 +60,7 @@ const DEFAULTS = { // DO NOT USE THESE KEYPAIRS IN PRODUCTION, TESTING KEYPAIRS ONLY KORA_ADDRESS: '7AqpcUvgJ7Kh1VmJZ44rWp2XDow33vswo9VK9VqpPU2d', // Make sure this matches the kora-rpc signer address on launch (root .env) - SENDER_SECRET: '3Tdt5TrRGJYPbTo8zZAscNTvgRGnCLM854tCpxapggUazqdYn6VQRQ9DqNz1UkEfoPCYKj6PwSwCNtckGGvAKugb', + SENDER_SECRET: 'tzgfgSWTE3KUA6qfRoFYLaSfJm59uUeZRDy4ybMrLn1JV2drA1mftiaEcVFvq1Lok6h6EX2C4Y9kSKLvQWyMpS5', // HhA5j2rRiPbMrpF2ZD36r69FyZf3zWmEHRNSZbbNdVjf TEST_USDC_MINT_SECRET: '59kKmXphL5UJANqpFFjtH17emEq3oRNmYsx6a3P3vSGJRmhMgVdzH77bkNEi9bArRViT45e8L2TsuPxKNFoc3Qfg', // Make sure this matches the USDC mint in kora.toml (9BgeTKqmFsPVnfYscfM6NvsgmZxei7XfdciShQ6D3bxJ) DESTINATION_ADDRESS: 'AVmDft8deQEo78bRKcGN5ZMf3hyjeLBK4Rd4xGB46yQM', KORA_SIGNER_TYPE: 'memory', // Default signer type @@ -68,6 +68,7 @@ const DEFAULTS = { interface TestSuite { koraClient: KoraClient; + koraRpcUrl: string; testWallet: KeyPairSigner; usdcMint: Address; destinationAddress: Address; @@ -85,7 +86,7 @@ const createKeyPairSignerFromB58Secret = async (b58Secret: string) => { return await createKeyPairSignerFromBytes(b58SecretEncoded); }; // TODO Add KORA_PRIVATE_KEY_2= support for multi-signer configs -function loadEnvironmentVariables() { +export function loadEnvironmentVariables() { const koraSignerType = process.env.KORA_SIGNER_TYPE || DEFAULTS.KORA_SIGNER_TYPE; let koraAddress = process.env.KORA_ADDRESS; @@ -283,7 +284,8 @@ async function initializeToken({ ), ) : []; - const instructions = [...baseInstructions, ...otherAtaInstructions]; + const alreadyExists = await mintExists(client, mint.address); + let instructions = alreadyExists ? [...otherAtaInstructions] : [...baseInstructions, ...otherAtaInstructions]; await sendAndConfirmInstructions(client, payer, instructions, 'Initialize token and ATAs', 'finalized'); } @@ -346,6 +348,7 @@ async function setupTestSuite(): Promise { return { koraClient: new KoraClient({ rpcUrl: koraRpcUrl, ...authConfig }), + koraRpcUrl, testWallet, usdcMint: usdcMint.address, destinationAddress, @@ -353,4 +356,13 @@ async function setupTestSuite(): Promise { }; } +const mintExists = async (client: Client, mint: Address) => { + try { + const mintAccount = await client.rpc.getAccountInfo(mint).send(); + return mintAccount.value !== null; + } catch (error) { + return false; + } +}; + export default setupTestSuite; diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 6ae195af..675d30d8 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -4,8 +4,8 @@ version = { workspace = true } edition = { workspace = true } [[bin]] -name = "setup_test_env" -path = "src/bin/setup_test_env.rs" +name = "test_runner" +path = "src/bin/test_runner.rs" [[test]] @@ -57,9 +57,10 @@ reqwest = { workspace = true } futures = { workspace = true } anyhow = { workspace = true } once_cell = "1.21.3" -clap = { workspace = true } +clap = { workspace = true, features = ["derive"] } toml = { workspace = true } solana-address-lookup-table-interface = { workspace = true } colored = "3.0" serde = { workspace = true } base64 = { workspace = true } +chrono = { workspace = true } diff --git a/tests/examples/estimateTransactionFee.json b/tests/examples/estimateTransactionFee.json deleted file mode 100644 index 33365ab3..00000000 --- a/tests/examples/estimateTransactionFee.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "jsonrpc": "2.0", - "id": 1, - "method": "estimateTransactionFee", - "params": [ - "XGbYe8A2uT4oUB5s64hDQE8A7mqW3NTex9U5ugCTvGpnoYh8xgdyJStbuhhLYACHbsMyqVXRvQQ8hXyHsH2paP3vGLJp1RnfMnyv4BrFDx2h9hveJgGfi7u5Gy8TE8iD66bjucdxv3BTxmKyJknUq9cZyM4iKYmVMCmTyqi31mJvr1zcYuSouaAmNR5nGSifeV7pkJDuNQrVWv4wTmLkYHinT3Qq1R3RWxv4zVrSojn5eFktuLY8qaNq9bnHa4PrWzB2A9Qf9rKgErrrM3genmcuK9qRY9M99g4kGphADvaTfzmeZDxx8vY8X57TGahMAHrDn1P7yRmHF2mRtWmqAwhjadqhVaSwM5ZzuweR3Tt8guE7mvFyzLmURvpQfNCGTi65YjqEmubPDP9z2Ks4utPUKxcmQVSN4T8Qm9i4cc6qPRiVqDggPKMHX9M4hhg5Mnirxxg5kUAf8r9fjLVNyYei3TijAHmEYu8nFi1m65oHJR8v4HrF2dXJn8mvYRpLLGsGpEec8ZGrWS2RwNVahV8c1XeNAyxYSCXD6uHRLXz4ujjG3AN93wqPRfY9yvW3fV72oprJwbiUjPWvDqb3hXADPPvK5ZbDxnYqysQArwhkBZMzEPAM1SHTCtmbWaZ4b1v3DnvDGcZ6BUqHjR47Bt8GCbpygdcfCBNnThrAGMPHJUHQ15qWNQjXqbSNDbFZAfiRv4rbegotm79eh3mArM3rhQfWFaqee1TBRKTGR3PoPhFMUoyrsA4WYtBnVadtwkhzTZtP9Y9Dw7pCwHohQqMuw5R41FgwKmAcJhfyVVqCNYgFJnudUXKK9T5iepyTCPeaJcWKwZMitauwtYtBv4t8yqvh93py1SN6RWTSmZcJT3DurPmtbyb1Seh6QebZxrc7NAvGhQYeZvqXBN2yHTCdRDHehfp8mDmK3e43h3VxB9DXw58GGYw9X8LhzhMJoHDVtiNRJRGyyF7tc8CbrBtsFEGRmWw1nFDn1wQsSkY833fcoBj2CJA9LexDBuKwpi2pSrMnEEiUDmjsuGU3yDW8Bm4pufeyHc4crhM4HHFYG1u8XghGH7zf9gLkkXHcVnqq1KWs4DtbzEuKY1Mr3vFJhYXY6v8Lu8gdiB9fb3aKoxC1yJrbP8nfDtgHNGkSRQJsszTu4hGm9uX2sK2MKMRS51NjtN1hEG5K5yDBVLjrqHUKBZRyEKAjGMfFRzJPCUR5RDgsimyJUqB3gra3xejF5Ny3csaoh9y8G2JdA4CkrZPBvpDFQqyBMUG5AUH7KkF5syiVTJ9nqkLsMa3xdmw4Ph", - "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" - ] -} \ No newline at end of file diff --git a/tests/examples/getBlockhash.json b/tests/examples/getBlockhash.json deleted file mode 100644 index 8b2f0a75..00000000 --- a/tests/examples/getBlockhash.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getBlockhash", - "params": [] -} \ No newline at end of file diff --git a/tests/examples/getConfig.json b/tests/examples/getConfig.json deleted file mode 100644 index 997b7545..00000000 --- a/tests/examples/getConfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getConfig", - "params": [] -} \ No newline at end of file diff --git a/tests/examples/getEnabledFeatures.json b/tests/examples/getEnabledFeatures.json deleted file mode 100644 index a78d40bd..00000000 --- a/tests/examples/getEnabledFeatures.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getEnabledFeatures", - "params": [] -} diff --git a/tests/examples/getSupportedTokens.json b/tests/examples/getSupportedTokens.json deleted file mode 100644 index c50023c1..00000000 --- a/tests/examples/getSupportedTokens.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getSupportedTokens", - "params": [] -} diff --git a/tests/examples/signAndSendTransaction.json b/tests/examples/signAndSendTransaction.json deleted file mode 100644 index fc99d196..00000000 --- a/tests/examples/signAndSendTransaction.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "jsonrpc": "2.0", - "id": 1, - "method": "signAndSendTransaction", - "params": [ - "3md7BBV9wFjYGnMWcMNyAZcjca2HGfXWZkrU8vvho66z2sJMZFcx6HZdBiAddjo2kzgBv3uZoac3domBRjJJSXkbBvokxSKZJ1JkQwVSYyKhcnWsgMzvEfJBaSjk7jdz8updcXb8j1Ejz1yWaZgeuV6wy4pncAFHDybqUih4TmRgcbARs5t3ZXRNTDqedyMiJUg5Jz1Apt2ohLsffqjnAhrbC41xrphouRDPx356JNz5MjKi4iLkBnRHfUMJiDxtbXCYNTe257PoASUU2v12WDYFheGKzHP8ctmN7" - ] -} \ No newline at end of file diff --git a/tests/examples/signTransaction.json b/tests/examples/signTransaction.json deleted file mode 100644 index 7c75bb55..00000000 --- a/tests/examples/signTransaction.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "jsonrpc": "2.0", - "id": 1, - "method": "signTransaction", - "params": [ - "3md7BBV9wFjYGnMWcMNyAZcjca2HGfXWZkrU8vvho66z2sJMZFcx6HZdBiAddjo2kzgBv3uZoac3domBRjJJSXkbBvokxSKZJ1JkQwVSYyKhcnWsgMzvEfJBaSjk7jdz8updcXb8j1Ejz1yWaZgeuV6wy4pncAFHDybqUih4TmRgcbARs5t3ZXRNTDqedyMiJUg5Jz1Apt2ohLsffqjnAhrbC41xrphouRDPx356JNz5MjKi4iLkBnRHfUMJiDxtbXCYNTe257PoASUU2v12WDYFheGKzHP8ctmN7" - ] -} \ No newline at end of file diff --git a/tests/examples/transferTransaction.json b/tests/examples/transferTransaction.json deleted file mode 100644 index 9ad4e535..00000000 --- a/tests/examples/transferTransaction.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "jsonrpc": "2.0", - "id": 1, - "method": "transferTransaction", - "params": [ - 1000000, - "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "5KKsLVU6TcbVDK4BS6K1DGDxnh4Q9xjYJ8XaDCG5t8ht", - "AVmDft8deQEo78bRKcGN5ZMf3hyjeLBK4Rd4xGB46yQM" - ] -} \ No newline at end of file diff --git a/tests/payment_address/payment_address_v0_tests.rs b/tests/payment_address/payment_address_v0_tests.rs index d60fe765..bd8c1c40 100644 --- a/tests/payment_address/payment_address_v0_tests.rs +++ b/tests/payment_address/payment_address_v0_tests.rs @@ -1,9 +1,6 @@ use crate::common::*; use jsonrpsee::rpc_params; -use kora_lib::{ - token::{TokenInterface, TokenProgram}, - transaction::{TransactionUtil, VersionedTransactionOps}, -}; +use kora_lib::token::{TokenInterface, TokenProgram}; use solana_sdk::{ pubkey::Pubkey, signature::{Keypair, Signer}, @@ -12,6 +9,7 @@ use spl_associated_token_account::{ get_associated_token_address, instruction::create_associated_token_account_idempotent, }; use std::str::FromStr; +use tests::common::helpers::get_fee_for_default_transaction_in_usdc; #[tokio::test] async fn test_sign_transaction_if_paid_with_payment_address_v0() { diff --git a/tests/rpc/transaction_signing.rs b/tests/rpc/transaction_signing.rs index 31baf657..bc5af31e 100644 --- a/tests/rpc/transaction_signing.rs +++ b/tests/rpc/transaction_signing.rs @@ -4,7 +4,7 @@ use crate::common::*; use jsonrpsee::rpc_params; use kora_lib::transaction::TransactionUtil; use solana_sdk::{pubkey::Pubkey, signature::Signer}; -use spl_associated_token_account::get_associated_token_address; +use tests::common::helpers::get_fee_for_default_transaction_in_usdc; // ************************************************************************************** // Sign transaction tests @@ -342,7 +342,8 @@ async fn test_sign_transaction_v0_with_invalid_lookup_table() { .with_fee_payer(FeePayerTestHelper::get_fee_payer_pubkey()) .with_transfer( &SenderTestHelper::get_test_sender_keypair().pubkey(), - &LookupTableHelper::get_test_disallowed_address(), + &LookupTableHelper::get_test_disallowed_address() + .expect("Failed to get test disallowed address"), 10, ) .build() diff --git a/tests/src/bin/setup_test_env.rs b/tests/src/bin/setup_test_env.rs deleted file mode 100644 index 1820f77f..00000000 --- a/tests/src/bin/setup_test_env.rs +++ /dev/null @@ -1,6 +0,0 @@ -use tests::setup_test_env; - -#[tokio::main] -async fn main() -> Result<(), Box> { - setup_test_env::run().await -} diff --git a/tests/src/bin/test_runner.rs b/tests/src/bin/test_runner.rs new file mode 100644 index 00000000..1786e0b9 --- /dev/null +++ b/tests/src/bin/test_runner.rs @@ -0,0 +1,507 @@ +use clap::Parser; +use solana_client::nonblocking::rpc_client::RpcClient; +use solana_commitment_config::CommitmentConfig; +use std::{collections::HashMap, sync::Arc, time::Instant}; +use tests::{ + common::{constants::DEFAULT_RPC_URL, setup::TestAccountSetup, TestAccountInfo}, + test_runner::{ + accounts::{ + download_accounts, set_environment_variables, set_lookup_table_environment_variables, + AccountFile, + }, + commands::{TestCommandHelper, TestLanguage}, + config::{TestPhaseConfig, TestRunnerConfig}, + kora::{ + get_kora_binary_path, is_kora_running_with_client, release_port, start_kora_rpc_server, + }, + output::{ + filter_command_output, limit_output_size, OutputFilter, PhaseOutput, TestPhaseColor, + }, + validator::start_test_validator, + }, +}; +use tokio::{process::Child, task::JoinSet}; + +pub struct TestRunner { + pub rpc_client: Arc, + pub reqwest_client: reqwest::Client, + pub solana_test_validator_pid: Option, + pub test_accounts: TestAccountInfo, + pub kora_pids: Vec, + pub cached_keys: Arc>, +} + +impl TestRunner { + pub async fn new(rpc_url: String) -> Result> { + let mut cached_keys = HashMap::new(); + + // Cache all required keys + for &account_file in AccountFile::required_for_kora() { + let key = tokio::fs::read_to_string(account_file.local_key_path()).await?; + cached_keys.insert(account_file, key); + } + + Ok(Self { + rpc_client: Arc::new(RpcClient::new_with_commitment( + rpc_url, + CommitmentConfig::confirmed(), + )), + reqwest_client: reqwest::Client::new(), + solana_test_validator_pid: None, + test_accounts: TestAccountInfo::default(), + kora_pids: Vec::new(), + cached_keys: Arc::new(cached_keys), + }) + } + + pub fn get_cached_key( + &self, + account_file: AccountFile, + ) -> Result<&str, Box> { + self.cached_keys + .get(&account_file) + .map(|s| s.as_str()) + .ok_or_else(|| format!("Key not found in cache: {account_file:?}").into()) + } +} + +/* +CLI +*/ +#[derive(Parser, Debug)] +#[command(name = "test_runner")] +#[command(about = "Kora integration test runner with configurable options")] +pub struct Args { + /// Enable verbose output showing detailed test information + #[arg(long, help = "Enable verbose output")] + pub verbose: bool, + + /// RPC URL to use for Solana connection + #[arg( + long, + default_value = DEFAULT_RPC_URL, + help = "Solana RPC URL to connect to" + )] + pub rpc_url: String, + + /// Force refresh of test accounts, ignoring cached versions + #[arg(long, help = "Skip loading cached accounts and setup test environment from scratch")] + pub force_refresh: bool, + + /// Test configuration file + #[arg( + long, + default_value = "tests/src/test_runner/test_cases.toml", + help = "Path to test configuration file" + )] + pub config: String, + + /// Run only specific test phases (can be used multiple times) + #[arg( + long = "filter", + help = "Run only specific test phases (e.g., --filter regular --filter auth)" + )] + pub filters: Vec, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + let start_time = Instant::now(); + + println!("๐Ÿš€ Starting test runner at {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")); + + let mut test_runner = TestRunner::new(args.rpc_url.clone()).await?; + let custom_rpc_url = args.rpc_url != DEFAULT_RPC_URL; + + let (result, completed_phases) = async { + setup_test_env(&mut test_runner, args.force_refresh, custom_rpc_url).await?; + let phases = + run_all_test_phases(&test_runner, args.verbose, &args.config, &args.filters).await?; + Ok::>(phases) + } + .await + .map_or_else(|e| (Err(e), 0), |phases| (Ok(()), phases)); + + clean_up(&mut test_runner).await?; + + let total_duration = start_time.elapsed(); + println!( + "โœ… Test runner completed at {} ({} phases, Total time: {:.2}s)", + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), + completed_phases, + total_duration.as_secs_f64() + ); + + result +} + +/* +Setting up test environment +*/ + +pub async fn setup_test_env_from_scratch( +) -> Result> { + let mut setup = TestAccountSetup::new().await; + let test_accounts = setup.setup_all_accounts().await?; + + Ok(test_accounts) +} + +async fn setup_test_env( + test_runner: &mut TestRunner, + force_refresh: bool, + custom_rpc_url: bool, +) -> Result<(), Box> { + let mut found_all_accounts = !force_refresh; + + if !force_refresh { + for account_file in AccountFile::required_test_accounts() { + if !account_file.test_account_path().exists() { + found_all_accounts = false; + break; + } + } + } + + // Only start local validator if using default RPC URL + if !custom_rpc_url { + test_runner.solana_test_validator_pid = + Some(start_test_validator(found_all_accounts).await?); + } else { + println!("Using external RPC, skipping local validator startup"); + } + + set_environment_variables(&test_runner.cached_keys)?; + + test_runner.test_accounts = setup_test_env_from_scratch().await?; + + if !found_all_accounts { + download_accounts(&test_runner.rpc_client.clone(), &test_runner.test_accounts).await?; + } + set_lookup_table_environment_variables(&test_runner.test_accounts).await?; + + Ok(()) +} + +/* +Running Tests +*/ + +pub async fn run_all_test_phases( + test_runner: &TestRunner, + verbose: bool, + config_path: &str, + filters: &[String], +) -> Result> { + let rpc_url = test_runner.rpc_client.url(); + + // Load test configuration + let config = if std::path::Path::new(config_path).exists() { + println!("Loading test configuration from: {config_path}"); + TestRunnerConfig::load_from_file(config_path).await? + } else { + panic!("Test configuration file not found: {config_path}"); + }; + + let mut join_set = JoinSet::new(); + + // Spawn test phases from config (filtered if specified) + for (phase_name, phase_config) in config.get_all_phases() { + // Apply filter if specified + if !filters.is_empty() && !filters.contains(&phase_name) { + continue; + } + + join_set.spawn({ + let rpc_url = rpc_url.clone(); + let phase_config = phase_config.clone(); + let cached_keys = test_runner.cached_keys.clone(); + let http_client = test_runner.reqwest_client.clone(); + async move { + run_test_phase_from_config( + rpc_url, + &phase_config, + verbose, + cached_keys, + http_client, + ) + .await + } + }); + } + + // Stream output as each test completes instead of waiting for all + let mut all_success = true; + let mut errors = Vec::new(); + let mut completed_phases = 0; + + while let Some(result) = join_set.join_next().await { + match result { + Ok(phase_output) => { + completed_phases += 1; + print!("{}", phase_output.output); + + if phase_output.truncated { + println!("โš ๏ธ Output truncated for phase '{}'", phase_output.phase_name); + } + + if !phase_output.success { + all_success = false; + } + } + Err(e) => { + println!("โŒ Task failed: {e}"); + errors.push(e); + all_success = false; + } + } + } + + if !errors.is_empty() { + return Err(format!("Multiple test phases failed: {errors:?}").into()); + } + + if !all_success { + return Err("One or more test phases failed".into()); + } + + Ok(completed_phases) +} + +async fn run_test_phase_from_config( + rpc_url: String, + config: &TestPhaseConfig, + verbose: bool, + cached_keys: Arc>, + http_client: reqwest::Client, +) -> PhaseOutput { + let test_names: Vec<&str> = config.tests.iter().map(|s| s.as_str()).collect(); + let preferred_port: u16 = config.port.parse().unwrap_or(8080); + + run_test_phase( + &config.name, + rpc_url, + &config.config, + &config.signers, + test_names, + config.initialize_payments_atas, + verbose, + cached_keys, + http_client, + preferred_port, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn run_test_phase( + phase_name: &str, + rpc_url: String, + config_file: &str, + signers_config: &str, + test_names: Vec<&str>, + initialize_payment_atas: bool, + verbose: bool, + cached_keys: Arc>, + http_client: reqwest::Client, + preferred_port: u16, +) -> PhaseOutput { + let color = TestPhaseColor::from_phase_name(phase_name); + let mut output = String::new(); + + output + .push_str(&color.colorize_with_controlled_flow(&format!("=== Starting {phase_name} ==="))); + + let (mut kora_pid, actual_port) = match start_kora_rpc_server( + rpc_url.clone(), + config_file, + signers_config, + &cached_keys, + preferred_port, + ) + .await + { + Ok((pid, port)) => (pid, port), + Err(e) => { + output.push_str( + &color.colorize_with_controlled_flow(&format!("Failed to start Kora server: {e}")), + ); + let (limited_output, truncated) = limit_output_size(output); + return PhaseOutput { + phase_name: phase_name.to_string(), + output: limited_output, + success: false, + truncated, + }; + } + }; + + let mut attempts = 0; + let mut delay = std::time::Duration::from_millis(50); + let max_delay = std::time::Duration::from_secs(1); + let max_attempts = 10; + let port_str = actual_port.to_string(); + + while !is_kora_running_with_client(&http_client, &port_str).await { + attempts += 1; + if attempts > max_attempts { + output.push_str(&color.colorize_with_controlled_flow(&format!( + "Kora server failed to start on port {actual_port} within {max_attempts} attempts" + ))); + kora_pid.kill().await.ok(); + release_port(actual_port); + let (limited_output, truncated) = limit_output_size(output); + return PhaseOutput { + phase_name: phase_name.to_string(), + output: limited_output, + success: false, + truncated, + }; + } + + tokio::time::sleep(delay).await; + delay = std::cmp::min(delay * 2, max_delay); + } + output.push_str( + &color.colorize_with_controlled_flow(&format!("Kora server started on port {actual_port}")), + ); + + let result = async { + if initialize_payment_atas { + run_initialize_atas_for_kora_cli_tests_buffered( + config_file, + &rpc_url, + signers_config, + color, + &mut output, + &cached_keys, + ) + .await? + } + + for test_name in test_names { + output.push_str(&color.colorize_with_controlled_flow(&format!( + "Running {test_name} tests on port {actual_port}" + ))); + if test_name.starts_with("typescript_") { + TestCommandHelper::run_test( + TestLanguage::TypeScript, + test_name, + &port_str, + color, + verbose, + &mut output, + ) + .await?; + } else { + TestCommandHelper::run_test( + TestLanguage::Rust, + test_name, + &port_str, + color, + verbose, + &mut output, + ) + .await? + } + } + + Ok::<(), Box>(()) + } + .await; + + kora_pid.kill().await.ok(); + release_port(actual_port); + + let success = result.is_ok(); + match &result { + Ok(_) => output.push_str( + &color.colorize_with_controlled_flow(&format!("\n\n=== Completed {phase_name} ===")), + ), + Err(e) => output.push_str(&color.colorize_with_controlled_flow(&format!( + "\n\n=== Failed {phase_name} - Error: {e} ===" + ))), + } + + let (limited_output, truncated) = limit_output_size(output); + PhaseOutput { phase_name: phase_name.to_string(), output: limited_output, success, truncated } +} + +pub async fn run_initialize_atas_for_kora_cli_tests_buffered( + config_file: &str, + rpc_url: &str, + signers_config: &str, + color: TestPhaseColor, + output: &mut String, + cached_keys: &Arc>, +) -> Result<(), Box> { + output.push_str(&color.colorize_with_controlled_flow("โ€ข Initializing payment ATAs...")); + + let fee_payer_key = + cached_keys.get(&AccountFile::FeePayer).ok_or("FeePayer key not found in cache")?; + + let kora_binary_path = get_kora_binary_path().await?; + + let cmd_output = tokio::process::Command::new(kora_binary_path) + .args([ + "--config", + config_file, + "--rpc-url", + rpc_url, + "rpc", + "initialize-atas", + "--signers-config", + signers_config, + ]) + .env("KORA_PRIVATE_KEY", fee_payer_key.trim()) + .output() + .await?; + + if !cmd_output.status.success() { + let stderr = String::from_utf8_lossy(&cmd_output.stderr); + let filtered_stderr = filter_command_output(&stderr, OutputFilter::CliCommand, false); + if !filtered_stderr.is_empty() { + output.push_str(&filtered_stderr); + } + return Err("Failed to initialize payment ATAs".into()); + } + + let stdout = String::from_utf8_lossy(&cmd_output.stdout); + let filtered_stdout = filter_command_output(&stdout, OutputFilter::CliCommand, false); + if !filtered_stdout.is_empty() { + output.push_str(&filtered_stdout); + } + output.push_str(&color.colorize_with_controlled_flow("โ€ข Payment ATAs ready")); + + Ok(()) +} + +/* +Clean up +*/ +pub async fn clean_up( + test_runner: &mut TestRunner, +) -> Result<(), Box> { + println!("=== Cleaning up processes ==="); + + if let Some(solana_test_validator_pid) = &mut test_runner.solana_test_validator_pid { + if let Err(e) = solana_test_validator_pid.kill().await { + println!("Failed to stop Solana test validator: {e}"); + } else { + println!("Stopped Solana test validator"); + } + } + + // Kill tracked Kora processes (though they're managed locally in each test phase) + for kora_pid in &mut test_runner.kora_pids { + if let Err(e) = kora_pid.kill().await { + println!("Failed to stop Kora process: {e}"); + } else { + println!("Stopped Kora process"); + } + } + + println!("=== Cleanup complete ==="); + Ok(()) +} diff --git a/tests/src/common/auth_helpers.rs b/tests/src/common/auth_helpers.rs index 18cec01b..fe2a201b 100644 --- a/tests/src/common/auth_helpers.rs +++ b/tests/src/common/auth_helpers.rs @@ -1,13 +1,15 @@ +#![cfg(test)] + use hmac::{Hmac, Mac}; use kora_lib::constant::{X_HMAC_SIGNATURE, X_TIMESTAMP}; use once_cell::sync::Lazy; use serde_json::{json, Value}; use sha2::Sha256; -use crate::common::client::TestClient; - -pub const TEST_API_KEY: &str = "test-api-key-123"; -pub const TEST_HMAC_SECRET: &str = "test-hmac-secret-456"; +use crate::common::{ + client::TestClient, + constants::{TEST_API_KEY, TEST_HMAC_SECRET}, +}; pub static JSON_TEST_BODY: Lazy = Lazy::new(|| { json!({ diff --git a/tests/src/common/client.rs b/tests/src/common/client.rs index 3c281157..6e7c6aaf 100644 --- a/tests/src/common/client.rs +++ b/tests/src/common/client.rs @@ -1,12 +1,17 @@ +#![cfg(test)] + use anyhow::Result; use jsonrpsee::{ core::{client::ClientT, traits::ToRpcParams}, http_client::{HttpClient, HttpClientBuilder}, }; use solana_client::nonblocking::rpc_client::RpcClient; +use solana_commitment_config::CommitmentConfig; use std::sync::Arc; -use crate::common::{TransactionBuilder, DEFAULT_RPC_URL, TEST_SERVER_URL}; +use crate::common::{ + TransactionBuilder, DEFAULT_RPC_URL, RPC_URL_ENV, TEST_SERVER_URL, TEST_SERVER_URL_ENV, +}; /// Unified test client that manages both HTTP and RPC clients #[derive(Clone)] @@ -29,7 +34,10 @@ impl TestClient { .build(&server_url) .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {}", e))?; - let rpc_client = Arc::new(RpcClient::new(rpc_url.clone())); + let rpc_client = Arc::new(RpcClient::new_with_commitment( + rpc_url.clone(), + CommitmentConfig::confirmed(), + )); Ok(Self { http_client, rpc_client, server_url, rpc_url }) } @@ -49,13 +57,13 @@ impl TestClient { /// Get the default test server URL (Kora RPC server) pub fn get_default_server_url() -> String { dotenv::dotenv().ok(); - std::env::var("TEST_SERVER_URL").unwrap_or_else(|_| TEST_SERVER_URL.to_string()) + std::env::var(TEST_SERVER_URL_ENV).unwrap_or_else(|_| TEST_SERVER_URL.to_string()) } /// Get the default RPC URL (Solana RPC) pub fn get_default_rpc_url() -> String { dotenv::dotenv().ok(); - std::env::var("RPC_URL").unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()) + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()) } } diff --git a/tests/src/common/constants.rs b/tests/src/common/constants.rs index c003cda9..7fdf9ddd 100644 --- a/tests/src/common/constants.rs +++ b/tests/src/common/constants.rs @@ -1,9 +1,3 @@ -use solana_sdk::pubkey::Pubkey; -use std::str::FromStr; - -pub const LOOKUP_TABLES_FILE_PATH: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/src/common/fixtures/lookup_tables.json"); - // ============================================================================ // Network URLs // ============================================================================ @@ -14,42 +8,6 @@ pub const DEFAULT_RPC_URL: &str = "http://127.0.0.1:8899"; /// Default Kora test server URL pub const TEST_SERVER_URL: &str = "http://127.0.0.1:8080"; -// ============================================================================ -// Test Keypair Paths -// ============================================================================ - -/// Fee payer keypair path (local testing only) -pub const FEE_PAYER_KEYPAIR_PATH: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/src/common/local-keys/fee-payer-local.json"); - -/// Sender keypair path (local testing only) -pub const SENDER_KEYPAIR_PATH: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/src/common/local-keys/sender-local.json"); - -/// USDC mint keypair path (local testing only) -pub const USDC_MINT_KEYPAIR_PATH: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/src/common/local-keys/usdc-mint-local.json"); - -/// USDC mint 2022 keypair path (local testing only) -pub const USDC_MINT_2022_KEYPAIR_PATH: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/src/common/local-keys/usdc-mint-2022-local.json"); - -/// Interest bearing mint keypair path (local testing only) -pub const INTEREST_BEARING_MINT_KEYPAIR_PATH: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/src/common/local-keys/mint-2022-interest-bearing.json"); - -/// Transfer hook mint keypair path (local testing only) -pub const TRANSFER_HOOK_MINT_KEYPAIR_PATH: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/src/common/local-keys/mint-transfer-hook-local.json"); - -/// Second signer keypair path (for multi-signer tests) -pub const SIGNER2_KEYPAIR_PATH: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/src/common/local-keys/signer2-local.json"); - -/// Payment address keypair path (for payment address tests) -pub const PAYMENT_KEYPAIR_PATH: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/src/common/local-keys/payment-local.json"); - // ============================================================================ // Test Public Keys // ============================================================================ @@ -87,36 +45,50 @@ pub const TEST_API_KEY: &str = "test-api-key-123"; pub const TEST_HMAC_SECRET: &str = "test-hmac-secret-456"; // ============================================================================ -// Helper Functions +// Test Environment Variables // ============================================================================ -/// Get recipient pubkey as Pubkey type -pub fn get_recipient_pubkey() -> Pubkey { - Pubkey::from_str(RECIPIENT_PUBKEY).expect("Invalid recipient pubkey") -} - -/// Get test disallowed address as Pubkey type -pub fn get_test_disallowed_pubkey() -> Pubkey { - Pubkey::from_str(TEST_DISALLOWED_ADDRESS).expect("Invalid disallowed address") -} - -/// Get test payment address as Pubkey type -pub fn get_test_payment_pubkey() -> Pubkey { - Pubkey::from_str(TEST_PAYMENT_ADDRESS).expect("Invalid payment address") -} - -/// Get PYUSD mint as Pubkey type -pub fn get_pyusd_mint_pubkey() -> Pubkey { - Pubkey::from_str(PYUSD_MINT).expect("Invalid PYUSD mint") -} - -/// Default fee for a transaction with 2 signers (5000 lamports each) -/// This is used for a lot of tests that only has sender and fee payer as signers -pub fn get_fee_for_default_transaction_in_usdc() -> u64 { - // 10 000 USDC priced at default 0.001 SOL / USDC (Mock pricing) (6 decimals), so 0.01 USDC - // 10 000 lamports required (2 x 5000 for signatures) (9 decimals), so 0.00001 SOL - // - // Required SOL amount is 0.01 (usdc amount) * 0.001 (usdc price) = 0.00001 SOL - // Required lamports is 0.00001 SOL * 10^9 (lamports per SOL) = 10 000 lamports - 10_000 -} +/// Test server URL environment variable +pub const TEST_SERVER_URL_ENV: &str = "TEST_SERVER_URL"; + +/// RPC URL environment variable +pub const RPC_URL_ENV: &str = "RPC_URL"; + +/// KORA private key environment variable +pub const KORA_PRIVATE_KEY_ENV: &str = "KORA_PRIVATE_KEY"; + +/// Signer 2 private key environment variable +pub const SIGNER_2_KEYPAIR_ENV: &str = "SIGNER_2_KEYPAIR"; + +/// Test sender private key environment variable +pub const TEST_SENDER_KEYPAIR_ENV: &str = "TEST_SENDER_KEYPAIR"; + +/// Test recipient public key environment variable +pub const TEST_RECIPIENT_PUBKEY_ENV: &str = "TEST_RECIPIENT_PUBKEY"; + +/// Test USDC mint private key environment variable +pub const TEST_USDC_MINT_KEYPAIR_ENV: &str = "TEST_USDC_MINT_KEYPAIR"; + +/// Test USDC mint decimals environment variable +pub const TEST_USDC_MINT_DECIMALS_ENV: &str = "TEST_USDC_MINT_DECIMALS"; + +/// Test USDC mint 2022 private key environment variable +pub const TEST_USDC_MINT_2022_KEYPAIR_ENV: &str = "TEST_USDC_MINT_2022_KEYPAIR"; + +/// Test interest bearing mint private key environment variable +pub const TEST_INTEREST_BEARING_MINT_KEYPAIR_ENV: &str = "TEST_INTEREST_BEARING_MINT_KEYPAIR"; + +/// Test transfer hook mint private key environment variable +pub const TEST_TRANSFER_HOOK_MINT_KEYPAIR_ENV: &str = "TEST_TRANSFER_HOOK_MINT_KEYPAIR"; + +/// Payment address keypair environment variable +pub const PAYMENT_ADDRESS_KEYPAIR_ENV: &str = "PAYMENT_ADDRESS_KEYPAIR"; + +/// Test allowed lookup table address environment variable +pub const TEST_ALLOWED_LOOKUP_TABLE_ADDRESS_ENV: &str = "TEST_ALLOWED_LOOKUP_TABLE_ADDRESS"; + +/// Test disallowed lookup table address environment variable +pub const TEST_DISALLOWED_LOOKUP_TABLE_ADDRESS_ENV: &str = "TEST_DISALLOWED_LOOKUP_TABLE_ADDRESS"; + +/// Test transaction lookup table address environment variable +pub const TEST_TRANSACTION_LOOKUP_TABLE_ADDRESS_ENV: &str = "TEST_TRANSACTION_LOOKUP_TABLE_ADDRESS"; diff --git a/tests/src/common/helpers.rs b/tests/src/common/helpers.rs index 63e239e5..6874217e 100644 --- a/tests/src/common/helpers.rs +++ b/tests/src/common/helpers.rs @@ -8,21 +8,15 @@ use std::str::FromStr; use crate::common::constants::*; -/// Test account information for outputting to the user -#[derive(Debug)] -pub struct TestAccountInfo { - pub fee_payer_pubkey: Pubkey, - pub sender_pubkey: Pubkey, - pub recipient_pubkey: Pubkey, - pub usdc_mint_pubkey: Pubkey, - pub sender_token_account: Pubkey, - pub recipient_token_account: Pubkey, - pub fee_payer_token_account: Pubkey, - // Token 2022 fields - pub usdc_mint_2022_pubkey: Pubkey, - pub sender_token_2022_account: Pubkey, - pub recipient_token_2022_account: Pubkey, - pub fee_payer_token_2022_account: Pubkey, +/// Default fee for a transaction with 2 signers (5000 lamports each) +/// This is used for a lot of tests that only has sender and fee payer as signers +pub fn get_fee_for_default_transaction_in_usdc() -> u64 { + // 10 000 USDC priced at default 0.001 SOL / USDC (Mock pricing) (6 decimals), so 0.01 USDC + // 10 000 lamports required (2 x 5000 for signatures) (9 decimals), so 0.00001 SOL + // + // Required SOL amount is 0.01 (usdc amount) * 0.001 (usdc price) = 0.00001 SOL + // Required lamports is 0.00001 SOL * 10^9 (lamports per SOL) = 10 000 lamports + 10_000 } /// Helper function to parse a private key string in multiple formats. @@ -35,17 +29,29 @@ pub struct FeePayerTestHelper; impl FeePayerTestHelper { pub fn get_fee_payer_keypair() -> Keypair { dotenv::dotenv().ok(); - let private_key = match std::env::var("KORA_PRIVATE_KEY") { - Ok(key) => key, - Err(_) => std::fs::read_to_string(FEE_PAYER_KEYPAIR_PATH) - .expect("Failed to read fee payer private key file"), - }; - parse_private_key_string(&private_key).expect("Failed to parse fee payer private key") + parse_private_key_string( + &std::env::var(KORA_PRIVATE_KEY_ENV) + .expect("KORA_PRIVATE_KEY environment variable is not set"), + ) + .expect("Failed to parse fee payer private key") } pub fn get_fee_payer_pubkey() -> Pubkey { Self::get_fee_payer_keypair().pubkey() } + + pub fn get_signer_2_keypair() -> Keypair { + dotenv::dotenv().ok(); + parse_private_key_string( + &std::env::var(SIGNER_2_KEYPAIR_ENV) + .expect("SIGNER_2_KEYPAIR environment variable is not set"), + ) + .expect("Failed to parse signer 2 private key") + } + + pub fn get_signer_2_pubkey() -> Pubkey { + Self::get_signer_2_keypair().pubkey() + } } pub struct SenderTestHelper; @@ -53,12 +59,11 @@ pub struct SenderTestHelper; impl SenderTestHelper { pub fn get_test_sender_keypair() -> Keypair { dotenv::dotenv().ok(); - let private_key = match std::env::var("TEST_SENDER_KEYPAIR") { - Ok(key) => key, - Err(_) => std::fs::read_to_string(SENDER_KEYPAIR_PATH) - .expect("Failed to read sender private key file"), - }; - parse_private_key_string(&private_key).expect("Failed to parse test sender private key") + parse_private_key_string( + &std::env::var(TEST_SENDER_KEYPAIR_ENV) + .expect("TEST_SENDER_KEYPAIR environment variable is not set"), + ) + .expect("Failed to parse test sender private key") } } @@ -67,8 +72,8 @@ pub struct RecipientTestHelper; impl RecipientTestHelper { pub fn get_recipient_pubkey() -> Pubkey { dotenv::dotenv().ok(); - let recipient_str = - std::env::var("TEST_RECIPIENT_PUBKEY").unwrap_or_else(|_| RECIPIENT_PUBKEY.to_string()); + let recipient_str = std::env::var(TEST_RECIPIENT_PUBKEY_ENV) + .unwrap_or_else(|_| RECIPIENT_PUBKEY.to_string()); Pubkey::from_str(&recipient_str).expect("Invalid recipient pubkey") } } @@ -78,12 +83,11 @@ pub struct USDCMintTestHelper; impl USDCMintTestHelper { pub fn get_test_usdc_mint_keypair() -> Keypair { dotenv::dotenv().ok(); - let mint_keypair = match std::env::var("TEST_USDC_MINT_KEYPAIR") { - Ok(key) => key, - Err(_) => std::fs::read_to_string(USDC_MINT_KEYPAIR_PATH) - .expect("Failed to read USDC mint private key file"), - }; - parse_private_key_string(&mint_keypair).expect("Failed to parse test USDC mint private key") + parse_private_key_string( + &std::env::var(TEST_USDC_MINT_KEYPAIR_ENV) + .expect("TEST_USDC_MINT_KEYPAIR environment variable is not set"), + ) + .expect("Failed to parse test USDC mint private key") } pub fn get_test_usdc_mint_pubkey() -> Pubkey { @@ -92,7 +96,7 @@ impl USDCMintTestHelper { pub fn get_test_usdc_mint_decimals() -> u8 { dotenv::dotenv().ok(); - std::env::var("TEST_USDC_MINT_DECIMALS") + std::env::var(TEST_USDC_MINT_DECIMALS_ENV) .ok() .and_then(|s| s.parse().ok()) .unwrap_or(TEST_USDC_MINT_DECIMALS) @@ -104,13 +108,12 @@ pub struct USDCMint2022TestHelper; impl USDCMint2022TestHelper { pub fn get_test_usdc_mint_2022_keypair() -> Keypair { dotenv::dotenv().ok(); - let mint_keypair = match std::env::var("TEST_USDC_MINT_2022_KEYPAIR") { - Ok(key) => key, - Err(_) => std::fs::read_to_string(USDC_MINT_2022_KEYPAIR_PATH) - .expect("Failed to read USDC mint 2022 private key file"), - }; - parse_private_key_string(&mint_keypair) - .expect("Failed to parse test USDC mint 2022 private key") + + parse_private_key_string( + &std::env::var(TEST_USDC_MINT_2022_KEYPAIR_ENV) + .expect("TEST_USDC_MINT_2022_KEYPAIR environment variable is not set"), + ) + .expect("Failed to parse test USDC mint 2022 private key") } pub fn get_test_usdc_mint_2022_pubkey() -> Pubkey { @@ -119,13 +122,12 @@ impl USDCMint2022TestHelper { pub fn get_test_interest_bearing_mint_keypair() -> Keypair { dotenv::dotenv().ok(); - let mint_keypair = match std::env::var("TEST_INTEREST_BEARING_MINT_KEYPAIR") { - Ok(key) => key, - Err(_) => std::fs::read_to_string(INTEREST_BEARING_MINT_KEYPAIR_PATH) - .expect("Failed to read interest bearing mint private key file"), - }; - parse_private_key_string(&mint_keypair) - .expect("Failed to parse test interest bearing mint private key") + + parse_private_key_string( + &std::env::var(TEST_INTEREST_BEARING_MINT_KEYPAIR_ENV) + .expect("TEST_INTEREST_BEARING_MINT_KEYPAIR environment variable is not set"), + ) + .expect("Failed to parse test interest bearing mint private key") } pub fn get_test_interest_bearing_mint_pubkey() -> Pubkey { @@ -134,16 +136,44 @@ impl USDCMint2022TestHelper { pub fn get_test_transfer_hook_mint_keypair() -> Keypair { dotenv::dotenv().ok(); - let mint_keypair = match std::env::var("TEST_TRANSFER_HOOK_MINT_KEYPAIR") { - Ok(key) => key, - Err(_) => std::fs::read_to_string(TRANSFER_HOOK_MINT_KEYPAIR_PATH) - .expect("Failed to read transfer hook mint private key file"), - }; - parse_private_key_string(&mint_keypair) - .expect("Failed to parse test transfer hook mint private key") + + parse_private_key_string( + &std::env::var(TEST_TRANSFER_HOOK_MINT_KEYPAIR_ENV) + .expect("TEST_TRANSFER_HOOK_MINT_KEYPAIR environment variable is not set"), + ) + .expect("Failed to parse test transfer hook mint private key") } pub fn get_test_transfer_hook_mint_pubkey() -> Pubkey { Self::get_test_transfer_hook_mint_keypair().pubkey() } } + +pub struct PaymentAddressTestHelper; + +impl PaymentAddressTestHelper { + pub fn get_payment_address_keypair() -> Keypair { + dotenv::dotenv().ok(); + parse_private_key_string( + &std::env::var(PAYMENT_ADDRESS_KEYPAIR_ENV) + .expect("PAYMENT_ADDRESS_KEYPAIR environment variable is not set"), + ) + .expect("Failed to parse payment address private key") + } + + pub fn get_payment_address_pubkey() -> Pubkey { + Self::get_payment_address_keypair().pubkey() + } + + pub fn get_payment_test_address_pubkey() -> Pubkey { + Pubkey::from_str(TEST_PAYMENT_ADDRESS).expect("Invalid payment test address") + } +} + +pub struct PYUSDTestHelper; + +impl PYUSDTestHelper { + pub fn get_pyusd_mint_pubkey() -> Pubkey { + Pubkey::from_str(PYUSD_MINT).expect("Invalid PYUSD mint") + } +} diff --git a/tests/src/common/lookup_tables.rs b/tests/src/common/lookup_tables.rs index e75f09a5..b823effb 100644 --- a/tests/src/common/lookup_tables.rs +++ b/tests/src/common/lookup_tables.rs @@ -4,70 +4,22 @@ use solana_address_lookup_table_interface::instruction::{ }; use solana_client::nonblocking::rpc_client::RpcClient; use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer, transaction::Transaction}; -use std::{ - str::FromStr, - sync::{Arc, OnceLock}, -}; +use std::{str::FromStr, sync::Arc}; use crate::common::{constants::*, SenderTestHelper, USDCMintTestHelper}; -// Static cache for lookup table addresses - loaded once, reused everywhere -pub static CACHED_ADDRESSES: OnceLock = OnceLock::new(); - -#[derive(Default, serde::Serialize, serde::Deserialize)] -pub struct LookupTablesAddresses { - pub allowed_lookup_table_address: String, - pub disallowed_lookup_table_address: String, - pub transaction_lookup_table_address: String, -} - /// Comprehensive helper for all lookup table operations in tests pub struct LookupTableHelper; -impl LookupTablesAddresses { - /// Save lookup table addresses to JSON file - pub fn save(&self) -> Result<()> { - let json = serde_json::to_string_pretty(self)?; - std::fs::write(LOOKUP_TABLES_FILE_PATH, json)?; - Ok(()) - } - - /// Load lookup table addresses from JSON file - pub fn load() -> Result { - let json = std::fs::read_to_string(LOOKUP_TABLES_FILE_PATH)?; - let addresses: LookupTablesAddresses = serde_json::from_str(&json)?; - Ok(addresses) - } - - pub fn allowed_pubkey(&self) -> Result { - Pubkey::from_str(&self.allowed_lookup_table_address).map_err(Into::into) - } - - pub fn disallowed_pubkey(&self) -> Result { - Pubkey::from_str(&self.disallowed_lookup_table_address).map_err(Into::into) - } - - pub fn transaction_pubkey(&self) -> Result { - Pubkey::from_str(&self.transaction_lookup_table_address).map_err(Into::into) - } - - /// Create from Pubkeys for saving - pub fn from_pubkeys(allowed: Pubkey, disallowed: Pubkey, transaction: Pubkey) -> Self { - Self { - allowed_lookup_table_address: allowed.to_string(), - disallowed_lookup_table_address: disallowed.to_string(), - transaction_lookup_table_address: transaction.to_string(), - } - } -} - impl LookupTableHelper { // ============================================================================ // Fixtures Management // ============================================================================ /// Create all standard lookup tables and save addresses to fixtures - pub async fn setup_and_save_lookup_tables(rpc_client: Arc) -> Result<()> { + pub async fn setup_and_save_lookup_tables( + rpc_client: Arc, + ) -> Result<(Pubkey, Pubkey, Pubkey)> { let sender = SenderTestHelper::get_test_sender_keypair(); // Create all standard lookup tables @@ -78,50 +30,34 @@ impl LookupTableHelper { let transaction_lookup_table = Self::create_transaction_lookup_table(rpc_client.clone(), &sender).await?; - // Save addresses to JSON file - let addresses = LookupTablesAddresses::from_pubkeys( - allowed_lookup_table, - disallowed_lookup_table, - transaction_lookup_table, - ); - addresses.save()?; - - Ok(()) + Ok((allowed_lookup_table, disallowed_lookup_table, transaction_lookup_table)) } - fn load_lookup_table_addresses() -> Result<&'static LookupTablesAddresses> { - if let Some(cached) = CACHED_ADDRESSES.get() { - return Ok(cached); - } - - let addresses = LookupTablesAddresses::load()?; - - let _ = CACHED_ADDRESSES.set(addresses); - - Ok(CACHED_ADDRESSES.get().unwrap()) + pub fn get_test_disallowed_address() -> Result { + Pubkey::from_str(TEST_DISALLOWED_ADDRESS).map_err(Into::into) } - /// Get allowed lookup table address from fixtures pub fn get_allowed_lookup_table_address() -> Result { - let addresses = Self::load_lookup_table_addresses()?; - addresses.allowed_pubkey() + dotenv::dotenv().ok(); + let allowed_lookup_table_address = std::env::var(TEST_ALLOWED_LOOKUP_TABLE_ADDRESS_ENV) + .expect("TEST_ALLOWED_LOOKUP_TABLE_ADDRESS environment variable is not set"); + Pubkey::from_str(&allowed_lookup_table_address).map_err(Into::into) } - /// Get disallowed lookup table address from fixtures pub fn get_disallowed_lookup_table_address() -> Result { - let addresses = Self::load_lookup_table_addresses()?; - addresses.disallowed_pubkey() + dotenv::dotenv().ok(); + let disallowed_lookup_table_address = + std::env::var(TEST_DISALLOWED_LOOKUP_TABLE_ADDRESS_ENV) + .expect("TEST_DISALLOWED_LOOKUP_TABLE_ADDRESS environment variable is not set"); + Pubkey::from_str(&disallowed_lookup_table_address).map_err(Into::into) } - /// Get transaction lookup table address from fixtures, includes USDC mint and SPL token program pub fn get_transaction_lookup_table_address() -> Result { - let addresses = Self::load_lookup_table_addresses()?; - addresses.transaction_pubkey() - } - - /// Get test disallowed address (for creating custom lookup tables) - pub fn get_test_disallowed_address() -> Pubkey { - get_test_disallowed_pubkey() + dotenv::dotenv().ok(); + let transaction_lookup_table_address = + std::env::var(TEST_TRANSACTION_LOOKUP_TABLE_ADDRESS_ENV) + .expect("TEST_TRANSACTION_LOOKUP_TABLE_ADDRESS environment variable is not set"); + Pubkey::from_str(&transaction_lookup_table_address).map_err(Into::into) } // ============================================================================ @@ -172,8 +108,16 @@ impl LookupTableHelper { rpc_client.send_and_confirm_transaction(&extend_transaction).await?; } - // Wait for the lookup table to be fully initialized - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + // Wait for the lookup table to be activated + // Lookup tables need to be activated for at least one slot before they can be used + let creation_slot = rpc_client.get_slot().await?; + let mut current_slot = creation_slot; + + // Wait until we're at least 2 slots past creation to ensure activation + while current_slot <= creation_slot + 1 { + tokio::time::sleep(tokio::time::Duration::from_millis(400)).await; + current_slot = rpc_client.get_slot().await?; + } Ok(lookup_table_key) } @@ -197,7 +141,7 @@ impl LookupTableHelper { rpc_client: Arc, authority: &Keypair, ) -> Result { - let disallowed_address = get_test_disallowed_pubkey(); + let disallowed_address = Self::get_test_disallowed_address()?; let blocked_lookup_table: Pubkey = Self::create_lookup_table(rpc_client, authority, vec![disallowed_address]).await?; diff --git a/tests/src/common/mod.rs b/tests/src/common/mod.rs index d1a0d719..4d482574 100644 --- a/tests/src/common/mod.rs +++ b/tests/src/common/mod.rs @@ -9,23 +9,14 @@ pub mod lookup_tables; pub mod setup; pub mod transaction; -// Re-export commonly used items for convenience -pub use assertions::{JsonRpcErrorCodes, RpcAssertions, TransactionAssertions}; -pub use client::{TestClient, TestContext}; -pub use extension_helpers::ExtensionHelpers; -pub use transaction::{TransactionBuilder, TransactionVersion}; - -// Re-export auth helpers (excluding constants that are in constants.rs) -pub use auth_helpers::{ - make_auth_request, make_auth_request_with_body, JSON_TEST_BODY, JSON_TEST_BODY_WITH_PARAMS, -}; - -// Re-export helpers (excluding constants that are in constants.rs) -pub use helpers::{ - parse_private_key_string, FeePayerTestHelper, RecipientTestHelper, SenderTestHelper, - TestAccountInfo, USDCMint2022TestHelper, USDCMintTestHelper, -}; -pub use lookup_tables::{LookupTableHelper, LookupTablesAddresses}; - -// Re-export all constants from the consolidated constants module +pub use assertions::*; +#[cfg(test)] +pub use auth_helpers::*; +#[cfg(test)] +pub use client::*; pub use constants::*; +pub use extension_helpers::*; +pub use helpers::*; +pub use lookup_tables::*; +pub use setup::*; +pub use transaction::*; diff --git a/tests/src/common/setup.rs b/tests/src/common/setup.rs index c2baf2ae..eacdbe6d 100644 --- a/tests/src/common/setup.rs +++ b/tests/src/common/setup.rs @@ -20,10 +20,32 @@ use spl_token_2022::{ use std::sync::Arc; use crate::common::{ - FeePayerTestHelper, LookupTableHelper, RecipientTestHelper, SenderTestHelper, TestAccountInfo, + FeePayerTestHelper, LookupTableHelper, RecipientTestHelper, SenderTestHelper, USDCMint2022TestHelper, USDCMintTestHelper, DEFAULT_RPC_URL, }; +/// Test account information for outputting to the user +#[derive(Debug, Default, Clone)] +pub struct TestAccountInfo { + pub fee_payer_pubkey: Pubkey, + pub sender_pubkey: Pubkey, + pub recipient_pubkey: Pubkey, + // USDC mint fields + pub usdc_mint_pubkey: Pubkey, + pub sender_token_account: Pubkey, + pub recipient_token_account: Pubkey, + pub fee_payer_token_account: Pubkey, + // Token 2022 fields + pub usdc_mint_2022_pubkey: Pubkey, + pub sender_token_2022_account: Pubkey, + pub recipient_token_2022_account: Pubkey, + pub fee_payer_token_2022_account: Pubkey, + // Lookup tables + pub allowed_lookup_table: Pubkey, + pub disallowed_lookup_table: Pubkey, + pub transaction_lookup_table: Pubkey, +} + /// Test account setup utilities for local validator pub struct TestAccountSetup { pub rpc_client: Arc, @@ -70,14 +92,39 @@ impl TestAccountSetup { } pub async fn setup_all_accounts(&mut self) -> Result { - self.fund_sol_accounts().await?; + let mut account_infos = TestAccountInfo::default(); - self.create_usdc_mint().await?; - self.create_usdc_mint_2022().await?; + let (sender_pubkey, recipient_pubkey, fee_payer_pubkey) = self.fund_sol_accounts().await?; + account_infos.sender_pubkey = sender_pubkey; + account_infos.recipient_pubkey = recipient_pubkey; + account_infos.fee_payer_pubkey = fee_payer_pubkey; - self.create_lookup_tables().await?; + let usdc_mint_pubkey = self.create_usdc_mint().await?; + account_infos.usdc_mint_pubkey = usdc_mint_pubkey; - let account_info = self.setup_token_accounts().await?; + let usdc_mint_2022_pubkey = self.create_usdc_mint_2022().await?; + account_infos.usdc_mint_2022_pubkey = usdc_mint_2022_pubkey; + + let (allowed_lookup_table, disallowed_lookup_table, transaction_lookup_table) = + self.create_lookup_tables().await?; + account_infos.allowed_lookup_table = allowed_lookup_table; + account_infos.disallowed_lookup_table = disallowed_lookup_table; + account_infos.transaction_lookup_table = transaction_lookup_table; + + let ( + sender_token_account, + recipient_token_account, + fee_payer_token_account, + sender_token_2022_account, + recipient_token_2022_account, + fee_payer_token_2022_account, + ) = self.setup_token_accounts().await?; + account_infos.sender_token_account = sender_token_account; + account_infos.recipient_token_account = recipient_token_account; + account_infos.fee_payer_token_account = fee_payer_token_account; + account_infos.sender_token_2022_account = sender_token_2022_account; + account_infos.recipient_token_2022_account = recipient_token_2022_account; + account_infos.fee_payer_token_2022_account = fee_payer_token_2022_account; // Wait for the accounts to be fully initialized (lookup tables, etc.) let await_for_slot = self.rpc_client.get_slot().await? + 30; @@ -86,7 +133,7 @@ impl TestAccountSetup { tokio::time::sleep(std::time::Duration::from_millis(500)).await; } - Ok(account_info) + Ok(account_infos) } pub async fn airdrop_if_required_sol(&self, receiver: &Pubkey, amount: u64) -> Result<()> { @@ -112,7 +159,7 @@ impl TestAccountSetup { Ok(()) } - pub async fn fund_sol_accounts(&self) -> Result<()> { + pub async fn fund_sol_accounts(&self) -> Result<(Pubkey, Pubkey, Pubkey)> { let sol_to_fund = 10 * LAMPORTS_PER_SOL; let sender_pubkey = self.sender_keypair.pubkey(); @@ -124,12 +171,12 @@ impl TestAccountSetup { self.airdrop_if_required_sol(&fee_payer_pubkey, sol_to_fund) )?; - Ok(()) + Ok((self.sender_keypair.pubkey(), self.recipient_pubkey, self.fee_payer_keypair.pubkey())) } - pub async fn create_usdc_mint(&self) -> Result<()> { + pub async fn create_usdc_mint(&self) -> Result { if (self.rpc_client.get_account(&self.usdc_mint.pubkey()).await).is_ok() { - return Ok(()); + return Ok(self.usdc_mint.pubkey()); } let rent = self @@ -164,12 +211,12 @@ impl TestAccountSetup { self.rpc_client.send_and_confirm_transaction(&transaction).await?; - Ok(()) + Ok(self.usdc_mint.pubkey()) } - pub async fn create_usdc_mint_2022(&self) -> Result<()> { + pub async fn create_usdc_mint_2022(&self) -> Result { if (self.rpc_client.get_account(&self.usdc_mint_2022.pubkey()).await).is_ok() { - return Ok(()); + return Ok(self.usdc_mint_2022.pubkey()); } let decimals = USDCMintTestHelper::get_test_usdc_mint_decimals(); @@ -221,10 +268,12 @@ impl TestAccountSetup { self.rpc_client.send_and_confirm_transaction(&transaction).await?; - Ok(()) + Ok(self.usdc_mint_2022.pubkey()) } - pub async fn setup_token_accounts(&self) -> Result { + pub async fn setup_token_accounts( + &self, + ) -> Result<(Pubkey, Pubkey, Pubkey, Pubkey, Pubkey, Pubkey)> { // SPL Token accounts let sender_token_account = get_associated_token_address(&self.sender_keypair.pubkey(), &self.usdc_mint.pubkey()); @@ -332,20 +381,14 @@ impl TestAccountSetup { // Mint Token 2022 tokens self.mint_tokens_2022_to_account(&sender_token_2022_account, mint_amount).await?; - Ok(TestAccountInfo { - fee_payer_pubkey: self.fee_payer_keypair.pubkey(), - sender_pubkey: self.sender_keypair.pubkey(), - recipient_pubkey: self.recipient_pubkey, - usdc_mint_pubkey: self.usdc_mint.pubkey(), + Ok(( sender_token_account, recipient_token_account, fee_payer_token_account, - // Token 2022 fields - usdc_mint_2022_pubkey: self.usdc_mint_2022.pubkey(), sender_token_2022_account, recipient_token_2022_account, fee_payer_token_2022_account, - }) + )) } async fn mint_tokens_to_account(&self, token_account: &Pubkey, amount: u64) -> Result<()> { @@ -392,9 +435,10 @@ impl TestAccountSetup { Ok(()) } - async fn create_lookup_tables(&mut self) -> Result<()> { - LookupTableHelper::setup_and_save_lookup_tables(self.rpc_client.clone()).await?; + async fn create_lookup_tables(&mut self) -> Result<(Pubkey, Pubkey, Pubkey)> { + let (allowed_lookup_table, disallowed_lookup_table, transaction_lookup_table) = + LookupTableHelper::setup_and_save_lookup_tables(self.rpc_client.clone()).await?; - Ok(()) + Ok((allowed_lookup_table, disallowed_lookup_table, transaction_lookup_table)) } } diff --git a/tests/src/common/transaction.rs b/tests/src/common/transaction.rs index e12b6846..017f555e 100644 --- a/tests/src/common/transaction.rs +++ b/tests/src/common/transaction.rs @@ -260,7 +260,7 @@ impl TransactionBuilder { let fee_payer = self.fee_payer.ok_or_else(|| anyhow::anyhow!("Fee payer is required"))?; let blockhash = - rpc_client.get_latest_blockhash_with_commitment(CommitmentConfig::finalized()).await?; + rpc_client.get_latest_blockhash_with_commitment(CommitmentConfig::confirmed()).await?; let message = match self.version { diff --git a/tests/src/lib.rs b/tests/src/lib.rs index ed29a720..4e71485c 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -1,2 +1,2 @@ pub mod common; -pub mod setup_test_env; +pub mod test_runner; diff --git a/tests/src/setup_test_env.rs b/tests/src/setup_test_env.rs deleted file mode 100644 index cebd8a09..00000000 --- a/tests/src/setup_test_env.rs +++ /dev/null @@ -1,56 +0,0 @@ -use solana_client::nonblocking::rpc_client::RpcClient; - -use crate::common::{ - constants::DEFAULT_RPC_URL, helpers::TestAccountInfo, setup::TestAccountSetup, -}; - -pub async fn check_test_validator() -> bool { - let client = RpcClient::new(DEFAULT_RPC_URL.to_string()); - client.get_health().await.is_ok() -} - -pub async fn setup_test_accounts() -> Result { - let mut setup = TestAccountSetup::new().await; - setup.setup_all_accounts().await -} - -pub async fn run() -> Result<(), Box> { - println!("๐Ÿ”ง Setting up test environment..."); - - // Check if test validator is running - if !check_test_validator().await { - eprintln!("โŒ Error: Solana test validator is not running"); - eprintln!("Please start it with: surfpool start or solana-test-validator"); - std::process::exit(1); - } - let start_time = tokio::time::Instant::now(); - - println!("โœ… Test validator is running"); - - // Setup test accounts - match setup_test_accounts().await { - Ok(account_info) => { - println!("โœ… Test environment setup complete!"); - println!(); - println!("๐Ÿ“‹ Account Summary:"); - println!(" Fee Payer: {}", account_info.fee_payer_pubkey); - println!(" Sender: {}", account_info.sender_pubkey); - println!(" Recipient: {}", account_info.recipient_pubkey); - println!(" USDC Mint: {}", account_info.usdc_mint_pubkey); - println!(" Sender Token Account: {}", account_info.sender_token_account); - println!(" Recipient Token Account: {}", account_info.recipient_token_account); - println!(" Fee Payer Token Account: {}", account_info.fee_payer_token_account); - println!(); - println!("๐ŸŽฏ Ready to run integration tests!"); - println!("Run: cargo test --test integration"); - } - Err(e) => { - eprintln!("โŒ Failed to setup test environment: {e}"); - std::process::exit(1); - } - } - - println!("Time to setup test environment: {} seconds", start_time.elapsed().as_secs()); - - Ok(()) -} diff --git a/tests/src/test_runner/accounts.rs b/tests/src/test_runner/accounts.rs new file mode 100644 index 00000000..452bdc72 --- /dev/null +++ b/tests/src/test_runner/accounts.rs @@ -0,0 +1,268 @@ +use crate::common::{ + TestAccountInfo, KORA_PRIVATE_KEY_ENV, PAYMENT_ADDRESS_KEYPAIR_ENV, SIGNER_2_KEYPAIR_ENV, + TEST_ALLOWED_LOOKUP_TABLE_ADDRESS_ENV, TEST_DISALLOWED_LOOKUP_TABLE_ADDRESS_ENV, + TEST_INTEREST_BEARING_MINT_KEYPAIR_ENV, TEST_RECIPIENT_PUBKEY_ENV, TEST_SENDER_KEYPAIR_ENV, + TEST_TRANSACTION_LOOKUP_TABLE_ADDRESS_ENV, TEST_TRANSFER_HOOK_MINT_KEYPAIR_ENV, + TEST_USDC_MINT_2022_KEYPAIR_ENV, TEST_USDC_MINT_KEYPAIR_ENV, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use solana_client::nonblocking::rpc_client::RpcClient; +use solana_sdk::pubkey::Pubkey; +use std::{fs, path::Path}; + +const TEST_ACCOUNTS_DIR: &str = "tests/src/common/fixtures/test-accounts"; + +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] +pub enum AccountFile { + FeePayer, + Sender, + Recipient, + UsdcMint, + SenderTokenAccount, + RecipientTokenAccount, + FeePayerTokenAccount, + UsdcMint2022, + SenderToken2022Account, + RecipientToken2022Account, + FeePayerToken2022Account, + AllowedLookupTable, + DisallowedLookupTable, + TransactionLookupTable, + Signer2, + InterestBearingMint, + TransferHookMint, + Payment, +} + +impl AccountFile { + pub fn filename(&self) -> &'static str { + match self { + Self::FeePayer => "fee-payer-local.json", + Self::Sender => "sender-local.json", + Self::Recipient => "recipient-local.json", + Self::UsdcMint => "usdc-mint-local.json", + Self::SenderTokenAccount => "sender-token-account-local.json", + Self::RecipientTokenAccount => "recipient-token-account-local.json", + Self::FeePayerTokenAccount => "fee-payer-token-account-local.json", + Self::UsdcMint2022 => "usdc-mint-2022-local.json", + Self::SenderToken2022Account => "sender-token-2022-account-local.json", + Self::RecipientToken2022Account => "recipient-token-2022-account-local.json", + Self::FeePayerToken2022Account => "fee-payer-token-2022-account-local.json", + Self::AllowedLookupTable => "allowed-lookup-table-local.json", + Self::DisallowedLookupTable => "disallowed-lookup-table-local.json", + Self::TransactionLookupTable => "transaction-lookup-table-local.json", + Self::Signer2 => "signer2-local.json", + Self::InterestBearingMint => "mint-2022-interest-bearing.json", + Self::TransferHookMint => "mint-transfer-hook-local.json", + Self::Payment => "payment-local.json", + } + } + + pub fn local_key_env_var(&self) -> &'static str { + match self { + Self::FeePayer => KORA_PRIVATE_KEY_ENV, + Self::Sender => TEST_SENDER_KEYPAIR_ENV, + Self::Recipient => TEST_RECIPIENT_PUBKEY_ENV, + Self::UsdcMint => TEST_USDC_MINT_KEYPAIR_ENV, + Self::UsdcMint2022 => TEST_USDC_MINT_2022_KEYPAIR_ENV, + Self::AllowedLookupTable => TEST_ALLOWED_LOOKUP_TABLE_ADDRESS_ENV, + Self::DisallowedLookupTable => TEST_DISALLOWED_LOOKUP_TABLE_ADDRESS_ENV, + Self::TransactionLookupTable => TEST_TRANSACTION_LOOKUP_TABLE_ADDRESS_ENV, + Self::Signer2 => SIGNER_2_KEYPAIR_ENV, + Self::InterestBearingMint => TEST_INTEREST_BEARING_MINT_KEYPAIR_ENV, + Self::TransferHookMint => TEST_TRANSFER_HOOK_MINT_KEYPAIR_ENV, + Self::Payment => PAYMENT_ADDRESS_KEYPAIR_ENV, + _ => panic!("Invalid account env"), + } + } + + pub fn local_key_path(&self) -> String { + format!("tests/src/common/local-keys/{}", self.filename()) + } + + pub fn test_account_path(&self) -> std::path::PathBuf { + Path::new(TEST_ACCOUNTS_DIR).join(self.filename()) + } + + pub fn required_test_accounts() -> &'static [AccountFile] { + &[ + Self::FeePayer, + Self::Sender, + Self::Recipient, + Self::UsdcMint, + Self::SenderTokenAccount, + Self::RecipientTokenAccount, + Self::FeePayerTokenAccount, + Self::UsdcMint2022, + Self::SenderToken2022Account, + Self::RecipientToken2022Account, + Self::FeePayerToken2022Account, + Self::AllowedLookupTable, + Self::DisallowedLookupTable, + Self::TransactionLookupTable, + Self::Signer2, + Self::InterestBearingMint, + Self::TransferHookMint, + Self::Payment, + ] + } + + pub fn required_test_accounts_env_vars() -> &'static [AccountFile] { + &[ + Self::FeePayer, + Self::Signer2, + Self::Sender, + Self::UsdcMint, + Self::UsdcMint2022, + Self::InterestBearingMint, + Self::TransferHookMint, + Self::Payment, + ] + } + + pub fn required_for_kora() -> &'static [AccountFile] { + &[Self::FeePayer, Self::Signer2] + } + + pub fn set_environment_variable_from_cache( + &self, + cached_keys: &std::collections::HashMap, + ) -> Result<(), Box> { + let key = + cached_keys.get(self).ok_or_else(|| format!("Key not found in cache: {self:?}"))?; + std::env::set_var(self.local_key_env_var(), key.trim()); + Ok(()) + } + + pub fn set_dynamic_environment_variable( + &self, + value: &str, + ) -> Result<(), Box> { + std::env::set_var(self.local_key_env_var(), value); + Ok(()) + } + + pub async fn save_account_for_file( + &self, + client: &RpcClient, + address: &Pubkey, + ) -> Result<(), Box> { + save_account(client, address, self.test_account_path()).await + } + + pub fn get_as_env_var(&self) -> (&'static str, String) { + (self.local_key_env_var(), std::env::var(self.local_key_env_var()).unwrap()) + } +} + +pub fn set_environment_variables( + cached_keys: &std::collections::HashMap, +) -> Result<(), Box> { + for account_file in AccountFile::required_test_accounts_env_vars() { + if cached_keys.contains_key(account_file) { + account_file.set_environment_variable_from_cache(cached_keys)?; + } else { + // For accounts not in cache, fallback to file read + let key = std::fs::read_to_string(account_file.local_key_path())?; + std::env::set_var(account_file.local_key_env_var(), key.trim()); + } + } + + Ok(()) +} + +pub async fn set_lookup_table_environment_variables( + test_accounts: &TestAccountInfo, +) -> Result<(), Box> { + AccountFile::AllowedLookupTable + .set_dynamic_environment_variable(&test_accounts.allowed_lookup_table.to_string())?; + AccountFile::DisallowedLookupTable + .set_dynamic_environment_variable(&test_accounts.disallowed_lookup_table.to_string())?; + AccountFile::TransactionLookupTable + .set_dynamic_environment_variable(&test_accounts.transaction_lookup_table.to_string())?; + Ok(()) +} + +pub async fn get_account_address_from_file( + account_path: &Path, +) -> Result> { + let account_json = tokio::fs::read_to_string(account_path).await?; + let account_data: serde_json::Value = serde_json::from_str(&account_json)?; + + if let Some(pubkey) = account_data["account"]["pubkey"].as_str() { + return Ok(pubkey.to_string()); + } + + if let Some(pubkey) = account_data["pubkey"].as_str() { + return Ok(pubkey.to_string()); + } + + Err("Could not find pubkey in account file".into()) +} + +pub async fn download_accounts( + client: &RpcClient, + test_accounts: &TestAccountInfo, +) -> Result<(), Box> { + let accounts_dir = Path::new(TEST_ACCOUNTS_DIR); + fs::create_dir_all(accounts_dir)?; + + AccountFile::FeePayer.save_account_for_file(client, &test_accounts.fee_payer_pubkey).await?; + AccountFile::Sender.save_account_for_file(client, &test_accounts.sender_pubkey).await?; + AccountFile::Recipient.save_account_for_file(client, &test_accounts.recipient_pubkey).await?; + AccountFile::UsdcMint.save_account_for_file(client, &test_accounts.usdc_mint_pubkey).await?; + AccountFile::SenderTokenAccount + .save_account_for_file(client, &test_accounts.sender_token_account) + .await?; + AccountFile::RecipientTokenAccount + .save_account_for_file(client, &test_accounts.recipient_token_account) + .await?; + AccountFile::FeePayerTokenAccount + .save_account_for_file(client, &test_accounts.fee_payer_token_account) + .await?; + AccountFile::UsdcMint2022 + .save_account_for_file(client, &test_accounts.usdc_mint_2022_pubkey) + .await?; + AccountFile::SenderToken2022Account + .save_account_for_file(client, &test_accounts.sender_token_2022_account) + .await?; + AccountFile::RecipientToken2022Account + .save_account_for_file(client, &test_accounts.recipient_token_2022_account) + .await?; + AccountFile::FeePayerToken2022Account + .save_account_for_file(client, &test_accounts.fee_payer_token_2022_account) + .await?; + AccountFile::AllowedLookupTable + .save_account_for_file(client, &test_accounts.allowed_lookup_table) + .await?; + AccountFile::DisallowedLookupTable + .save_account_for_file(client, &test_accounts.disallowed_lookup_table) + .await?; + AccountFile::TransactionLookupTable + .save_account_for_file(client, &test_accounts.transaction_lookup_table) + .await?; + Ok(()) +} + +async fn save_account( + client: &RpcClient, + address: &Pubkey, + path: std::path::PathBuf, +) -> Result<(), Box> { + let account = client.get_account(address).await?; + + let account_data = serde_json::json!({ + "pubkey": address.to_string(), + "account": { + "lamports": account.lamports, + "data": [STANDARD.encode(&account.data), "base64"], + "owner": account.owner.to_string(), + "executable": account.executable, + "rentEpoch": 0 + } + }); + + std::fs::write(path, serde_json::to_string_pretty(&account_data)?)?; + + Ok(()) +} diff --git a/tests/src/test_runner/commands.rs b/tests/src/test_runner/commands.rs new file mode 100644 index 00000000..1d3ac7d1 --- /dev/null +++ b/tests/src/test_runner/commands.rs @@ -0,0 +1,111 @@ +use crate::{ + common::TEST_SERVER_URL_ENV, + test_runner::{ + accounts::AccountFile, + output::{filter_and_colorize_output, OutputFilter, TestPhaseColor}, + }, +}; + +pub enum TestLanguage { + Rust, + TypeScript, +} + +pub struct TestCommandHelper; + +impl TestCommandHelper { + pub async fn run_test( + test_language: TestLanguage, + test_name: &str, + port: &str, + color: TestPhaseColor, + verbose: bool, + output: &mut String, + ) -> Result<(), Box> { + match test_language { + TestLanguage::Rust => { + Self::run_tests_buffered(test_name, port, color, verbose, output).await + } + TestLanguage::TypeScript => { + Self::run_typescript_tests(test_name, port, color, verbose, output).await + } + } + } + + async fn run_tests_buffered( + test_name: &str, + port: &str, + color: TestPhaseColor, + verbose: bool, + output: &mut String, + ) -> Result<(), Box> { + let server_url = format!("http://127.0.0.1:{port}"); + + let mut cmd = tokio::process::Command::new("cargo"); + + cmd.args(["test", "-p", "tests", "--test", test_name, "--", "--nocapture"]) + .env(TEST_SERVER_URL_ENV, &server_url); + + for account_file in AccountFile::required_test_accounts_env_vars() { + let (env_var, value) = account_file.get_as_env_var(); + cmd.env(env_var, value); + } + + let cmd_output = cmd.output().await?; + + if !cmd_output.status.success() { + let stderr = String::from_utf8_lossy(&cmd_output.stderr); + let filtered_stderr = + filter_and_colorize_output(&stderr, OutputFilter::Test, verbose, color); + if !filtered_stderr.is_empty() { + output.push_str(&filtered_stderr); + } + return Err(format!("{test_name} tests failed").into()); + } + + let stdout = String::from_utf8_lossy(&cmd_output.stdout); + let filtered_stdout = + filter_and_colorize_output(&stdout, OutputFilter::Test, verbose, color); + output.push_str(&filtered_stdout); + Ok(()) + } + + async fn run_typescript_tests( + test_name: &str, + port: &str, + color: TestPhaseColor, + verbose: bool, + output: &mut String, + ) -> Result<(), Box> { + let pnpm_command = match test_name { + "typescript_basic" => "test:integration", + "typescript_auth" => "test:integration:auth", + "typescript_turnkey" => "test:integration:turnkey", + "typescript_privy" => "test:integration:privy", + _ => return Err(format!("Unknown TypeScript test: {test_name}").into()), + }; + + let server_url = format!("http://127.0.0.1:{port}"); + + let mut cmd = tokio::process::Command::new("pnpm"); + cmd.current_dir("sdks/ts").args(["run", pnpm_command]).env("KORA_RPC_URL", server_url); + + let cmd_output = cmd.output().await?; + + if !cmd_output.status.success() { + let stderr = String::from_utf8_lossy(&cmd_output.stderr); + let filtered_stderr = + filter_and_colorize_output(&stderr, OutputFilter::TypeScript, verbose, color); + if !filtered_stderr.is_empty() { + output.push_str(&filtered_stderr); + } + return Err(format!("{test_name} TypeScript tests failed").into()); + } + + let stdout = String::from_utf8_lossy(&cmd_output.stdout); + let filtered_stdout = + filter_and_colorize_output(&stdout, OutputFilter::TypeScript, verbose, color); + output.push_str(&filtered_stdout); + Ok(()) + } +} diff --git a/tests/src/test_runner/config.rs b/tests/src/test_runner/config.rs new file mode 100644 index 00000000..f5d880da --- /dev/null +++ b/tests/src/test_runner/config.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, path::Path}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TestPhaseConfig { + pub name: String, + pub config: String, + pub signers: String, + pub port: String, + pub tests: Vec, + #[serde(default)] + pub initialize_payments_atas: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct TestRunnerConfig { + pub test: HashMap, +} + +impl TestRunnerConfig { + pub async fn load_from_file>( + path: P, + ) -> Result> { + let content = tokio::fs::read_to_string(path).await?; + let config: TestRunnerConfig = toml::from_str(&content)?; + Ok(config) + } + + pub fn get_all_phases(&self) -> Vec<(String, TestPhaseConfig)> { + self.test.iter().map(|(key, config)| (key.clone(), config.clone())).collect() + } +} diff --git a/tests/src/test_runner/kora.rs b/tests/src/test_runner/kora.rs new file mode 100644 index 00000000..080c8ee0 --- /dev/null +++ b/tests/src/test_runner/kora.rs @@ -0,0 +1,96 @@ +use crate::test_runner::accounts::AccountFile; +use std::{ + collections::HashSet, + path::Path, + sync::{LazyLock, Mutex}, +}; +use tokio::{net::TcpListener, process::Child}; + +// Global port tracker to prevent immediate reuse +static USED_PORTS: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); + +pub const KORA_BINARY_PATH: &str = "target/debug/kora"; +pub const PORT_RANGE_START: u16 = 8080; +pub const PORT_RANGE_END: u16 = 8180; + +pub async fn get_kora_binary_path() -> Result> { + if !Path::new(KORA_BINARY_PATH).exists() { + return Err(format!( + "Pre-built Kora binary not found at '{KORA_BINARY_PATH}'. \ + Run 'cargo build --bin kora' or 'make build' first for much better performance.", + ) + .into()); + } + Ok(KORA_BINARY_PATH.to_string()) +} + +pub async fn check_port_available(port: u16) -> bool { + TcpListener::bind(("127.0.0.1", port)).await.is_ok() +} + +pub async fn find_available_port() -> Result> { + for port in PORT_RANGE_START..PORT_RANGE_END { + // Check if port is available and not recently used + if check_port_available(port).await { + let mut used_ports = USED_PORTS.lock().unwrap(); + if !used_ports.contains(&port) { + used_ports.insert(port); + return Ok(port); + } + } + } + Err(format!("No available ports found in range {PORT_RANGE_START}-{PORT_RANGE_END}").into()) +} + +pub fn release_port(port: u16) { + let mut used_ports = USED_PORTS.lock().unwrap(); + used_ports.remove(&port); +} + +pub async fn is_kora_running_with_client(client: &reqwest::Client, port: &str) -> bool { + let url = format!("http://127.0.0.1:{port}/liveness"); + client.get(&url).timeout(std::time::Duration::from_secs(5)).send().await.is_ok() +} + +pub async fn start_kora_rpc_server( + rpc_url: String, + config_file: &str, + signers_config: &str, + cached_keys: &std::collections::HashMap, + preferred_port: u16, +) -> Result<(Child, u16), Box> { + let fee_payer_key = + cached_keys.get(&AccountFile::FeePayer).ok_or("FeePayer key not found in cache")?; + let signer_2 = + cached_keys.get(&AccountFile::Signer2).ok_or("Signer2 key not found in cache")?; + + let port = if check_port_available(preferred_port).await { + let mut used_ports = USED_PORTS.lock().unwrap(); + used_ports.insert(preferred_port); + preferred_port + } else { + find_available_port().await? + }; + let kora_binary_path = get_kora_binary_path().await?; + + let kora_pid = tokio::process::Command::new(kora_binary_path) + .args([ + "--config", + config_file, + "--rpc-url", + rpc_url.as_str(), + "rpc", + "start", + "--signers-config", + signers_config, + "--port", + &port.to_string(), + ]) + .env("KORA_PRIVATE_KEY", fee_payer_key.trim()) + .env("KORA_PRIVATE_KEY_2", signer_2.trim()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn()?; + + Ok((kora_pid, port)) +} diff --git a/tests/src/test_runner/mod.rs b/tests/src/test_runner/mod.rs new file mode 100644 index 00000000..066a4c85 --- /dev/null +++ b/tests/src/test_runner/mod.rs @@ -0,0 +1,6 @@ +pub mod accounts; +pub mod commands; +pub mod config; +pub mod kora; +pub mod output; +pub mod validator; diff --git a/tests/src/test_runner/output.rs b/tests/src/test_runner/output.rs new file mode 100644 index 00000000..5caa59f1 --- /dev/null +++ b/tests/src/test_runner/output.rs @@ -0,0 +1,193 @@ +pub const MAX_OUTPUT_SIZE: usize = 1024 * 1024; // 1MB limit + +#[derive(Debug)] +pub struct PhaseOutput { + pub phase_name: String, + pub output: String, + pub success: bool, + pub truncated: bool, +} + +#[derive(Debug, Clone, Copy)] +pub enum OutputFilter { + Test, + CliCommand, + TypeScript, +} + +#[derive(Debug, Clone, Copy)] +pub enum TestPhaseColor { + Regular, + Auth, + Payment, + MultiSigner, + TypeScriptBasic, + TypeScriptAuth, + TypeScriptTurnkey, + TypeScriptPrivy, +} + +impl TestPhaseColor { + pub fn from_phase_name(phase_name: &str) -> Self { + match phase_name { + "Regular Integration Tests" => Self::Regular, + "Auth Tests" => Self::Auth, + "Payment Address Tests" => Self::Payment, + "Multi-Signer Tests" => Self::MultiSigner, + name if name.starts_with("TypeScript") => Self::from_typescript_phase(name), + name if name.starts_with("typescript_") => Self::from_typescript_phase(name), + // Fallback patterns + name if name.to_lowercase().contains("auth") => Self::Auth, + name if name.to_lowercase().contains("payment") => Self::Payment, + name if name.to_lowercase().contains("multi") => Self::MultiSigner, + name if name.to_lowercase().contains("typescript") => Self::TypeScriptBasic, + _ => Self::Regular, + } + } + + fn from_typescript_phase(name: &str) -> Self { + match name { + "typescript_basic" => Self::TypeScriptBasic, + "typescript_auth" => Self::TypeScriptAuth, + "typescript_turnkey" => Self::TypeScriptTurnkey, + "typescript_privy" => Self::TypeScriptPrivy, + _ => Self::TypeScriptBasic, + } + } + + pub fn ansi_code(&self) -> &'static str { + match self { + Self::Regular => "\x1b[32m", // Green + Self::Auth => "\x1b[34m", // Blue + Self::Payment => "\x1b[33m", // Yellow + Self::MultiSigner => "\x1b[35m", // Magenta + Self::TypeScriptBasic => "\x1b[36m", // Cyan + Self::TypeScriptAuth => "\x1b[31m", // Red + Self::TypeScriptTurnkey => "\x1b[37m", // White + Self::TypeScriptPrivy => "\x1b[90m", // Gray + } + } + + pub fn reset_code() -> &'static str { + "\x1b[0m" + } + + pub fn colorize(&self, text: &str) -> String { + format!("{}{}{}", self.ansi_code(), text, Self::reset_code()) + } + + pub fn colorize_with_controlled_flow(&self, text: &str) -> String { + // Remove all existing newlines and add controlled ones with proper spacing + let cleaned_text = text.replace('\n', ""); + let controlled_text = format!("{cleaned_text}\n\n"); + format!("{}{}{}", self.ansi_code(), controlled_text, Self::reset_code()) + } +} + +impl OutputFilter { + pub fn should_show_line(&self, line: &str, show_verbose: bool) -> bool { + match self { + Self::Test => { + // + line.contains("test result:") + || line.contains("FAILED") + || line.contains("failures:") + || line.contains("panicked") + || line.contains("assertion") + || line.contains("ERROR") + || (show_verbose + && (line.contains("Compiling") + || line.contains("running ") + || line.starts_with("test ") + || line.contains("Finished") + || line.contains("warning:") + || line.contains("error:"))) + } + Self::CliCommand => { + line.contains("ERROR") + || line.contains("error") + || line.contains("Failed") + || line.contains("Success") + || line.contains("โœ—") + || (show_verbose + && (line.contains("INFO") + || line.contains("โœ“") + || line.contains("Initialized") + || line.contains("Created"))) + } + Self::TypeScript => { + // Jest and TypeScript test output patterns + line.contains("PASS") + || line.contains("FAIL") + || line.contains("โœ“") + || line.contains("โœ—") + || line.contains("Tests:") + || line.contains("Test Suites:") + || line.contains("Snapshots:") + || line.contains("Time:") + || line.contains("Ran all test suites") + || line.contains("Test results:") + || line.contains("expect") + || line.contains("Error:") + || line.contains("AssertionError") + || line.contains("TypeError") + || line.contains("ReferenceError") + || line.contains("failed with exit code") + || line.contains("npm ERR!") + || line.contains("pnpm ERR!") + || (show_verbose + && (line.contains("Running") + || line.contains("Starting") + || line.contains("Finished"))) + } + } + } +} + +pub fn filter_command_output(output: &str, filter: OutputFilter, show_verbose: bool) -> String { + // If verbose, show everything without filtering + if show_verbose { + return clean_multiple_newlines(output); + } + + // Otherwise apply pattern filtering + let filtered = output + .lines() + .filter(|line| filter.should_show_line(line, show_verbose)) + .collect::>() + .join("\n"); + + clean_multiple_newlines(&filtered) +} + +fn clean_multiple_newlines(text: &str) -> String { + // Replace multiple consecutive newlines with single newlines + let mut result = text.to_string(); + while result.contains("\n\n\n") { + result = result.replace("\n\n\n", "\n\n"); + } + result.trim_end().to_string() +} + +pub fn filter_and_colorize_output( + output: &str, + filter: OutputFilter, + show_verbose: bool, + color: TestPhaseColor, +) -> String { + let filtered = filter_command_output(output, filter, show_verbose); + color.colorize(&filtered) +} + +pub fn limit_output_size(output: String) -> (String, bool) { + if output.len() > MAX_OUTPUT_SIZE { + let truncated_output = format!( + "{}... (truncated {} bytes)", + &output[..MAX_OUTPUT_SIZE], + output.len() - MAX_OUTPUT_SIZE + ); + (truncated_output, true) + } else { + (output, false) + } +} diff --git a/tests/src/test_runner/test_cases.toml b/tests/src/test_runner/test_cases.toml new file mode 100644 index 00000000..4fe73bfb --- /dev/null +++ b/tests/src/test_runner/test_cases.toml @@ -0,0 +1,42 @@ +[test.regular] +name = "Regular Integration Tests" +config = "tests/src/common/fixtures/kora-test.toml" +signers = "tests/src/common/fixtures/signers.toml" +port = "8080" +tests = ["rpc", "tokens", "external"] + +[test.auth] +name = "Auth Tests" +config = "tests/src/common/fixtures/auth-test.toml" +signers = "tests/src/common/fixtures/signers.toml" +port = "8081" +tests = ["auth"] + +[test.payment_address] +name = "Payment Address Tests" +config = "tests/src/common/fixtures/paymaster-address-test.toml" +signers = "tests/src/common/fixtures/signers.toml" +port = "8082" +tests = ["payment_address"] +initialize_payments_atas = true + +[test.multi_signer] +name = "Multi-Signer Tests" +config = "tests/src/common/fixtures/kora-test.toml" +signers = "tests/src/common/fixtures/multi-signers.toml" +port = "8083" +tests = ["multi_signer"] + +[test.typescript_basic] +name = "TypeScript SDK Tests" +config = "tests/src/common/fixtures/kora-test.toml" +signers = "tests/src/common/fixtures/signers.toml" +port = "8084" +tests = ["typescript_basic"] + +[test.typescript_auth] +name = "TypeScript Auth Tests" +config = "tests/src/common/fixtures/auth-test.toml" +signers = "tests/src/common/fixtures/signers.toml" +port = "8085" +tests = ["typescript_auth"] diff --git a/tests/src/test_runner/validator.rs b/tests/src/test_runner/validator.rs new file mode 100644 index 00000000..e17157bf --- /dev/null +++ b/tests/src/test_runner/validator.rs @@ -0,0 +1,74 @@ +use crate::{ + common::constants::DEFAULT_RPC_URL, + test_runner::accounts::{get_account_address_from_file, AccountFile}, +}; +use solana_client::nonblocking::rpc_client::RpcClient; +use std::path::Path; +use tokio::process::Child; + +const TRANSFER_HOOK_PROGRAM_ID: &str = "Bcdikjss8HWzKEuj6gEQoFq9TCnGnk6v3kUnRU1gb6hA"; +const TRANSFER_HOOK_PROGRAM_PATH: &str = + "tests/src/common/transfer-hook-example/transfer_hook_example.so"; + +pub async fn check_test_validator(rpc_url: &str) -> bool { + let client = RpcClient::new_with_commitment( + rpc_url.to_string(), + solana_commitment_config::CommitmentConfig::confirmed(), + ); + client.get_health().await.is_ok() +} + +pub async fn start_test_validator( + load_accounts: bool, +) -> Result> { + let mut cmd = tokio::process::Command::new("solana-test-validator"); + cmd.arg("--reset").arg("--quiet"); + + if Path::new(TRANSFER_HOOK_PROGRAM_PATH).exists() { + cmd.arg("--bpf-program").arg(TRANSFER_HOOK_PROGRAM_ID).arg(TRANSFER_HOOK_PROGRAM_PATH); + } else { + println!("โš ๏ธ Transfer hook program not found at: {TRANSFER_HOOK_PROGRAM_PATH}"); + println!(" Starting validator without transfer hook program"); + println!(" Run 'make build-transfer-hook' to build it if needed"); + } + + if load_accounts { + for account_file in AccountFile::required_test_accounts() { + let account_path = account_file.test_account_path(); + if account_path.exists() { + if let Ok(account_address) = get_account_address_from_file(&account_path).await { + cmd.arg("--account").arg(&account_address).arg(&account_path); + println!( + "Loading account: {} from {}", + account_address, + account_path.display() + ); + } + } + } + } + + let validator_pid = + cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()?; + + let mut attempts = 0; + let mut delay = std::time::Duration::from_millis(100); + let max_delay = std::time::Duration::from_secs(2); + let max_attempts = 15; + + while !check_test_validator(DEFAULT_RPC_URL).await { + attempts += 1; + if attempts > max_attempts { + return Err(format!( + "Solana test validator failed to start within {max_attempts} attempts" + ) + .into()); + } + + tokio::time::sleep(delay).await; + delay = std::cmp::min(delay * 2, max_delay); + } + + println!("Solana test validator started successfully"); + Ok(validator_pid) +}