#!/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" source "$ROOT_DIR/scripts/qa/pwa-cross-version-fixture.sh" MODE="${1:-}" if [[ "$MODE" == "full-local" || "$MODE" == "full-local-diagnostic" || "$MODE" == "retained-local" || "$MODE" == "retained-server-local" ]]; then if [[ -z "${QA_ENVIRONMENT_CONTEXT:-}" ]]; then exec node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" "$MODE" fi node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify elif [[ "$MODE" == "__full-local-diagnostic-stage" ]]; then node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify fi 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}" if [[ "$MODE" == "auth-security-local" ]]; then DEFAULT_QA_DB_PREFIX="hyb_oa_auth" elif [[ "$MODE" == "payment-concurrency-local" ]]; then DEFAULT_QA_DB_PREFIX="hyb_oa_payment" else DEFAULT_QA_DB_PREFIX="hyb_oa_webgate" fi QA_DB_PREFIX="${QA_DB_PREFIX:-$DEFAULT_QA_DB_PREFIX}" QA_KEEP_DB="${QA_KEEP_DB:-0}" QA_RUN_ID="$(date +%Y%m%d%H%M%S)_$$_$RANDOM" QA_SERVER_LOG="${QA_SERVER_LOG:-/tmp/hyb-oa-web-gates-server-${QA_RUN_ID}.log}" QA_SERVER_PID_FILE="${QA_SERVER_PID_FILE:-/tmp/hyb-oa-web-gates-server-${QA_RUN_ID}.pid}" QA_SERVER_ENTRY="src/index.js" 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}" QA_DB_CREATED=0 QA_FULL_STAGE_FILE="" QA_SERVER_STARTED=0 QA_SERVER_OWNED=0 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="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_FULL_REPORT_DIR full-local 独立发现、构建日志与浏览器结果目录 retained-local 受管限定重验,无普通发布资格;必填 QA_RETAINED_SPECS QA_RETAINED_GREP 可选历史用例标题筛选,不改变 full-local 分母 QA_RETAINED_SERVER_SUITE retained-server-local 必填 workflow-actions 或 server-regressions full-local-diagnostic 仅本地诊断,始终 PARTIAL/退出 2,无发布资格 QA_DIAGNOSTIC_REASON / QA_DIAGNOSTIC_OPERATOR / QA_DIAGNOSTIC_APPROVAL_REF QA_DIAGNOSTIC_REPORT_DIR 本次诊断专用空目录;禁止覆盖历史报告 QA_SKIP_CLIENT_BUILD=1 复用已有 client/dist(低内存远程机排查视觉问题时使用) QA_ISSUE_ID / VISUAL_MATRIX_REPORT_DIR 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 if [[ "$QA_DB_CLIENT" == "docker" ]]; then if ! qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \ dropdb --force --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then echo "[ERROR] 临时数据库删除失败" >&2 return 1 fi QA_DB_CREATED=0 return fi if ! PGPASSWORD="$QA_DB_PASSWORD" dropdb --force --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null; then echo "[ERROR] 临时数据库删除失败" >&2 return 1 fi QA_DB_CREATED=0 } qa_cleanup_server() { if [[ "$QA_SERVER_STARTED" != "1" || "$QA_SERVER_OWNED" != "1" ]]; then return 0 fi 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 QA_SERVER_STARTED=0 QA_SERVER_OWNED=0 if qa_wait_port_free 15; then return 0 fi echo "[WARN] 端口 ${QA_PORT} 在 server 关闭后仍未释放,后续启动可能失败。" >&2 } qa_cleanup() { local original_status=$? local cleanup_status=0 trap - EXIT if ! qa_cleanup_server; then cleanup_status=1 fi if ! pwa_cleanup_fixture; then cleanup_status=1 fi if [[ "$QA_DB_CREATED" == "1" && "$MODE" != "smoke-live" ]]; then if ! qa_dropdb; then cleanup_status=1 fi fi if [[ -n "$QA_FULL_STAGE_FILE" ]]; then local stage_status="$original_status" if [[ "$stage_status" == "0" ]]; then stage_status="$cleanup_status"; fi if ! node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" finish "$QA_FULL_STAGE_FILE" "$stage_status"; then cleanup_status=1 fi fi if [[ "$original_status" != "0" ]]; then exit "$original_status" fi exit "$cleanup_status" } 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 AUTH_CODE_PEPPER="${AUTH_CODE_PEPPER:-qa_auth_code_pepper_with_at_least_32_bytes}" 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 ATTENDANCE_ENABLED="${ATTENDANCE_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 ERP_SYNC_ENABLED="${ERP_SYNC_ENABLED:-true}" export PAYMENT_BATCH_ENABLED="${PAYMENT_BATCH_ENABLED:-true}" export IM_NOTIFIER_ENABLED="true" export FIELD_ENCRYPTION_KEY="${FIELD_ENCRYPTION_KEY:-$TEST_FIELD_ENCRYPTION_KEY_DEFAULT}" } qa_run_auth_security_local() { case "$QA_HOST" in 127.0.0.1|localhost|::1) ;; *) echo "[ERROR] auth-security-local 只允许 loopback API" >&2 exit 1 ;; esac case "$QA_DB_HOST" in 127.0.0.1|localhost|::1) ;; *) echo "[ERROR] auth-security-local 只允许 loopback PostgreSQL" >&2 exit 1 ;; esac if [[ ! "$QA_DB_NAME" =~ ^hyb_oa_auth_[A-Za-z0-9_]+$ ]]; then echo "[ERROR] auth-security-local 数据库名必须使用 hyb_oa_auth_ 前缀" >&2 exit 1 fi qa_select_port export JOBS_ENABLED=false export RATE_LIMIT_ENABLED=false export DEV_SHOW_PASSWORD_RESET_CODE=false unset JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY qa_export_app_env export EMAIL_ENABLED=false export SMTP_HOST="" export SMTP_PORT="" export SMTP_SECURE=false export SMTP_USER="" export SMTP_PASS="" export IM_NOTIFIER_ENABLED=false export WECOM_ENABLED=false export WECOM_AGENT_SECRET="" export OIDC_ENABLED=false export OIDC_CLIENT_SECRET="" unset JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY JWT_SECRET="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')" AUTH_CODE_PEPPER="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')" FIELD_ENCRYPTION_KEY="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("base64"))')" export JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY export TRUST_PROXY_HOPS=0 export SERVE_CLIENT_DIST=false export RUN_AUTH_SECURITY_REGRESSION=1 export AUTH_SECURITY_DATABASE_CONFIRM="$QA_DB_NAME" QA_SERVER_ENTRY="src/scripts/start-auth-security-test-server.js" qa_prepare_db qa_start_server echo ">>> 执行认证安全与真实 PostgreSQL 并发回归" ( cd "$SERVER_DIR" RUN_AUTH_POSTGRES_CONCURRENCY=1 ./scripts/regression-auth-security.sh ) } qa_run_payment_concurrency_local() { case "$QA_DB_HOST" in 127.0.0.1|localhost|::1) ;; *) echo "[ERROR] payment-concurrency-local 只允许 loopback PostgreSQL" >&2 exit 1 ;; esac if [[ ! "$QA_DB_NAME" =~ ^hyb_oa_payment_[A-Za-z0-9_]+$ ]]; then echo "[ERROR] payment-concurrency-local 数据库名必须使用 hyb_oa_payment_ 前缀" >&2 exit 1 fi qa_select_port export JOBS_ENABLED=false export RATE_LIMIT_ENABLED=false export TRUST_PROXY_HOPS=0 export PAYMENT_BATCH_ENABLED=true export ERP_SYNC_ENABLED=false export SERVE_CLIENT_DIST=false unset JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY qa_export_app_env export SERVE_CLIENT_DIST=false export METRICS_ENABLED=false export EMAIL_ENABLED=false export SMTP_HOST="" export SMTP_PORT="" export SMTP_SECURE=false export SMTP_USER="" export SMTP_PASS="" export IM_NOTIFIER_ENABLED=false export WECOM_ENABLED=false export WECOM_AGENT_SECRET="" export WECOM_CONTACT_SECRET="" export WECOM_HR_ASSISTANT_SECRET="" export OIDC_ENABLED=false export OIDC_CLIENT_SECRET="" export STORAGE_DRIVER=local JWT_SECRET="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')" AUTH_CODE_PEPPER="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')" FIELD_ENCRYPTION_KEY="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("base64"))')" export JWT_SECRET AUTH_CODE_PEPPER FIELD_ENCRYPTION_KEY export PAYMENT_CONCURRENCY_DATABASE_CONFIRM="$QA_DB_NAME" qa_prepare_db echo ">>> 执行付款真实 PostgreSQL 并发回归" ( cd "$SERVER_DIR" RUN_PAYMENT_POSTGRES_CONCURRENCY=1 node src/scripts/check-payment-postgres-concurrency.js ) } qa_prepare_db() { echo ">>> 生成并校验 Prisma Client" ( cd "$SERVER_DIR" npm run prisma:generate npm run prisma:check-generated ) echo ">>> 创建临时数据库: $QA_DB_NAME" qa_createdb QA_DB_CREATED=1 echo ">>> 执行 Prisma migrate" ( cd "$SERVER_DIR" npm run prisma:migrate:deploy ) echo ">>> 写入开发种子数据" ( cd "$SERVER_DIR" SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM="$QA_DB_NAME" npm run seed:dev ) } qa_start_server() { if [[ "$QA_SERVER_STARTED" == "1" || "$QA_SERVER_OWNED" == "1" ]]; then echo "[ERROR] 本轮 server 已启动,拒绝重复 spawn" >&2 exit 1 fi 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 "$QA_SERVER_ENTRY" > "$QA_SERVER_LOG" 2>&1 & echo $! > "$QA_SERVER_PID_FILE" ) if [[ ! -s "$QA_SERVER_PID_FILE" ]]; then echo "[ERROR] 未取得本轮 server PID" >&2 exit 1 fi QA_SERVER_STARTED=1 QA_SERVER_OWNED=1 qa_wait_ready "$QA_READY_TIMEOUT_SECONDS" || { echo "[ERROR] server 未就绪,最近日志如下:" >&2 tail -n 200 "$QA_SERVER_LOG" >&2 || true exit 1 } # Derive browser capabilities from this successfully started server, never # from a caller's PLAYWRIGHT_OCR_MOCK assertion. A restart recalculates it. export PLAYWRIGHT_OCR_MOCK=0 if [[ "$OCR_PROVIDER" == "mock" && "$JOBS_ENABLED" == "true" ]]; then export PLAYWRIGHT_OCR_MOCK=1 fi if [[ -n "${QA_FULL_REPORT_DIR:-}" ]]; then mkdir -p "$QA_FULL_REPORT_DIR" node -e 'require("node:fs").appendFileSync(process.argv[1], JSON.stringify({provider:process.env.OCR_PROVIDER,jobsEnabled:process.env.JOBS_ENABLED === "true",mockCorrectionEnabled:process.env.PLAYWRIGHT_OCR_MOCK === "1",source:"owned-server-start"})+"\n")' "$QA_FULL_REPORT_DIR/server-capabilities.jsonl" fi } 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 NODE_ENV=production 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_prepare_full_inputs() { QA_FULL_REPORT_DIR="${QA_FULL_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/full-local/$QA_RUN_ID}" mkdir -p "$QA_FULL_REPORT_DIR" QA_FULL_REPORT_DIR="$(cd "$QA_FULL_REPORT_DIR" && pwd)" echo ">>> 准备 full-local PWA 双构建输入" pwa_prepare_fixture "$CLIENT_DIR" "$QA_FULL_REPORT_DIR" echo ">>> 发现并核对全部非 manual Playwright spec" ( cd "$CLIENT_DIR" pwa_with_fixture env E2E_MODE=local \ PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/discovery.json" \ QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" \ npx playwright test --list --retries=0 --forbid-only --reporter=json \ > "$QA_FULL_REPORT_DIR/discovery.stdout.log" 2> "$QA_FULL_REPORT_DIR/discovery.stderr.log" ) node "$ROOT_DIR/scripts/qa/verify-full-local-discovery.mjs" "$QA_FULL_REPORT_DIR/discovery.json" \ > "$QA_FULL_REPORT_DIR/discovery-coverage.json" node "$ROOT_DIR/scripts/qa/full-local-coverage.mjs" plan "$QA_FULL_REPORT_DIR" \ > "$QA_FULL_REPORT_DIR/coverage-plan.json" } # One owner prepares synthetic accounts before any business suite runs. qa_seed_synthetic_credentials() { export QA_SYNTHETIC_INITIAL_PASSWORD="Init!$(node -e 'process.stdout.write(require("node:crypto").randomBytes(24).toString("hex"))')" local role for role in CEO ADMIN MANAGER EMPLOYEE FINANCE FINANCE_MGR HR ADMINISTRATION; do export "SEED_${role}_PASSWORD=$QA_SYNTHETIC_INITIAL_PASSWORD" done } qa_prepare_synthetic_accounts() { mkdir -p "$QA_FULL_REPORT_DIR" export SEED_DEV_DATABASE_CONFIRM="$QA_DB_NAME" node "$ROOT_DIR/scripts/qa/prepare-local-e2e-accounts.mjs" "$QA_FULL_REPORT_DIR/synthetic-account-preparation.json" unset QA_SYNTHETIC_INITIAL_PASSWORD for role in CEO ADMIN MANAGER EMPLOYEE FINANCE FINANCE_MGR HR ADMINISTRATION; do unset "SEED_${role}_PASSWORD"; done } # Focused historical-case diagnosis; never a normal full-local release receipt. qa_run_retained_local() { [[ -n "${QA_RETAINED_SPECS:-}" ]] || { echo "retained-local requires an explicit spec list" >&2; return 1; } local -a retained_specs read -r -a retained_specs <<< "$QA_RETAINED_SPECS" local spec for spec in "${retained_specs[@]}"; do [[ "$spec" =~ ^[a-zA-Z0-9_-]+\.spec\.ts$ && -f "$CLIENT_DIR/tests/e2e/$spec" ]] || { echo "invalid local spec selector" >&2; return 1; } done mkdir -p "$QA_FULL_REPORT_DIR" node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({scope:"focused-historical-retest",normalReleaseEligible:false,specs:process.argv.slice(2),grep:process.env.QA_RETAINED_GREP||null})+"\n",{flag:"wx"})' "$QA_FULL_REPORT_DIR/selection.json" "${retained_specs[@]}" qa_select_port export JOBS_ENABLED=true RATE_LIMIT_ENABLED=false IM_NOTIFIER_ENABLED=false qa_export_app_env export IM_NOTIFIER_ENABLED=false qa_seed_synthetic_credentials qa_prepare_db qa_build_client qa_start_server mkdir -p "$QA_FULL_REPORT_DIR" qa_prepare_synthetic_accounts node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL" node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" report-guest > "$QA_FULL_REPORT_DIR/environment-before-browser.json" local -a pwa_env=() if [[ " $QA_RETAINED_SPECS " == *" pwa-cross-version-upgrade.spec.ts "* ]]; then pwa_prepare_fixture "$CLIENT_DIR" "$QA_FULL_REPORT_DIR" pwa_env=("PWA_BUILD_A_DIR=$PWA_BUILD_A_DIR" "PWA_BUILD_B_DIR=$PWA_BUILD_B_DIR" "PWA_BUILD_A_SHA=$PWA_BUILD_A_SHA" "PWA_BUILD_B_SHA=$PWA_BUILD_B_SHA") fi local -a grep_args=() if [[ -n "${QA_RETAINED_GREP:-}" ]]; then grep_args=(--grep "$QA_RETAINED_GREP"); fi ( cd "$CLIENT_DIR" env "${pwa_env[@]}" E2E_MODE=local PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/playwright-results.json" \ QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" QA_VISUAL_ARTIFACT_DIR="$QA_FULL_REPORT_DIR/visual-artifacts" \ node node_modules/@playwright/test/cli.js test "${retained_specs[@]}" "${grep_args[@]}" \ --retries=0 --reporter=line,json --output="$QA_FULL_REPORT_DIR/test-results" ) } # A fixed server suite in the same managed environment; never full-local coverage. qa_run_retained_server_local() { case "${QA_RETAINED_SERVER_SUITE:-}" in workflow-actions|server-regressions) ;; *) echo "retained-server-local requires an allowed server suite" >&2; return 1 ;; esac mkdir -p "$QA_FULL_REPORT_DIR" node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({scope:"focused-server-regression",normalReleaseEligible:false,suite:process.argv[2]})+"\n",{flag:"wx"})' "$QA_FULL_REPORT_DIR/selection.json" "$QA_RETAINED_SERVER_SUITE" qa_select_port qa_export_app_env export JOBS_ENABLED=false RATE_LIMIT_ENABLED=false IM_NOTIFIER_ENABLED=false if [[ "$QA_RETAINED_SERVER_SUITE" == "server-regressions" ]]; then export IM_NOTIFIER_ENABLED=true; fi qa_seed_synthetic_credentials qa_prepare_db qa_build_client qa_start_server node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL" qa_prepare_synthetic_accounts ( cd "$SERVER_DIR" case "$QA_RETAINED_SERVER_SUITE" in workflow-actions) ./scripts/regression-workflow-actions.sh ;; server-regressions) ./scripts/regression-all.sh ;; esac ) } # Plain calls preserve errexit inside compound helpers. Failure is sealed by # qa_cleanup; wrapping "$@" in an if/|| would suppress their inner failures. qa_full_stage() { local stage="$1" shift node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" start "$QA_FULL_STAGE_FILE" "$stage" echo ">>> [full-local] $stage" "$@" node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" end "$QA_FULL_STAGE_FILE" "$stage" 0 } qa_full_in_directory() ( cd "$1" shift "$@" ) qa_full_database() { qa_seed_synthetic_credentials qa_prepare_db } qa_full_server_start() { qa_start_server node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL" } qa_full_browser_server() { qa_cleanup_server export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}" qa_start_server } qa_run_full_local() { qa_select_port export JOBS_ENABLED="${JOBS_ENABLED:-false}" qa_export_app_env export RATE_LIMIT_ENABLED=false QA_FULL_REPORT_DIR="${QA_FULL_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/full-local/$QA_RUN_ID}" mkdir -p "$QA_FULL_REPORT_DIR" node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" init "$QA_FULL_REPORT_DIR/full-local-stages.json" QA_FULL_STAGE_FILE="$QA_FULL_REPORT_DIR/full-local-stages.json" qa_full_stage inputs qa_prepare_full_inputs qa_full_stage database qa_full_database qa_full_stage prisma qa_full_in_directory "$SERVER_DIR" npm run prisma:validate qa_full_stage server-unit qa_full_in_directory "$SERVER_DIR" npm test qa_full_stage typecheck qa_full_in_directory "$CLIENT_DIR" npx tsc --noEmit --pretty false --extendedDiagnostics qa_full_stage client-build qa_build_client qa_full_stage server-start qa_full_server_start qa_full_stage accounts qa_prepare_synthetic_accounts qa_full_stage workflow-actions qa_full_in_directory "$SERVER_DIR" ./scripts/regression-workflow-actions.sh qa_full_stage server-regressions qa_full_in_directory "$SERVER_DIR" ./scripts/regression-all.sh qa_full_stage browser-server qa_full_browser_server qa_full_stage browser qa_run_full_browser } qa_run_full_browser() { # Only the diagnostic owner may add tracing; ordinary callers cannot narrow coverage. if (( $# )) && [[ "${QA_DIAGNOSTIC_STAGE:-}" != "browser" || "$*" != "--trace=on" ]]; then echo "[ERROR] full-local browser scope override rejected" >&2 return 1 fi local -a local_specs mapfile -t local_specs < "$QA_FULL_REPORT_DIR/local-spec-args.txt" (( ${#local_specs[@]} > 0 )) || return 1 if [[ -n "${QA_DIAGNOSTIC_STAGE:-}" ]]; then QA_DIAGNOSTIC_PHASE=environment; fi node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL" node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" report-guest > "$QA_FULL_REPORT_DIR/environment-before-browser.json" if [[ -n "${QA_DIAGNOSTIC_STAGE:-}" ]]; then QA_DIAGNOSTIC_PHASE=test; fi echo ">>> 执行 Playwright full E2E" local browser_exit=0 coverage_exit=0 node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" selection "$QA_FULL_REPORT_DIR/execution-selection.json" "${#local_specs[@]}" "${local_specs[@]}" "$@" ( cd "$CLIENT_DIR" pwa_with_fixture env E2E_MODE=local \ PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/playwright-results.json" \ QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" \ PLAYWRIGHT_HTML_OUTPUT_DIR="$QA_FULL_REPORT_DIR/playwright-report" PLAYWRIGHT_HTML_OPEN=never \ npm run test:e2e:full -- "${local_specs[@]}" "$@" --retries=0 --forbid-only --reporter=list,json,html --output="$QA_FULL_REPORT_DIR/test-results" ) || browser_exit=$? node "$ROOT_DIR/scripts/qa/full-local-coverage.mjs" verify-local "$QA_FULL_REPORT_DIR" \ > "$QA_FULL_REPORT_DIR/local-coverage-result.json" || coverage_exit=$? if (( browser_exit )); then return "$browser_exit"; fi return "$coverage_exit" } 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_r109_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 ">>> 执行 R109 企业微信移动报销反馈专项" ( cd "$CLIENT_DIR" rm -rf test-results playwright-report E2E_MODE=local npm run test:e2e:r109-mobile-feedback ) } qa_run_r110_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 ">>> 执行 R110 手机和 PC 路由分流专项" ( cd "$CLIENT_DIR" rm -rf test-results playwright-report E2E_MODE=local npm run test:e2e:r110-mobile-routing ) } qa_run_business_flow_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 ">>> 执行业务流门禁" E2E_BASE_URL="$QA_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-business-flow-gates.sh" attached } qa_run_project_wide_visual_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 ">>> 执行项目级全页面视觉巡检" ( cd "$CLIENT_DIR" rm -rf test-results playwright-report E2E_BASE_URL="$QA_BASE_URL" E2E_MODE=local npx playwright test tests/e2e/project-wide-visual.spec.ts --workers=1 --reporter=list ) } qa_run_qg6_visual_matrix_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 local issue_id="${QA_ISSUE_ID:-qg6-qa-governance-upgrade}" local report_dir="${VISUAL_MATRIX_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/${issue_id}-visual-matrix-$(date +%Y%m%d-%H%M%S)}" mkdir -p "$report_dir" echo ">>> 执行 QG6 固定视觉矩阵" ( cd "$CLIENT_DIR" rm -rf test-results playwright-report E2E_BASE_URL="$QA_BASE_URL" \ E2E_MODE=local \ QA_ISSUE_ID="$issue_id" \ QA_VISUAL_OUTPUT_DIR="$ROOT_DIR/output/playwright/${issue_id}/project-wide" \ QA_VISUAL_ARTIFACT_DIR="$report_dir/project-wide" \ MOBILE_VISUAL_OUTPUT_DIR="${issue_id}/mobile-matrix" \ npx playwright test \ tests/e2e/project-wide-visual.spec.ts \ tests/e2e/r6-mobile-unified-design-rollout.spec.ts \ --workers=1 \ --reporter=list ) QA_REPORT_DIR="$report_dir" \ QA_ISSUE_ID="$issue_id" \ QA_BASE_URL="$QA_BASE_URL" \ QA_MODE="visual-matrix-local" \ QA_STATUS="PASS" \ QA_SUMMARY_NOTES="PC 1440x960;移动 320/360/375/390/393/414/430;企业微信 iOS/Android UA 和普通移动 UA。" \ node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" } 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-diagnostic) qa_require_cmd node node "$ROOT_DIR/scripts/qa/full-local-diagnostic.mjs" ;; __full-local-diagnostic-stage) source "$ROOT_DIR/scripts/qa/full-local-diagnostic-stage.sh" qa_run_diagnostic_stage "${2:-}" ;; retained-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_retained_local ;; retained-server-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_retained_server_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 ;; auth-security-local) qa_require_cmd curl qa_require_cmd jq qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_auth_security_local ;; payment-concurrency-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_payment_concurrency_local ;; business-flow-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_business_flow_local ;; project-wide-visual-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_project_wide_visual_local ;; qg6-visual-matrix-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_qg6_visual_matrix_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 ;; r109-mobile-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_r109_mobile_local ;; r110-mobile-local) qa_require_cmd curl qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT qa_run_r110_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