#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" TMP_DIR="$ROOT_DIR/.tmp/ui-e2e" LOG_DIR="$TMP_DIR/logs" POSTGRES_CONTAINER="crank-e2e-postgres" POSTGRES_PORT="${CRANK_E2E_POSTGRES_PORT:-55433}" ADMIN_PORT="${CRANK_E2E_ADMIN_PORT:-3301}" MCP_PORT="${CRANK_E2E_MCP_PORT:-3302}" UI_PORT="${CRANK_E2E_UI_PORT:-3300}" STREAM_FIXTURE_PORT="${CRANK_E2E_STREAM_FIXTURE_PORT:-3310}" POSTGRES_DB="${CRANK_E2E_POSTGRES_DB:-crank}" POSTGRES_USER="${CRANK_E2E_POSTGRES_USER:-crank}" POSTGRES_PASSWORD="${CRANK_E2E_POSTGRES_PASSWORD:-crank}" POSTGRES_HOST="${CRANK_E2E_POSTGRES_HOST:-127.0.0.1}" USE_EXTERNAL_POSTGRES="${CRANK_E2E_USE_EXTERNAL_POSTGRES:-0}" ADMIN_EMAIL="${CRANK_E2E_ADMIN_EMAIL:-owner@crank.local}" ADMIN_PASSWORD="${CRANK_E2E_ADMIN_PASSWORD:-change-me-admin-password}" STARTUP_TIMEOUT_SECONDS="${CRANK_E2E_STARTUP_TIMEOUT_SECONDS:-120}" if [[ ! "$STARTUP_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]]; then echo "CRANK_E2E_STARTUP_TIMEOUT_SECONDS must be a positive integer" >&2 exit 1 fi # These variables configure only this orchestration script. The application # rejects unknown variables in the owned CRANK_* namespace, so never leak the # E2E control plane into production process configuration. unset \ CRANK_E2E_ADMIN_EMAIL \ CRANK_E2E_ADMIN_PASSWORD \ CRANK_E2E_ADMIN_PORT \ CRANK_E2E_MCP_PORT \ CRANK_E2E_POSTGRES_DB \ CRANK_E2E_POSTGRES_HOST \ CRANK_E2E_POSTGRES_PASSWORD \ CRANK_E2E_POSTGRES_PORT \ CRANK_E2E_POSTGRES_USER \ CRANK_E2E_STARTUP_TIMEOUT_SECONDS \ CRANK_E2E_STREAM_FIXTURE_PORT \ CRANK_E2E_UI_PORT \ CRANK_E2E_USE_EXTERNAL_POSTGRES if [[ -f "$HOME/.cargo/env" ]]; then . "$HOME/.cargo/env" fi PYTHON_BIN="${PYTHON_BIN:-}" if [[ -z "$PYTHON_BIN" ]]; then if command -v python3 >/dev/null 2>&1; then PYTHON_BIN="python3" else PYTHON_BIN="python" fi fi mkdir -p "$LOG_DIR" find "$LOG_DIR" -maxdepth 1 -type f -name '*.log' -delete ( cd "$ROOT_DIR/apps/ui" npm run build >"$LOG_DIR/ui-build.log" 2>&1 ) kill_port_processes() { local port="$1" if command -v lsof >/dev/null 2>&1; then lsof -ti "tcp:$port" | xargs -r kill >/dev/null 2>&1 || true fi } cleanup() { set +e if [[ -f "$TMP_DIR/admin-api.pid" ]]; then kill "$(cat "$TMP_DIR/admin-api.pid")" >/dev/null 2>&1 || true rm -f "$TMP_DIR/admin-api.pid" fi if [[ -f "$TMP_DIR/mcp-server.pid" ]]; then kill "$(cat "$TMP_DIR/mcp-server.pid")" >/dev/null 2>&1 || true rm -f "$TMP_DIR/mcp-server.pid" fi if [[ -f "$TMP_DIR/ui-server.pid" ]]; then kill "$(cat "$TMP_DIR/ui-server.pid")" >/dev/null 2>&1 || true rm -f "$TMP_DIR/ui-server.pid" fi if [[ -f "$TMP_DIR/http-fixture.pid" ]]; then kill "$(cat "$TMP_DIR/http-fixture.pid")" >/dev/null 2>&1 || true rm -f "$TMP_DIR/http-fixture.pid" fi if [[ "$USE_EXTERNAL_POSTGRES" != "1" ]]; then docker rm -f "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true fi kill_port_processes "$UI_PORT" kill_port_processes "$ADMIN_PORT" kill_port_processes "$MCP_PORT" kill_port_processes "$STREAM_FIXTURE_PORT" } trap cleanup EXIT trap 'exit 130' INT TERM cleanup show_startup_log() { local log_file="$1" if [[ -f "$log_file" ]]; then echo "---- $log_file ----" >&2 tail -n 120 "$log_file" >&2 fi } wait_for_port() { local label="$1" local host="$2" local port="$3" local deadline=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) echo "Waiting for $label on $host:$port" while true; do if "$PYTHON_BIN" - "$host" "$port" <<'PY' import socket, sys sock = socket.socket() sock.settimeout(0.5) try: sock.connect((sys.argv[1], int(sys.argv[2]))) except OSError: sys.exit(1) finally: sock.close() PY then return 0 fi if (( SECONDS >= deadline )); then echo "Timed out waiting for $label on $host:$port after ${STARTUP_TIMEOUT_SECONDS}s" >&2 return 1 fi sleep 1 done } wait_for_http() { local label="$1" local url="$2" local pid_file="$3" local log_file="$4" local deadline=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) echo "Waiting for $label at $url" while true; do if "$PYTHON_BIN" - "$url" <<'PY' import sys, urllib.request try: with urllib.request.urlopen(sys.argv[1], timeout=0.5) as response: sys.exit(0 if 200 <= response.status < 400 else 1) except Exception: sys.exit(1) PY then return 0 fi local pid="" if [[ -f "$pid_file" ]]; then pid="$(cat "$pid_file")" fi if [[ -z "$pid" ]] || ! kill -0 "$pid" >/dev/null 2>&1; then echo "$label exited before becoming ready at $url" >&2 show_startup_log "$log_file" return 1 fi if (( SECONDS >= deadline )); then echo "Timed out waiting for $label at $url after ${STARTUP_TIMEOUT_SECONDS}s" >&2 show_startup_log "$log_file" return 1 fi sleep 1 done } wait_for_postgres_container() { local deadline=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) echo "Waiting for PostgreSQL container $POSTGRES_CONTAINER" while ! docker exec "$POSTGRES_CONTAINER" pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; do if ! docker inspect "$POSTGRES_CONTAINER" >/dev/null 2>&1; then echo "PostgreSQL container $POSTGRES_CONTAINER exited before becoming ready" >&2 return 1 fi if (( SECONDS >= deadline )); then echo "Timed out waiting for PostgreSQL container after ${STARTUP_TIMEOUT_SECONDS}s" >&2 docker logs "$POSTGRES_CONTAINER" >&2 || true return 1 fi sleep 1 done } if [[ "$USE_EXTERNAL_POSTGRES" = "1" ]]; then wait_for_port "PostgreSQL" "$POSTGRES_HOST" "$POSTGRES_PORT" || exit 1 else docker run -d --rm \ --name "$POSTGRES_CONTAINER" \ -e POSTGRES_DB="$POSTGRES_DB" \ -e POSTGRES_USER="$POSTGRES_USER" \ -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ -p "$POSTGRES_PORT:5432" \ postgres:16-alpine >/dev/null wait_for_postgres_container || exit 1 fi export POSTGRES_HOST export POSTGRES_PORT="$POSTGRES_PORT" export POSTGRES_DB="$POSTGRES_DB" export POSTGRES_USER="$POSTGRES_USER" export POSTGRES_PASSWORD="$POSTGRES_PASSWORD" export DATABASE_URL="postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB" export SQLX_OFFLINE="true" export CRANK_STORAGE_ROOT="$TMP_DIR/storage" export CRANK_ADMIN_BIND="0.0.0.0:$ADMIN_PORT" export CRANK_MCP_BIND="0.0.0.0:$MCP_PORT" export CRANK_MCP_REFRESH_MS="1000" export CRANK_OUTBOUND_ALLOWED_HOSTS="127.0.0.1" export CRANK_LOG_LEVEL="info" export CRANK_SESSION_SECRET="e2e-session-secret" export CRANK_PASSWORD_PEPPER="e2e-password-pepper" export CRANK_MASTER_KEY="0000000000000000000000000000000000000000000000000000000000000000" export CRANK_SESSION_TTL_HOURS="24" export CRANK_BOOTSTRAP_ADMIN_EMAIL="$ADMIN_EMAIL" export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank E2E" export CRANK_BASE_URL="http://127.0.0.1:$UI_PORT" mkdir -p -m 700 "$CRANK_STORAGE_ROOT" chmod 700 "$CRANK_STORAGE_ROOT" ( cd "$ROOT_DIR/apps/ui" exec node scripts/playwright-http-fixture.js >"$LOG_DIR/http-fixture.log" 2>&1 ) & echo $! > "$TMP_DIR/http-fixture.pid" wait_for_http \ "Playwright HTTP fixture" \ "http://127.0.0.1:$STREAM_FIXTURE_PORT/health" \ "$TMP_DIR/http-fixture.pid" \ "$LOG_DIR/http-fixture.log" || exit 1 if ! ( cd "$ROOT_DIR" cargo run -p admin-api --bin crank-migrate -- apply >"$LOG_DIR/migrate.log" 2>&1 ); then echo "Failed to apply E2E database migrations" >&2 show_startup_log "$LOG_DIR/migrate.log" exit 1 fi if ! ( cd "$ROOT_DIR" cargo run -p admin-api --bin crank-migrate -- admin-auth bootstrap-create \ --email "$ADMIN_EMAIL" \ --display-name "Crank E2E" \ >"$LOG_DIR/bootstrap-create.log" 2>&1 ); then echo "Failed to create the E2E bootstrap contract" >&2 show_startup_log "$LOG_DIR/bootstrap-create.log" exit 1 fi BOOTSTRAP_JSON="$(tail -n 1 "$LOG_DIR/bootstrap-create.log")" if ! BOOTSTRAP_TOKEN="$("$PYTHON_BIN" - <<'PY' "$BOOTSTRAP_JSON" import json, sys print(json.loads(sys.argv[1])["bootstrap_token"]) PY )"; then echo "Failed to parse the E2E bootstrap token" >&2 show_startup_log "$LOG_DIR/bootstrap-create.log" exit 1 fi printf '%s' "$BOOTSTRAP_TOKEN" >"$TMP_DIR/bootstrap-token.txt" printf '%s' "$ADMIN_PASSWORD" >"$TMP_DIR/admin-password.txt" printf '%s' "$CRANK_PASSWORD_PEPPER" >"$TMP_DIR/password-pepper.txt" if ! ( cd "$ROOT_DIR" cargo run -p admin-api --bin crank-migrate -- admin-auth bootstrap-complete \ --token-file "$TMP_DIR/bootstrap-token.txt" \ --password-file "$TMP_DIR/admin-password.txt" \ --password-pepper-file "$TMP_DIR/password-pepper.txt" \ >"$LOG_DIR/bootstrap-complete.log" 2>&1 ); then echo "Failed to complete the E2E admin bootstrap" >&2 show_startup_log "$LOG_DIR/bootstrap-complete.log" exit 1 fi ( cd "$ROOT_DIR" exec env -u CRANK_MCP_BIND -u CRANK_MCP_REFRESH_MS CRANK_DEMO_SEED=true \ cargo run -p admin-api --bin admin-api >>"$LOG_DIR/admin-api.log" 2>&1 ) & echo $! > "$TMP_DIR/admin-api.pid" wait_for_http \ "admin-api" \ "http://127.0.0.1:$ADMIN_PORT/health" \ "$TMP_DIR/admin-api.pid" \ "$LOG_DIR/admin-api.log" || exit 1 ( cd "$ROOT_DIR" exec env -u CRANK_ADMIN_BIND -u CRANK_STORAGE_ROOT -u CRANK_SESSION_SECRET \ -u CRANK_PASSWORD_PEPPER -u CRANK_SESSION_TTL_HOURS \ -u CRANK_BOOTSTRAP_ADMIN_EMAIL -u CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME -u CRANK_DEMO_SEED \ cargo run -p mcp-server >"$LOG_DIR/mcp-server.log" 2>&1 ) & echo $! > "$TMP_DIR/mcp-server.pid" wait_for_http \ "mcp-server" \ "http://127.0.0.1:$MCP_PORT/health" \ "$TMP_DIR/mcp-server.pid" \ "$LOG_DIR/mcp-server.log" || exit 1 ( cd "$ROOT_DIR/apps/ui" exec node scripts/playwright-ui-server.js >"$LOG_DIR/ui-server.log" 2>&1 ) & echo $! > "$TMP_DIR/ui-server.pid" wait_for_http \ "Playwright UI server" \ "http://127.0.0.1:$UI_PORT/login" \ "$TMP_DIR/ui-server.pid" \ "$LOG_DIR/ui-server.log" || exit 1 echo "Crank UI e2e stack is ready on http://127.0.0.1:$UI_PORT" while true; do sleep 1 done