From f7ef56f65f3bcf718f17a1d2bb4389ad81be5a75 Mon Sep 17 00:00:00 2001 From: johba Date: Tue, 7 Oct 2025 21:57:32 +0000 Subject: [PATCH] reworked stack --- containers/bootstrap.sh | 13 + onchain/deployments-local.json | 19 +- podman-compose.yml | 36 +- scripts/dev.sh | 155 ++-- services/ponder/generated/schema.graphql | 9 + tests/e2e/01-acquire-and-stake.spec.ts | 60 +- tests/setup/stack.ts | 137 +--- web-app/README.md | 49 +- web-app/env.d.ts | 3 - web-app/src/components/StakeHolder.vue | 761 +++++++++++++----- .../src/components/fcomponents/FButton.vue | 11 +- web-app/src/components/fcomponents/FInput.vue | 58 +- 12 files changed, 853 insertions(+), 458 deletions(-) diff --git a/containers/bootstrap.sh b/containers/bootstrap.sh index 24da139..b6f70a6 100755 --- a/containers/bootstrap.sh +++ b/containers/bootstrap.sh @@ -193,6 +193,18 @@ prime_chain() { log "Pre-mining complete" } +write_deployments_json() { + cat >"$ROOT_DIR/onchain/deployments-local.json" <"$ROOT_DIR/services/ponder/.env.local" </dev/null; then - echo "Stopping existing kraiken-lib watcher ($existing_pid)..." - kill "$existing_pid" 2>/dev/null || true - wait "$existing_pid" 2>/dev/null || true +cleanup_existing() { + # Kill any existing watch scripts + pkill -f "watch-kraiken-lib.sh" 2>/dev/null || true + pkill -f "inotifywait.*$(pwd)/kraiken-lib" 2>/dev/null || true + + # Remove PID file + rm -f "$PID_FILE" + + # Kill zombie podman processes + pkill -9 -f "podman wait.*harb_" 2>/dev/null || true + + # Remove any existing containers (suppress errors if they don't exist) + echo " Cleaning up existing containers..." + podman ps -a --filter "name=harb_" --format "{{.Names}}" 2>/dev/null | \ + xargs -r podman rm -f 2>&1 | grep -v "Error.*no container" || true +} + +# Wait for container to be healthy (via healthcheck) +wait_for_healthy() { + local container=$1 + local timeout_sec=$2 + local max_attempts=$((timeout_sec / POLL_INTERVAL)) + local start_time=$(date +%s) + + for i in $(seq 1 "$max_attempts"); do + if podman healthcheck run "$container" &>/dev/null; then + local elapsed=$(($(date +%s) - start_time)) + echo " ✓ $container ready (${elapsed}s)" + return 0 fi - rm -f "$PID_FILE" - fi + sleep "$POLL_INTERVAL" + done + + echo "ERROR: $container failed to become healthy after ${timeout_sec}s" + return 1 +} + +# Wait for container to exit (used for bootstrap) +wait_for_exited() { + local container=$1 + local timeout_sec=$2 + local max_attempts=$((timeout_sec / POLL_INTERVAL)) + local start_time=$(date +%s) + + for i in $(seq 1 "$max_attempts"); do + local status + status=$(podman inspect "$container" --format='{{.State.Status}}' 2>/dev/null || echo "unknown") + if [[ "$status" == "exited" ]]; then + local elapsed=$(($(date +%s) - start_time)) + echo " ✓ $container completed (${elapsed}s)" + return 0 + fi + sleep "$POLL_INTERVAL" + done + + echo "ERROR: $container failed to complete after ${timeout_sec}s" + return 1 +} + +start_stack() { + local stack_start_time=$(date +%s) + + # Clean up any existing processes first + cleanup_existing # Show branch if set if [[ -n "${GIT_BRANCH:-}" ]]; then @@ -27,84 +89,53 @@ start_stack() { ./scripts/build-kraiken-lib.sh echo "Starting stack..." - # Start services in strict dependency order with explicit create+start - # This avoids podman dependency graph issues - - # Create all containers first (without starting) - echo " Creating containers..." - podman-compose up --no-start 2>&1 | grep -v "STEP\|Copying\|Writing\|Getting\|fetch\|Installing\|Executing" || true # Phase 1: Start base services (no dependencies) echo " Starting anvil & postgres..." - podman-compose start anvil postgres >/dev/null 2>&1 + podman-compose up -d anvil postgres 2>&1 | grep -v "STEP\|Copying\|Writing\|Getting\|fetch\|Installing\|Executing" || true - # Wait for base services to be healthy - echo " Waiting for anvil & postgres..." - for i in {1..30}; do - anvil_healthy=$(podman healthcheck run harb_anvil_1 >/dev/null 2>&1 && echo "yes" || echo "no") - postgres_healthy=$(podman healthcheck run harb_postgres_1 >/dev/null 2>&1 && echo "yes" || echo "no") - if [[ "$anvil_healthy" == "yes" ]] && [[ "$postgres_healthy" == "yes" ]]; then - break - fi - sleep 2 - done + wait_for_healthy harb_anvil_1 "$ANVIL_TIMEOUT" || exit 1 + wait_for_healthy harb_postgres_1 "$POSTGRES_TIMEOUT" || exit 1 # Phase 2: Start bootstrap (depends on anvil & postgres healthy) echo " Starting bootstrap..." - podman-compose start bootstrap >/dev/null 2>&1 + podman-compose up -d bootstrap >/dev/null 2>&1 - # Wait for bootstrap to complete - echo " Waiting for bootstrap..." - for i in {1..60}; do - bootstrap_status=$(podman inspect harb_bootstrap_1 --format='{{.State.Status}}') - if [[ "$bootstrap_status" == "exited" ]]; then - break - fi - sleep 2 - done + wait_for_exited harb_bootstrap_1 "$BOOTSTRAP_TIMEOUT" || exit 1 # Phase 3: Start ponder (depends on bootstrap completed) echo " Starting ponder..." - podman-compose start ponder >/dev/null 2>&1 + podman-compose up -d ponder >/dev/null 2>&1 - # Wait for ponder to be healthy - echo " Waiting for ponder..." - for i in {1..60}; do - ponder_healthy=$(podman healthcheck run harb_ponder_1 >/dev/null 2>&1 && echo "yes" || echo "no") - if [[ "$ponder_healthy" == "yes" ]]; then - break - fi - sleep 2 - done + wait_for_healthy harb_ponder_1 "$PONDER_TIMEOUT" || exit 1 # Phase 4: Start frontend services (depend on ponder healthy) echo " Starting webapp, landing, txn-bot..." - podman-compose start webapp landing txn-bot >/dev/null 2>&1 + podman-compose up -d webapp landing txn-bot >/dev/null 2>&1 + + wait_for_healthy harb_webapp_1 "$WEBAPP_TIMEOUT" || exit 1 # Phase 5: Start caddy (depends on frontend services) - sleep 5 echo " Starting caddy..." - podman-compose start caddy >/dev/null 2>&1 + podman-compose up -d caddy >/dev/null 2>&1 - echo "Watching for kraiken-lib changes..." - ./scripts/watch-kraiken-lib.sh & - echo $! > "$PID_FILE" + wait_for_healthy harb_caddy_1 "$CADDY_TIMEOUT" || exit 1 + if [[ -z "${SKIP_WATCH:-}" ]]; then + echo "Watching for kraiken-lib changes..." + ./scripts/watch-kraiken-lib.sh & + echo $! > "$PID_FILE" + fi + + local total_time=$(($(date +%s) - stack_start_time)) echo "" - echo "[ok] Stack started" + echo "[ok] Stack started in ${total_time}s" echo " Web App: http://localhost:8081/app/" echo " GraphQL: http://localhost:8081/graphql" } stop_stack() { - if [[ -f "$PID_FILE" ]]; then - local watcher_pid - watcher_pid=$(cat "$PID_FILE") - if kill "$watcher_pid" 2>/dev/null; then - wait "$watcher_pid" 2>/dev/null || true - fi - rm -f "$PID_FILE" - fi + cleanup_existing podman-compose down echo "[ok] Stack stopped" } diff --git a/services/ponder/generated/schema.graphql b/services/ponder/generated/schema.graphql index 2f70eea..a3a3945 100644 --- a/services/ponder/generated/schema.graphql +++ b/services/ponder/generated/schema.graphql @@ -244,6 +244,7 @@ type positions { owner: String! share: Float! taxRate: Float! + taxRateIndex: Int! kraikenDeposit: BigInt! stakeDeposit: BigInt! taxPaid: BigInt! @@ -303,6 +304,14 @@ input positionsFilter { taxRate_lt: Float taxRate_gte: Float taxRate_lte: Float + taxRateIndex: Int + taxRateIndex_not: Int + taxRateIndex_in: [Int] + taxRateIndex_not_in: [Int] + taxRateIndex_gt: Int + taxRateIndex_lt: Int + taxRateIndex_gte: Int + taxRateIndex_lte: Int kraikenDeposit: BigInt kraikenDeposit_not: BigInt kraikenDeposit_in: [BigInt] diff --git a/tests/e2e/01-acquire-and-stake.spec.ts b/tests/e2e/01-acquire-and-stake.spec.ts index d758852..61e8f4b 100644 --- a/tests/e2e/01-acquire-and-stake.spec.ts +++ b/tests/e2e/01-acquire-and-stake.spec.ts @@ -1,13 +1,16 @@ import { expect, test, type APIRequestContext } from '@playwright/test'; import { Wallet } from 'ethers'; import { createWalletContext } from '../setup/wallet-provider'; -import { startStack, waitForStackReady, stopStack } from '../setup/stack'; +import { getStackConfig, validateStackHealthy } from '../setup/stack'; const ACCOUNT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; const ACCOUNT_ADDRESS = new Wallet(ACCOUNT_PRIVATE_KEY).address.toLowerCase(); -const STACK_RPC_URL = process.env.STACK_RPC_URL ?? 'http://127.0.0.1:8545'; -const STACK_WEBAPP_URL = process.env.STACK_WEBAPP_URL ?? 'http://localhost:5173'; -const STACK_GRAPHQL_URL = process.env.STACK_GRAPHQL_URL ?? 'http://localhost:42069/graphql'; + +// Get stack configuration from environment (or defaults) +const STACK_CONFIG = getStackConfig(); +const STACK_RPC_URL = STACK_CONFIG.rpcUrl; +const STACK_WEBAPP_URL = STACK_CONFIG.webAppUrl; +const STACK_GRAPHQL_URL = STACK_CONFIG.graphqlUrl; async function fetchPositions(request: APIRequestContext, owner: string) { const response = await request.post(STACK_GRAPHQL_URL, { @@ -42,17 +45,10 @@ async function fetchPositions(request: APIRequestContext, owner: string) { } test.describe('Acquire & Stake', () => { + // Validate that a healthy stack exists before running tests + // Tests do NOT start their own stack - stack must be running already test.beforeAll(async () => { - await startStack(); - await waitForStackReady({ - rpcUrl: STACK_RPC_URL, - webAppUrl: STACK_WEBAPP_URL, - graphqlUrl: STACK_GRAPHQL_URL, - }); - }); - - test.afterAll(async () => { - await stopStack(); + await validateStackHealthy(STACK_CONFIG); }); test('users can swap KRK via UI', async ({ browser, request }) => { @@ -153,22 +149,30 @@ test.describe('Acquire & Stake', () => { // Wait for the stake form to be initialized console.log('[TEST] Waiting for stake form to load...'); - await page.waitForSelector('text=Token Amount', { timeout: 15_000 }); + const tokenAmountSlider = page.getByRole('slider', { name: 'Token Amount' }); + await expect(tokenAmountSlider).toBeVisible({ timeout: 15_000 }); - // Use the test helper to fill the stake form - console.log('[TEST] Filling stake form via test helper...'); - await page.evaluate(async () => { - if (!window.__testHelpers) { - throw new Error('Test helpers not available'); - } - await window.__testHelpers.fillStakeForm({ - amount: 100, // Stake 100 KRK - taxRateIndex: 2, // 5% tax rate option - }); - }); + console.log('[TEST] Filling stake form via accessible controls...'); + + // Take screenshot before interaction + await page.screenshot({ path: 'test-results/before-stake-fill.png' }); + + // Check if input is visible and enabled + const stakeAmountInput = page.getByLabel('Staking Amount'); + console.log('[TEST] Checking if staking amount input is visible...'); + await expect(stakeAmountInput).toBeVisible({ timeout: 10_000 }); + console.log('[TEST] Staking amount input is visible, filling value...'); + await stakeAmountInput.fill('100'); + console.log('[TEST] Filled staking amount!'); + + const taxSelect = page.getByRole('combobox', { name: 'Tax' }); + console.log('[TEST] Selecting tax rate...'); + await taxSelect.selectOption({ value: '2' }); + console.log('[TEST] Tax rate selected!'); console.log('[TEST] Clicking stake button...'); - const stakeButton = page.getByRole('button', { name: /Stake|Snatch and Stake/i }); + // Use the main form's submit button (large/block), not the small position card buttons + const stakeButton = page.getByRole('main').getByRole('button', { name: /Stake|Snatch and Stake/i }); await expect(stakeButton).toBeVisible({ timeout: 5_000 }); await stakeButton.click(); @@ -190,7 +194,7 @@ test.describe('Acquire & Stake', () => { console.log(`[TEST] Found ${positions.length} position(s)`); expect(positions.length).toBeGreaterThan(0); - const activePositions = positions.filter(p => p.status === 'OPEN'); + const activePositions = positions.filter(p => p.status === 'Active'); expect(activePositions.length).toBeGreaterThan(0); console.log(`[TEST] ✅ Staking successful! Created ${activePositions.length} active position(s)`); diff --git a/tests/setup/stack.ts b/tests/setup/stack.ts index 9204f67..fc2001a 100644 --- a/tests/setup/stack.ts +++ b/tests/setup/stack.ts @@ -1,118 +1,49 @@ -import { exec as execCallback } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; import { runAllHealthChecks, formatHealthCheckError, - type HealthCheckResult, } from './health-checks.js'; -const exec = promisify(execCallback); +const DEFAULT_RPC_URL = 'http://127.0.0.1:8545'; +const DEFAULT_WEBAPP_URL = 'http://localhost:8081'; +const DEFAULT_GRAPHQL_URL = 'http://localhost:8081/graphql'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const repoRoot = resolve(__dirname, '..', '..'); - -const DEFAULT_RPC_URL = process.env.STACK_RPC_URL ?? 'http://127.0.0.1:8545'; -const DEFAULT_WEBAPP_URL = process.env.STACK_WEBAPP_URL ?? 'http://localhost:5173'; -const DEFAULT_GRAPHQL_URL = process.env.STACK_GRAPHQL_URL ?? 'http://localhost:42069/graphql'; - -let stackStarted = false; - -async function cleanupContainers(): Promise { - try { - await run("podman pod ps -q --filter name=harb | xargs -r podman pod rm -f || true"); - await run("podman ps -aq --filter name=harb | xargs -r podman rm -f || true"); - } catch (error) { - console.warn('[stack] Failed to cleanup containers', error); - } -} - -function delay(ms: number): Promise { - return new Promise(resolveDelay => setTimeout(resolveDelay, ms)); -} - -async function run(command: string): Promise { - await exec(command, { cwd: repoRoot, shell: true }); +export interface StackConfig { + rpcUrl: string; + webAppUrl: string; + graphqlUrl: string; } /** - * Run functional health checks that verify services are actually usable, not just running + * Get stack configuration from environment variables. + * Tests should NOT start their own stack - they require a pre-existing healthy stack. */ -async function checkStackFunctional( - rpcUrl: string, - webAppUrl: string, - graphqlUrl: string -): Promise { - const results = await runAllHealthChecks({ rpcUrl, webAppUrl, graphqlUrl }); +export function getStackConfig(): StackConfig { + return { + rpcUrl: process.env.STACK_RPC_URL ?? DEFAULT_RPC_URL, + webAppUrl: process.env.STACK_WEBAPP_URL ?? DEFAULT_WEBAPP_URL, + graphqlUrl: process.env.STACK_GRAPHQL_URL ?? DEFAULT_GRAPHQL_URL, + }; +} + +/** + * Validate that a healthy stack exists and is functional. + * If validation fails, the test run should exit immediately. + * + * Tests do NOT manage stack lifecycle - stack must be started externally via: + * ./scripts/dev.sh start + */ +export async function validateStackHealthy(config: StackConfig): Promise { + const results = await runAllHealthChecks(config); const failures = results.filter(r => !r.success); if (failures.length > 0) { - throw new Error(formatHealthCheckError(results)); - } -} - -export async function startStack(): Promise { - if (stackStarted) { - return; - } - - await cleanupContainers(); - - await run('nohup ./scripts/dev.sh start > ./tests/.stack.log 2>&1 &'); - stackStarted = true; -} - -export async function waitForStackReady(options: { - rpcUrl?: string; - webAppUrl?: string; - graphqlUrl?: string; - timeoutMs?: number; - pollIntervalMs?: number; -} = {}): Promise { - const rpcUrl = options.rpcUrl ?? DEFAULT_RPC_URL; - const webAppUrl = options.webAppUrl ?? DEFAULT_WEBAPP_URL; - const graphqlUrl = options.graphqlUrl ?? DEFAULT_GRAPHQL_URL; - const timeoutMs = options.timeoutMs ?? 180_000; - const pollIntervalMs = options.pollIntervalMs ?? 2_000; - - const start = Date.now(); - const errors = new Map(); - - while (Date.now() - start < timeoutMs) { - try { - await checkStackFunctional(rpcUrl, webAppUrl, graphqlUrl); - console.log('✅ All stack health checks passed'); - return; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - errors.set('lastError', message); - await delay(pollIntervalMs); - } - } - - const logPath = resolve(repoRoot, 'tests', '.stack.log'); - let logTail = ''; - try { - const contents = await readFile(logPath, 'utf-8'); - logTail = contents.split('\n').slice(-40).join('\n'); - } catch (readError) { - logTail = `Unable to read stack log: ${readError}`; - } - - throw new Error(`Stack failed to become ready within ${timeoutMs}ms\nLast error: ${errors.get('lastError')}\nLog tail:\n${logTail}`); -} - -export async function stopStack(): Promise { - if (!stackStarted) { - return; - } - - try { - await run('./scripts/dev.sh stop'); - } finally { - stackStarted = false; + const errorMessage = formatHealthCheckError(results); + console.error('\n❌ Stack health validation failed'); + console.error('Tests require a pre-existing healthy stack.'); + console.error('Start the stack with: ./scripts/dev.sh start\n'); + console.error(errorMessage); + process.exit(1); } + + console.log('✅ Stack health validation passed'); } diff --git a/web-app/README.md b/web-app/README.md index 6707b0d..3b13880 100644 --- a/web-app/README.md +++ b/web-app/README.md @@ -34,52 +34,15 @@ npm run build ## Testing -### Test Helpers +### Accessibility Hooks -The application exposes test helpers on `window.__testHelpers` in development mode to facilitate E2E testing. +The staking form now exposes semantic controls that Playwright can exercise directly: -#### Available Helpers +- Slider: `page.getByRole('slider', { name: 'Token Amount' })` +- Amount input: `page.getByLabel('Staking Amount')` +- Tax selector: `page.getByLabel('Tax')` -##### `fillStakeForm(params)` - -Programmatically fills the staking form without requiring fragile UI selectors. - -**Parameters:** -- `amount` (number): Amount of KRK tokens to stake (must be >= minimum stake) -- `taxRateIndex` (number): Index of the tax rate option (must match one of the configured options) - -**Example:** -```typescript -// In Playwright test -await page.evaluate(async () => { - await window.__testHelpers.fillStakeForm({ - amount: 100, - taxRateIndex: 2, - }); -}); - -// Then click the stake button -const stakeButton = page.getByRole('button', { name: /Stake|Snatch and Stake/i }); -await stakeButton.click(); -``` - -**Validation:** -- Throws if amount is below minimum stake -- Throws if amount exceeds wallet balance -- Throws if `taxRateIndex` does not match an available option - -**TypeScript Support:** -Type declarations are available in `env.d.ts`: -```typescript -interface Window { - __testHelpers?: { - fillStakeForm: (params: { amount: number; taxRateIndex: number }) => Promise; - }; -} -``` - -**Security:** -Test helpers are only available when `import.meta.env.DEV === true` and are automatically stripped from production builds. +Tests should rely on these roles and labels instead of private helpers. ### E2E Tests diff --git a/web-app/env.d.ts b/web-app/env.d.ts index dfa0f6e..5a8efba 100644 --- a/web-app/env.d.ts +++ b/web-app/env.d.ts @@ -5,9 +5,6 @@ import type { EIP1193Provider } from 'viem'; declare global { interface Window { ethereum?: EIP1193Provider; - __testHelpers?: { - fillStakeForm: (params: { amount: number; taxRateIndex: number }) => Promise; - }; } } diff --git a/web-app/src/components/StakeHolder.vue b/web-app/src/components/StakeHolder.vue index 9d27d20..70d0906 100644 --- a/web-app/src/components/StakeHolder.vue +++ b/web-app/src/components/StakeHolder.vue @@ -8,72 +8,135 @@ @@ -82,9 +145,8 @@ diff --git a/web-app/src/components/fcomponents/FButton.vue b/web-app/src/components/fcomponents/FButton.vue index 2094a5d..d034ecd 100644 --- a/web-app/src/components/fcomponents/FButton.vue +++ b/web-app/src/components/fcomponents/FButton.vue @@ -1,5 +1,12 @@ @@ -15,6 +22,7 @@ interface Props { bgColor?: string; light?: boolean; dark?: boolean; + type?: 'button' | 'submit' | 'reset'; } import { computed } from 'vue'; @@ -22,6 +30,7 @@ import { computed } from 'vue'; const props = withDefaults(defineProps(), { size: 'medium', bgColor: '', + type: 'button', }); const classObject = computed(() => ({ diff --git a/web-app/src/components/fcomponents/FInput.vue b/web-app/src/components/fcomponents/FInput.vue index 1cc7b77..c473090 100644 --- a/web-app/src/components/fcomponents/FInput.vue +++ b/web-app/src/components/fcomponents/FInput.vue @@ -1,7 +1,7 @@