#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SERVER_DIR="$ROOT_DIR/server" CLIENT_DIR="$ROOT_DIR/client" MODE="${1:-}" QA_HOST="${QA_HOST:-127.0.0.1}" QA_PORT_INPUT="${QA_PORT:-}" QA_PORT="${QA_PORT_INPUT:-3000}" QA_DB_HOST="${QA_DB_HOST:-127.0.0.1}" QA_DB_PORT="${QA_DB_PORT:-5432}" QA_DB_USER="${QA_DB_USER:-hyboa_user}" QA_DB_PASSWORD="${QA_DB_PASSWORD:-hyboa_password}" QA_DB_ADMIN_DB="${QA_DB_ADMIN_DB:-postgres}" QA_DB_CLIENT="${QA_DB_CLIENT:-host}" QA_DB_DOCKER_CONTAINER="${QA_DB_DOCKER_CONTAINER:-hyb-oa-trialrun-db}" QA_DOCKER_USE_SG="${QA_DOCKER_USE_SG:-0}" QA_DB_PREFIX="${QA_DB_PREFIX:-hyb_oa_webgate}" QA_KEEP_DB="${QA_KEEP_DB:-0}" QA_SERVER_LOG="${QA_SERVER_LOG:-/tmp/hyb-oa-web-gates-server.log}" QA_SERVER_PID_FILE="${QA_SERVER_PID_FILE:-/tmp/hyb-oa-web-gates-server.pid}" QA_READY_TIMEOUT_SECONDS="${QA_READY_TIMEOUT_SECONDS:-90}" QA_BASE_URL="" QA_DB_NAME="${QA_DB_NAME:-${QA_DB_PREFIX}_$(date +%Y%m%d%H%M%S)_$RANDOM}" DATABASE_URL="postgresql://${QA_DB_USER}:${QA_DB_PASSWORD}@${QA_DB_HOST}:${QA_DB_PORT}/${QA_DB_NAME}?schema=public" TEST_FIELD_ENCRYPTION_KEY_DEFAULT="${FIELD_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}" qa_require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "[ERROR] 缺少命令: $1" >&2 exit 1 } } qa_usage() { cat < 可选环境变量: QA_PORT / QA_HOST QA_DB_HOST / QA_DB_PORT / QA_DB_USER / QA_DB_PASSWORD / QA_DB_ADMIN_DB QA_DB_CLIENT=host|docker QA_DB_DOCKER_CONTAINER / QA_DOCKER_USE_SG=1 QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB QA_SKIP_CLIENT_BUILD=1 复用已有 client/dist(低内存远程机排查视觉问题时使用) LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS USAGE } qa_join_command() { local command="$1" shift local rendered="$command" local arg local quoted for arg in "$@"; do printf -v quoted '%q' "$arg" rendered+=" $quoted" done printf '%s' "$rendered" } qa_docker() { if [[ "$QA_DOCKER_USE_SG" == "1" ]]; then local command command="$(qa_join_command docker "$@")" sg docker -c "$command" return fi docker "$@" } qa_require_db_client() { case "$QA_DB_CLIENT" in host) qa_require_cmd psql qa_require_cmd createdb qa_require_cmd dropdb ;; docker) qa_require_cmd docker if [[ "$QA_DOCKER_USE_SG" == "1" ]]; then qa_require_cmd sg fi if ! qa_docker inspect "$QA_DB_DOCKER_CONTAINER" >/dev/null 2>&1; then echo "[ERROR] PostgreSQL 容器不可用: $QA_DB_DOCKER_CONTAINER" >&2 echo "[ERROR] 请确认容器运行,或设置 QA_DB_DOCKER_CONTAINER=。" >&2 exit 1 fi ;; *) echo "[ERROR] QA_DB_CLIENT 仅支持 host 或 docker,当前: $QA_DB_CLIENT" >&2 exit 1 ;; esac } qa_port_in_use() { python3 - "$QA_HOST" "$QA_PORT" <<'PY' import socket, sys host = sys.argv[1] port = int(sys.argv[2]) with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(0.5) try: sock.connect((host, port)) except OSError: sys.exit(1) sys.exit(0) PY } qa_refresh_base_url() { QA_BASE_URL="http://${QA_HOST}:${QA_PORT}" } qa_find_available_port() { python3 - "$QA_HOST" "$QA_PORT" <<'PY' import socket, sys host = sys.argv[1] start = int(sys.argv[2]) for port in range(start, start + 200): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sock.bind((host, port)) except OSError: continue print(port) sys.exit(0) raise SystemExit(1) PY } qa_select_port() { if qa_port_in_use; then if [[ -n "$QA_PORT_INPUT" ]]; then echo "[ERROR] 端口 ${QA_PORT} 已被占用,请改用 QA_PORT= 后重试。" >&2 exit 1 fi local next_port next_port="$(qa_find_available_port)" if [[ -z "$next_port" ]]; then echo "[ERROR] 无法为网页门禁分配空闲端口,请手工设置 QA_PORT。" >&2 exit 1 fi echo "[INFO] 默认端口 ${QA_PORT} 已被占用,自动切换到 ${next_port}" QA_PORT="$next_port" fi qa_refresh_base_url } qa_wait_port_free() { local timeout="${1:-15}" local elapsed=0 while [[ "$elapsed" -lt "$timeout" ]]; do if ! qa_port_in_use; then return 0 fi sleep 1 elapsed=$((elapsed + 1)) done return 1 } qa_wait_ready() { local timeout="$1" local elapsed=0 while [[ "$elapsed" -lt "$timeout" ]]; do if curl -fsS "$QA_BASE_URL/ready" >/dev/null 2>&1; then return 0 fi sleep 2 elapsed=$((elapsed + 2)) done echo "[ERROR] /ready 在 ${timeout}s 内未通过:$QA_BASE_URL/ready" >&2 return 1 } qa_psql() { if [[ "$QA_DB_CLIENT" == "docker" ]]; then qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \ psql -v ON_ERROR_STOP=1 -U "$QA_DB_USER" -d "$QA_DB_ADMIN_DB" "$@" return fi PGPASSWORD="$QA_DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" -d "$QA_DB_ADMIN_DB" "$@" } qa_createdb() { if [[ "$QA_DB_CLIENT" == "docker" ]]; then qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \ createdb -U "$QA_DB_USER" "$QA_DB_NAME" return fi PGPASSWORD="$QA_DB_PASSWORD" createdb -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" } qa_dropdb() { if [[ "$QA_KEEP_DB" == "1" ]]; then echo "[INFO] QA_KEEP_DB=1,保留临时数据库:$QA_DB_NAME" return 0 fi qa_psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${QA_DB_NAME}' AND pid <> pg_backend_pid();" >/dev/null || true if [[ "$QA_DB_CLIENT" == "docker" ]]; then qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \ dropdb --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true return fi PGPASSWORD="$QA_DB_PASSWORD" dropdb --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true } qa_cleanup_server() { if [[ -f "$QA_SERVER_PID_FILE" ]]; then local pid pid="$(cat "$QA_SERVER_PID_FILE")" if [[ -n "$pid" ]] && kill -0 "$pid" >/dev/null 2>&1; then kill "$pid" >/dev/null 2>&1 || true for _ in $(seq 1 15); do if ! kill -0 "$pid" >/dev/null 2>&1; then break fi sleep 1 done if kill -0 "$pid" >/dev/null 2>&1; then kill -9 "$pid" >/dev/null 2>&1 || true fi wait "$pid" >/dev/null 2>&1 || true fi rm -f "$QA_SERVER_PID_FILE" fi if qa_wait_port_free 15; then return 0 fi echo "[WARN] 端口 ${QA_PORT} 在 server 关闭后仍未释放,后续启动可能失败。" >&2 } qa_cleanup() { qa_cleanup_server if [[ -n "${QA_DB_NAME:-}" && "$MODE" != "smoke-live" ]]; then qa_dropdb fi } qa_export_app_env() { export NODE_ENV=test export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=4096}" export HOST="$QA_HOST" export PORT="$QA_PORT" export APP_ORIGIN="$QA_BASE_URL" export E2E_BASE_URL="$QA_BASE_URL" export BASE_URL="$QA_BASE_URL" export DATABASE_URL="$DATABASE_URL" export JWT_SECRET="${JWT_SECRET:-qa_web_gates_secret_key}" export OCR_PROVIDER="${OCR_PROVIDER:-mock}" export OCR_MAX_ATTEMPTS="${OCR_MAX_ATTEMPTS:-3}" export OCR_POLL_CRON="${OCR_POLL_CRON:-*/5 * * * * *}" export DELEGATION_REVOKE_CRON="${DELEGATION_REVOKE_CRON:-0 * * * *}" export SERVE_CLIENT_DIST="true" export CLIENT_DIST_DIR="../client/dist" export PORTAL_HOME_ENABLED="true" export GLOBAL_SEARCH_ENABLED="true" export ANNOUNCEMENTS_ENABLED="true" export PRESENCE_BOARD_ENABLED="true" export WEEKLY_REPORT_ENABLED="true" export EMPLOYEE_LIFECYCLE_ENABLED="true" export TODO_BOARD_ENABLED="true" export PROJECT_DOMAIN_ENABLED="true" export PROJECT_LINKING_ENABLED="true" export PROJECT_COMMUNICATION_LOG_ENABLED="true" export PROJECT_CLOSURE_CHECKLIST_ENABLED="true" export CONFIDENTIAL_DOC_REFS_ENABLED="true" export TRAVEL_SYNC_ENABLED="true" export IM_NOTIFIER_ENABLED="true" export FIELD_ENCRYPTION_KEY="$TEST_FIELD_ENCRYPTION_KEY_DEFAULT" } qa_prepare_db() { echo ">>> 生成并校验 Prisma Client" ( cd "$SERVER_DIR" npm run prisma:generate npm run prisma:check-generated ) echo ">>> 创建临时数据库: $QA_DB_NAME" qa_createdb echo ">>> 执行 Prisma migrate" ( cd "$SERVER_DIR" npm run prisma:migrate:deploy ) echo ">>> 写入开发种子数据" ( cd "$SERVER_DIR" npm run seed:dev ) } qa_start_server() { if ! qa_wait_port_free 15; then echo "[ERROR] 端口 ${QA_PORT} 已被占用,请改用 QA_PORT= 后重试。" >&2 exit 1 fi rm -f "$QA_SERVER_LOG" "$QA_SERVER_PID_FILE" echo ">>> 启动本地 server: $QA_BASE_URL" ( cd "$SERVER_DIR" nohup node src/index.js > "$QA_SERVER_LOG" 2>&1 & echo $! > "$QA_SERVER_PID_FILE" ) qa_wait_ready "$QA_READY_TIMEOUT_SECONDS" || { echo "[ERROR] server 未就绪,最近日志如下:" >&2 tail -n 200 "$QA_SERVER_LOG" >&2 || true exit 1 } } qa_build_client() { if [[ "${QA_SKIP_CLIENT_BUILD:-0}" == "1" ]]; then if [[ ! -f "$CLIENT_DIR/dist/index.html" ]]; then echo "[ERROR] QA_SKIP_CLIENT_BUILD=1 但 client/dist/index.html 不存在,请先执行 client 构建。" >&2 exit 1 fi echo ">>> 复用已有 client dist(QA_SKIP_CLIENT_BUILD=1)" return 0 fi echo ">>> 构建 client dist" ( cd "$CLIENT_DIR" npx tsc --noEmit --pretty false --extendedDiagnostics npx vite build ) } qa_run_smoke_local() { qa_select_port export JOBS_ENABLED="${JOBS_ENABLED:-false}" qa_export_app_env qa_prepare_db qa_build_client qa_start_server echo ">>> 执行 Playwright smoke 门禁" ( cd "$CLIENT_DIR" rm -rf test-results playwright-report E2E_MODE=local npm run test:e2e:smoke ) } qa_run_full_local() { qa_select_port export JOBS_ENABLED="${JOBS_ENABLED:-false}" qa_export_app_env qa_prepare_db echo ">>> 执行 server Prisma 校验" ( cd "$SERVER_DIR" npm run prisma:validate ) echo ">>> 执行 server 单元测试" ( cd "$SERVER_DIR" npm test ) echo ">>> 执行 client typecheck" ( cd "$CLIENT_DIR" npx tsc --noEmit --pretty false --extendedDiagnostics ) qa_build_client qa_start_server echo ">>> 执行 workflow-actions 回归" ( cd "$SERVER_DIR" ./scripts/regression-workflow-actions.sh ) echo ">>> 执行 server 全量回归" ( cd "$SERVER_DIR" ./scripts/regression-all.sh ) echo ">>> 切换 server 到 Playwright E2E 模式(启用后台 job)" qa_cleanup_server export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}" qa_start_server echo ">>> 执行 Playwright full E2E" ( cd "$CLIENT_DIR" rm -rf test-results playwright-report E2E_MODE=local npm run test:e2e:full ) } qa_run_r70_assist_local() { qa_select_port export JOBS_ENABLED="${JOBS_ENABLED:-true}" qa_export_app_env qa_prepare_db qa_build_client qa_start_server echo ">>> 执行 R70 自动辅助试运行" ( cd "$CLIENT_DIR" rm -rf test-results playwright-report E2E_MODE=local npm run test:e2e:r70-assist ) } qa_run_r106_visual_local() { qa_select_port export JOBS_ENABLED="${JOBS_ENABLED:-true}" qa_export_app_env qa_prepare_db qa_build_client qa_start_server echo ">>> 执行 R106 自动视觉巡检" ( cd "$CLIENT_DIR" rm -rf test-results playwright-report E2E_MODE=local npm run test:e2e:r106-visual-audit ) } qa_run_r108_mobile_local() { qa_select_port export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=1536}" export JOBS_ENABLED="${JOBS_ENABLED:-true}" qa_export_app_env qa_prepare_db qa_build_client qa_start_server echo ">>> 执行 R108 企业微信移动端视觉专项" ( cd "$CLIENT_DIR" rm -rf test-results playwright-report E2E_MODE=local npm run test:e2e:r108-mobile-visual ) } qa_run_live_smoke() { local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" echo ">>> 探活 live /ready: ${live_base_url}/ready" curl -fsS "${live_base_url}/ready" >/dev/null echo ">>> 执行 live smoke Playwright" ( cd "$CLIENT_DIR" rm -rf test-results playwright-report E2E_MODE=live npm run test:e2e:live ) } case "$MODE" in smoke-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_smoke_local ;; full-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_full_local ;; r70-assist-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_r70_assist_local ;; r106-visual-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_r106_visual_local ;; r108-mobile-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_r108_mobile_local ;; smoke-live) qa_require_cmd curl qa_require_cmd node qa_require_cmd npm qa_run_live_smoke ;; *) qa_usage >&2 exit 1 ;; esac