文件
hyb-oa/scripts/qa/run-web-gates.sh
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

1063 行
34 KiB
Bash
可执行文件
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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 <<USAGE
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|retained-local|retained-server-local|auth-security-local|payment-concurrency-local|business-flow-local|project-wide-visual-local|qg6-visual-matrix-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
可选环境变量:
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=<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=<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=<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