#!/usr/bin/env bash # e2e 一键跑通: # 1. 启 docker compose(postgres + redis) # 2. 复制 .env.e2e → .env,注入强随机 JWT 密钥 # 3. migrate --seed,跑 LocalDemoSeeder(admin/12345678 + demo_player) # 4. php artisan serve 起应用 # 5. 启动 queue:work(异步开奖/广播) # 6. 启动 reverb:start # 7. npx playwright install chromium(仅缺浏览器缓存时) # 8. npx playwright test # 9. 失败时打 docker logs + Laravel 日志;结束时清理 compose set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT_DIR" E2E_DIR="$ROOT_DIR/e2e" COMPOSE_FILE="$E2E_DIR/docker-compose.yml" ENV_FILE="$ROOT_DIR/.env" ENV_E2E="$E2E_DIR/.env.e2e" ARTIFACT_DIR="$E2E_DIR/artifacts" LOG_DIR="$E2E_DIR/logs" mkdir -p "$ARTIFACT_DIR" "$LOG_DIR" API_URL="http://127.0.0.1:8000" PUBLIC_URL="$API_URL/api/v1" PIDS=() cleanup() { local code=$? trap - INT TERM EXIT echo echo "==> Cleaning up e2e processes (code=$code)…" for pid in "${PIDS[@]:-}"; do [[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true done # docker compose 留给用户决定是否 down,避免误删 pg volume exit "$code" } trap cleanup INT TERM EXIT require() { command -v "$1" >/dev/null 2>&1 || { echo "Missing: $1"; exit 2; } } require docker require php require node require npx playwright_cache_dir() { case "$(uname -s)" in Darwin) echo "$HOME/Library/Caches/ms-playwright" ;; Linux) echo "${XDG_CACHE_HOME:-$HOME/.cache}/ms-playwright" ;; MINGW*|MSYS*|CYGWIN*) echo "${LOCALAPPDATA:-$HOME/AppData/Local}/ms-playwright" ;; *) echo "${XDG_CACHE_HOME:-$HOME/.cache}/ms-playwright" ;; esac } playwright_chromium_cached() { local cache_dir cache_dir="$(playwright_cache_dir)" # UI 项目依赖 chromium_headless_shell;API 仅 request 时可能不触发,但一并检测。 compgen -G "${cache_dir}/chromium-"* >/dev/null 2>&1 \ || compgen -G "${cache_dir}/chromium_headless_shell-"* >/dev/null 2>&1 \ || [[ -d "$E2E_DIR/node_modules/playwright-core/.local-browsers" ]] } echo "==> [1/8] docker compose up -d" if ! docker image inspect postgres:16-alpine >/dev/null 2>&1 \ || ! docker image inspect redis:7-alpine >/dev/null 2>&1; then echo " pulling postgres/redis images (docker-credential-desktop workaround)" mkdir -p /tmp/docker-e2e-nocreds printf '%s\n' '{"auths":{}}' > /tmp/docker-e2e-nocreds/config.json docker --config /tmp/docker-e2e-nocreds pull postgres:16-alpine docker --config /tmp/docker-e2e-nocreds pull redis:7-alpine fi if ! docker info >/dev/null 2>&1; then cat <<'EOF' >&2 !! Docker daemon 未运行。 启动 Docker Desktop / OrbStack / Colima 后重试。 macOS Docker Desktop: 打开 Docker.app macOS OrbStack: open -a OrbStack macOS Colima: colima start 或者不使用 docker,直接本机起 PG(端口 15432)+ Redis(端口 16379), 跳过这一步,只跑 [4/8] 起的 .env + 后面步骤。 EOF exit 5 fi docker compose -f "$COMPOSE_FILE" up -d echo "==> [2/8] wait for pg/redis health" for i in {1..30}; do if docker compose -f "$COMPOSE_FILE" ps --format json | grep -q '"Health":"healthy"' \ || docker compose -f "$COMPOSE_FILE" ps | grep -E "(healthy|running)" >/dev/null; then pg_ok=$(docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U lottery -d lottery_e2e 2>/dev/null || true) redis_ok=$(docker compose -f "$COMPOSE_FILE" exec -T redis redis-cli ping 2>/dev/null || true) if [[ "$pg_ok" == *"accepting connections"* && "$redis_ok" == "PONG" ]]; then echo " pg/redis healthy" break fi fi sleep 1 if [[ $i -eq 30 ]]; then echo "!! pg/redis not healthy after 30s; dumping compose logs" docker compose -f "$COMPOSE_FILE" logs --no-color exit 3 fi done echo "==> [3/8] install composer deps if missing" if [[ ! -d vendor ]]; then composer install --no-interaction --prefer-dist fi echo "==> [4/8] prepare .env" if [[ ! -f "$ENV_FILE" ]]; then cp "$ENV_E2E" "$ENV_FILE" fi # 同步关键 e2e 变量(不覆盖已存在的 APP_KEY,避免触发额外 key:generate 流程) python3 - < [5/8] lottery:db-init --fresh (e2e environment only)" # 注意:AGENTS.md 要求 migrate:fresh 须用户确认;本脚本为 e2e 自动化专用,仅作用于 # docker compose 内的 lottery_e2e 库,绝不触及生产。 # composer dump-autoload 让 E2EPlayerSeeder(位于 e2e/database/seeders)被发现。 composer dump-autoload --quiet # 用统一入口:migrate:fresh + FoundationSeeder + admin-auth-sync + LocalDemoSeeder # lottery:db-init --fresh 内部已对 migrate 传 --force,无需外层加。 DB_DATABASE=lottery_e2e php artisan lottery:db-init --fresh # e2e 专用 seeder:建可登录玩家(带 password_hash)。LocalDemoSeeder 不会建可登录玩家 DB_DATABASE=lottery_e2e php artisan db:seed --class='E2E\Seeders\E2EPlayerSeeder' --force echo "==> [6/10] boot backend processes" php artisan config:clear >/dev/null export LOTTERY_NATIVE_JWT_SECRET="$(grep '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE" | cut -d= -f2-)" if [[ -z "${LOTTERY_NATIVE_JWT_SECRET}" ]]; then LOTTERY_NATIVE_JWT_SECRET="$(openssl rand -hex 32)" if grep -q '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE"; then perl -i -pe "s/^LOTTERY_NATIVE_JWT_SECRET=.*/LOTTERY_NATIVE_JWT_SECRET=${LOTTERY_NATIVE_JWT_SECRET}/" "$ENV_FILE" else echo "LOTTERY_NATIVE_JWT_SECRET=${LOTTERY_NATIVE_JWT_SECRET}" >>"$ENV_FILE" fi export LOTTERY_NATIVE_JWT_SECRET fi export LOTTERY_E2E=true php artisan serve --host=127.0.0.1 --port=8000 >"$LOG_DIR/serve.log" 2>&1 & PIDS+=($!) php artisan queue:work redis --queue=broadcasts:countdown,broadcasts,default --tries=3 --timeout=120 >"$LOG_DIR/queue.log" 2>&1 & PIDS+=($!) php artisan reverb:start --host=127.0.0.1 --hostname=127.0.0.1 --port=8080 >"$LOG_DIR/reverb.log" 2>&1 & PIDS+=($!) node "$E2E_DIR/scripts/mock-wallet-server.mjs" >"$LOG_DIR/mock-wallet.log" 2>&1 & PIDS+=($!) ADMIN_DIR="$ROOT_DIR/../lotteryadmin" FRONT_DIR="$ROOT_DIR/../lotteryfront" if [[ "${E2E_UI:-1}" == "1" && -d "$ADMIN_DIR" ]]; then echo "==> [7/10] start lotteryadmin (3801)" (cd "$ADMIN_DIR" && LOTTERY_API_UPSTREAM="$API_URL" ALLOWED_DEV_ORIGINS=127.0.0.1 npm run dev) >"$LOG_DIR/admin-ui.log" 2>&1 & PIDS+=($!) fi if [[ "${E2E_UI:-1}" == "1" && -d "$FRONT_DIR" ]]; then echo "==> [7/10] start lotteryfront (3800)" (cd "$FRONT_DIR" && LOTTERY_API_UPSTREAM="$API_URL" NEXT_PUBLIC_PLAYER_SITE_CODE=demo ALLOWED_DEV_ORIGINS=127.0.0.1 npm run dev) >"$LOG_DIR/front-ui.log" 2>&1 & PIDS+=($!) fi echo "==> [8/10] wait for API healthy" for i in {1..30}; do if curl -fsS "$PUBLIC_URL/health" >/dev/null 2>&1; then echo " API up" break fi sleep 1 if [[ $i -eq 30 ]]; then echo "!! API not healthy; logs:" tail -n 50 "$LOG_DIR"/*.log exit 4 fi done if [[ "${E2E_UI:-1}" == "1" ]]; then echo "==> [9/10] wait for admin/front UI" for i in {1..60}; do admin_ok=false front_ok=false curl -fsS "http://localhost:3801/admin/login" >/dev/null 2>&1 && admin_ok=true curl -fsS "http://localhost:3800/login" >/dev/null 2>&1 && front_ok=true if [[ "$admin_ok" == true && "$front_ok" == true ]]; then echo " UI up" break fi sleep 2 if [[ $i -eq 60 ]]; then echo "!! UI not ready; see admin-ui.log / front-ui.log" fi done fi echo "==> [10/10] playwright test" cd "$E2E_DIR" if [[ ! -d node_modules ]]; then npm install fi # UI 默认 channel:chrome(本机 Google Chrome);仅显式要求 bundled 时才下载 ms-playwright Chromium。 if [[ "${PLAYWRIGHT_USE_BUNDLED_CHROMIUM:-0}" == "1" ]]; then if ! playwright_chromium_cached; then export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$(playwright_cache_dir)}" npx playwright install chromium else export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$(playwright_cache_dir)}" fi fi export PLAYWRIGHT_JWT_SECRET="$(grep '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE" | cut -d= -f2-)" export PLAYWRIGHT_API_URL="$API_URL" export PLAYWRIGHT_ADMIN_URL="http://localhost:3801" export PLAYWRIGHT_FRONT_URL="http://localhost:3800" export REVERB_APP_KEY="$(grep '^REVERB_APP_KEY=' "$ENV_FILE" | cut -d= -f2-)" export REVERB_HOST=127.0.0.1 export REVERB_PORT=8080 export E2E_MOCK_WALLET_PORT=5555 export E2E_ADMIN_USERNAME=admin export E2E_ADMIN_PASSWORD=12345678 export E2E_PLAYER_USERNAME="$(grep '^E2E_PLAYER_USERNAME=' "$ENV_E2E" | cut -d= -f2-)" export E2E_PLAYER_PASSWORD="$(grep '^E2E_PLAYER_PASSWORD=' "$ENV_E2E" | cut -d= -f2-)" if [[ "${E2E_UI:-1}" != "1" ]]; then set -- --project=api "$@" fi npx playwright test "$@" rc=$? if [[ $rc -ne 0 ]]; then echo "==> Playwright failed; saving artifacts" cp -R "$E2E_DIR/test-results" "$ARTIFACT_DIR/test-results-$(date +%s)" 2>/dev/null || true cp -R "$E2E_DIR/playwright-report" "$ARTIFACT_DIR/playwright-report-$(date +%s)" 2>/dev/null || true tail -n 200 "$LOG_DIR"/*.log > "$ARTIFACT_DIR/backend-logs-$(date +%s).log" || true fi exit "$rc"