#!/usr/bin/env bash set -u -o pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" CLIENT_DIR="$ROOT_DIR/client" SERVER_DIR="$ROOT_DIR/server" PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" REPORT_DIR="${WFULLSMOKE_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/three-day-full-goal-smoke-$(date +%Y%m%d-%H%M%S)}" SUMMARY_TXT="$REPORT_DIR/summary.txt" STATUS=0 FINDING_COUNT=0 usage() { cat <<'EOF' Usage: bash scripts/qa/run-three-day-full-goal-smoke.sh --remote WFULLSMOKE includes Enterprise WeChat read checks, so business evidence must run on the deployment server trusted IP. Local execution is intentionally blocked and bash -n should be used for syntax/static checks. EOF } run_remote() { local deploy_server="${DEPLOY_SERVER:-deploy@118.89.77.218}" local remote_dir="${REMOTE_DIR:-/opt/hyb-oa-trialrun}" local remote_env_file="${REMOTE_ENV_FILE:-${remote_dir}/deploy/trialrun/.env}" local compose_file="${COMPOSE_FILE:-deploy/trialrun/docker-compose.yml}" local public_base_url="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}" local public_writes="${WFULLSMOKE_ALLOW_PUBLIC_WRITES:-1}" ssh "$deploy_server" \ "REMOTE_DIR='${remote_dir}' REMOTE_ENV_FILE='${remote_env_file}' COMPOSE_FILE='${compose_file}' LIVE_SMOKE_BASE_URL='${public_base_url}' WFULLSMOKE_ALLOW_PUBLIC_WRITES='${public_writes}' bash -s" <<'REMOTE' set -euo pipefail cd "$REMOTE_DIR" if docker compose version >/dev/null 2>&1; then compose_cmd=(docker compose) else compose_cmd=(docker-compose) fi "${compose_cmd[@]}" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" exec -T server sh -lc \ "cd /app && LIVE_SMOKE_BASE_URL='$LIVE_SMOKE_BASE_URL' WFULLSMOKE_EXECUTION_MODE=remote WFULLSMOKE_REMOTE_CONTEXT=deployment-server WFULLSMOKE_ALLOW_PUBLIC_WRITES='$WFULLSMOKE_ALLOW_PUBLIC_WRITES' bash scripts/qa/run-three-day-full-goal-smoke.sh" REMOTE } case "${1:-}" in --remote) run_remote exit $? ;; --help|-h) usage exit 0 ;; "") ;; *) printf '[wfullsmoke1][ERROR] unknown argument: %s\n' "$1" >&2 usage >&2 exit 2 ;; esac if [[ "${WFULLSMOKE_EXECUTION_MODE:-}" != "remote" || "${WFULLSMOKE_REMOTE_CONTEXT:-}" != "deployment-server" ]]; then printf '[wfullsmoke1][ERROR] Enterprise WeChat checks must run on the deployment server trusted IP. Use --remote.\n' >&2 usage >&2 exit 2 fi mkdir -p "$REPORT_DIR" : >"$SUMMARY_TXT" log() { printf '%s\n' "$*" | tee -a "$SUMMARY_TXT" } write_step_json() { local name="$1" local status="$2" local kind="$3" local code="${4:-0}" local detail="${5:-}" node - "$REPORT_DIR" "$name" "$status" "$kind" "$code" "$detail" <<'NODE' || true const fs = require("node:fs"); const path = require("node:path"); const [reportDir, name, status, kind, code, detail] = process.argv.slice(2); const file = path.join(reportDir, `${name}.step.json`); fs.writeFileSync(file, `${JSON.stringify({ name, status, kind, code: Number(code || 0), detail: String(detail || "").slice(0, 500), logPath: path.resolve(path.join(reportDir, `${name}.log`)), checkedAt: new Date().toISOString(), }, null, 2)}\n`, "utf8"); NODE } run_step() { local name="$1" shift local log_file="$REPORT_DIR/${name}.log" log ">>> [wfullsmoke1] $name" if "$@" > >(tee "$log_file") 2>&1; then log "[wfullsmoke1][PASS] $name" write_step_json "$name" "PASS" "required" 0 "ok" else local code="$?" log "[wfullsmoke1][FAIL] $name exit=$code" STATUS=1 write_step_json "$name" "FAIL" "required" "$code" "required step failed" fi } run_finding_step() { local name="$1" shift local log_file="$REPORT_DIR/${name}.log" log ">>> [wfullsmoke1] $name" if "$@" > >(tee "$log_file") 2>&1; then log "[wfullsmoke1][PASS] $name" write_step_json "$name" "PASS" "finding_allowed" 0 "ok" else local code="$?" log "[wfullsmoke1][FINDING] $name exit=$code" FINDING_COUNT=$((FINDING_COUNT + 1)) write_step_json "$name" "FINDING" "finding_allowed" "$code" "finding or missing external config" fi } run_client_finding_step() { local name="$1" shift local log_file="$REPORT_DIR/${name}.log" log ">>> [wfullsmoke1] $name" if (cd "$CLIENT_DIR" && env "$@") > >(tee "$log_file") 2>&1; then log "[wfullsmoke1][PASS] $name" write_step_json "$name" "PASS" "finding_allowed" 0 "ok" else local code="$?" log "[wfullsmoke1][FINDING] $name exit=$code" FINDING_COUNT=$((FINDING_COUNT + 1)) write_step_json "$name" "FINDING" "finding_allowed" "$code" "finding or missing external config" fi } run_client_spec_if_present() { local name="$1" local spec="$2" shift 2 if [[ ! -f "$CLIENT_DIR/$spec" ]]; then log "[wfullsmoke1][FINDING] $name missing client spec in current runtime image: $spec" FINDING_COUNT=$((FINDING_COUNT + 1)) write_step_json "$name" "FINDING" "finding_allowed" 0 "client spec missing in current runtime image: $spec" return 0 fi run_client_finding_step "$name" "$@" npx playwright test "$spec" --workers=1 --reporter=list } load_default_env() { local env_file="${WFULLSMOKE_ENV_FILE:-$HOME/.hyb-oa/live-smoke.env}" if [[ "${WFULLSMOKE_SKIP_ENV_FILE:-0}" == "1" || ! -f "$env_file" ]]; then return 0 fi set -a # shellcheck disable=SC1090 source "$env_file" set +a PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}" log "[wfullsmoke1] loaded masked live smoke env file" } has_live_business_credentials() { local keys=( LIVE_SMOKE_ADMIN_IDENTIFIER LIVE_SMOKE_ADMIN_PASSWORD LIVE_SMOKE_EMPLOYEE_IDENTIFIER LIVE_SMOKE_EMPLOYEE_PASSWORD LIVE_SMOKE_MANAGER_IDENTIFIER LIVE_SMOKE_MANAGER_PASSWORD LIVE_SMOKE_FINANCE_IDENTIFIER LIVE_SMOKE_FINANCE_PASSWORD LIVE_SMOKE_FINANCE_MGR_IDENTIFIER LIVE_SMOKE_FINANCE_MGR_PASSWORD ) local key for key in "${keys[@]}"; do if [[ -z "${!key:-}" ]]; then return 1 fi done return 0 } run_r7e_flow() { local flow="$1" local flow_report="$REPORT_DIR/r7e-$flow" local flow_screens="$REPORT_DIR/r7e-$flow-screens" mkdir -p "$flow_report" "$flow_screens" run_client_finding_step "r7e-$flow" \ PLAYWRIGHT_MANUAL=1 \ E2E_MODE=live \ LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \ QA_ISSUE_ID=WFULLSMOKE1 \ TRIALRUN_ISSUE_ID=WFULLSMOKE1 \ TRIALRUN_MARKER_PREFIX="${WFULLSMOKE_MARKER_PREFIX:-TEST-SMOKE-WFULL}" \ TRIALRUN_REPORT_TITLE="WFULLSMOKE1 ${flow} 主路径报告" \ TRIALRUN_REPORT_DIR="$flow_report" \ TRIALRUN_SCREENSHOT_DIR="$flow_screens" \ R7E_BROWSER_FLOW="$flow" \ npx playwright test tests/e2e/manual/r7e-browser-business-trialrun.spec.ts --workers=1 --reporter=list } run_procurement_contract_project_regression() { local marker marker="$(date +%Y%m%d%H%M%S)" run_finding_step "procurement-contract-project" env \ BASE_URL="$PUBLIC_BASE_URL" \ DEVICE_FINGERPRINT="wfullsmoke1-procurement-contract" \ EMPLOYEE_IDENTIFIER="$LIVE_SMOKE_EMPLOYEE_IDENTIFIER" \ EMPLOYEE_PASSWORD="$LIVE_SMOKE_EMPLOYEE_PASSWORD" \ MANAGER_IDENTIFIER="$LIVE_SMOKE_MANAGER_IDENTIFIER" \ MANAGER_PASSWORD="$LIVE_SMOKE_MANAGER_PASSWORD" \ FINANCE_MGR_IDENTIFIER="$LIVE_SMOKE_FINANCE_MGR_IDENTIFIER" \ FINANCE_MGR_PASSWORD="$LIVE_SMOKE_FINANCE_MGR_PASSWORD" \ PROCUREMENT_REGRESSION_MARKER_PREFIX="${WFULLSMOKE_MARKER_PREFIX:-TEST-SMOKE-WFULL}" \ PROCUREMENT_PO_NO="PO-${WFULLSMOKE_MARKER_PREFIX:-TEST-SMOKE-WFULL}-${marker}" \ PROCUREMENT_CONTRACT_NO="CT-${WFULLSMOKE_MARKER_PREFIX:-TEST-SMOKE-WFULL}-${marker}" \ bash "$ROOT_DIR/server/scripts/regression-procurement.sh" } run_ready_and_flags() { run_step "public-ready" curl -fsS "$PUBLIC_BASE_URL/ready" run_finding_step "live-feature-flags" env \ LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \ node "$ROOT_DIR/scripts/qa/check-live-feature-flags.mjs" \ --expect payment_batch_v1=force_on \ --expect erp_sync=force_off \ --expect attendance_v1=disabled \ --expect attendance_unified_v1=enabled \ --json } run_wecom_and_archive_chain() { run_finding_step "warch3-ops" env \ WARCH3_SEND_ALERT=0 \ WARCH3_OPS_REPORT_DIR="$REPORT_DIR/warch3-ops" \ bash "$ROOT_DIR/scripts/qa/run-warch3-ops-daily.sh" run_finding_step "wauto1-evidence" env \ WAUTO1_REPORT_DIR="$REPORT_DIR/wecom-controlled-trial-evidence" \ WAUTO1_EXECUTION_MODE=remote \ WAUTO1_REMOTE_CONTEXT=deployment-server \ LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \ bash "$ROOT_DIR/scripts/qa/collect-wecom-controlled-trial-evidence.sh" } run_oa_business_chain() { run_finding_step "live-smoke-env" env \ LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \ node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json if [[ "${WFULLSMOKE_ALLOW_PUBLIC_WRITES:-1}" != "1" ]]; then log "[wfullsmoke1][SKIP] oa-public-write-flows WFULLSMOKE_ALLOW_PUBLIC_WRITES!=1" write_step_json "oa-public-write-flows" "SKIP" "manual_or_config" 0 "public OA test data writes disabled" return 0 fi if ! has_live_business_credentials; then log "[wfullsmoke1][FINDING] oa-public-write-flows missing live smoke business credentials" FINDING_COUNT=$((FINDING_COUNT + 1)) write_step_json "oa-public-write-flows" "PAUSED_MANUAL_CONFIRMATION" "manual_or_config" 0 "missing live smoke business credentials" return 0 fi run_r7e_flow "reimbursement-finance" run_r7e_flow "purchase-attachment" run_procurement_contract_project_regression } run_mobile_and_wecom_entry() { run_client_spec_if_present "mobile-entry-matrix" "tests/e2e/r124-mobile-entry-matrix.spec.ts" \ E2E_BASE_URL="$PUBLIC_BASE_URL" run_client_spec_if_present "wecom-rendering-matrix" "tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts" \ E2E_BASE_URL="$PUBLIC_BASE_URL" run_client_spec_if_present "wecom-entry" "tests/e2e/wecom-entry.spec.ts" \ E2E_BASE_URL="$PUBLIC_BASE_URL" } write_manual_checklist() { cat >"$REPORT_DIR/manual-gates.md" <<'EOF' # WFULLSMOKE1 人工门禁 ## Day 1 - SMOKE002:尝试企微上/下班打卡,提交低风险请假和补卡。 - SMOKE101:每条审批先打开详情,再同意或驳回。 - HYD000006:查看 OA 企微审批历史和打卡摘要。 ## Day 2 - SMOKE002:继续按企微规则打卡。 - HYD000006:已在 2026-05-21 真机确认请假、补卡、外出、加班四类模板均为 A。 - WAPPR1 当前结论:A 审批人自动带出且申请人不能任意选择;申请人不能删除、改人或新增审批人。若后续模板配置变化,B/C/D 均重新阻断 WEXT1-FORMAL 和 WREAL1。 - 小团队规则:公司不到 10 人,默认一层直属主管或指定审批角色自动带出审批人;主管链缺失、审批人不可见/停用/未绑定或命中自审时,由管理员兜底或阻断。 ## Day 3 - HYD000006:确认三天业务结果和 OA 历史可读性。 - 若 WAPPR1 为 A 且远端收口通过,再问答是否进入 WEXT1-FORMAL:由 SMOKE002 真机提交正式模板外出/加班测试单,系统只读校验。 边界:不要记录 Secret、token、EncodingAESKey、完整 userid、完整审批单号、完整 template_id、表单值、附件、评论或原始 payload。 EOF } run_privacy_smoke() { local sensitive_pattern='(["'\''"]?(WECOM_[A-Z0-9_]*(SECRET|TOKEN)|[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|ENCODING_AES_KEY|ENCODINGAESKEY|CORP_SECRET|ACCESS_TOKEN)[A-Z0-9_]*)["'\''"]?[[:space:]]*[:=][[:space:]]*["'\''"]?[A-Za-z0-9._:/+=-]{8,}["'\''"]?)|Authorization:[[:space:]]*Bearer[[:space:]]+[A-Za-z0-9._-]{8,}|access_token=[A-Za-z0-9._-]{8,}|1[3-9][0-9]{9}|[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}|-----BEGIN|BEGIN [A-Z ]*KEY|"((token|secret|password|EncodingAESKey|encodingAESKey|sp_no|spNo|template_id|templateId|userid|userId|external_userid|externalUserId|external_user_id))"[[:space:]]*:[[:space:]]*"[^"*]{8,}"|\b20[0-9]{12,}\b|"(raw|rawPayload|raw_payload|payload|xml|ciphertext|Encrypt|formValue|formValues|attachments|comments)"[[:space:]]*:' local matches matches="$(grep -RInE "$sensitive_pattern" "$REPORT_DIR" | grep -vE 'HRWDTEST-[0-9]{14}' || true)" if [[ -n "$matches" ]]; then printf '%s\n' "$matches" log "[wfullsmoke1][FAIL] report-privacy-smoke matched sensitive pattern" STATUS=1 else log "[wfullsmoke1][PASS] report-privacy-smoke" fi } write_summary_json() { node - "$ROOT_DIR" "$REPORT_DIR" "$PUBLIC_BASE_URL" "$STATUS" "$FINDING_COUNT" <<'NODE' || return 1 const fs = require("node:fs"); const path = require("node:path"); const { execSync } = require("node:child_process"); const [rootDir, reportDir, baseURL, statusInput, findingCountInput] = process.argv.slice(2); function readJson(file) { if (!file || !fs.existsSync(file)) return null; try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } } function step(name) { return readJson(path.join(reportDir, `${name}.step.json`)) || { name, status: "MISSING", kind: "unknown", logPath: path.resolve(path.join(reportDir, `${name}.log`)), }; } function child(relativePath) { const file = path.join(reportDir, relativePath, "summary.json"); const json = readJson(file); return { status: json?.status || json?.conclusion || "MISSING", reportPath: fs.existsSync(file) ? path.resolve(file) : null, }; } function safeStatus(value) { return String(value || "UNKNOWN").slice(0, 80); } function isBad(value) { return /FAIL|ERROR|BLOCKED|SKIP/i.test(value); } function isGap(value) { return /PAUSED|PARTIAL|NEEDS|MISSING|FINDING/i.test(value); } let commit = "unknown"; try { commit = execSync("git rev-parse --short HEAD", { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch {} const stepNames = [ "public-ready", "live-feature-flags", "warch3-ops", "wauto1-evidence", "live-smoke-env", "oa-public-write-flows", "r7e-reimbursement-finance", "r7e-purchase-attachment", "procurement-contract-project", "mobile-entry-matrix", "wecom-rendering-matrix", "wecom-entry", ]; const steps = Object.fromEntries(stepNames.map((name) => [name, step(name)])); const childReports = { warch3: child("warch3-ops"), wroad2: child("warch3-ops/wroad2-ops"), wauto1: child("wecom-controlled-trial-evidence"), }; const findings = []; for (const [name, item] of Object.entries(steps)) { const status = safeStatus(item.status); if (status === "PASS" || status === "SKIP") continue; if (name === "oa-public-write-flows" && /PAUSED/.test(status)) { findings.push({ source: name, status, severity: "manual_or_config" }); } else if (isBad(status)) { findings.push({ source: name, status, severity: item.kind === "required" ? "blocker" : "finding" }); } else if (isGap(status)) { findings.push({ source: name, status, severity: "manual_or_config" }); } } for (const [name, item] of Object.entries(childReports)) { const status = safeStatus(item.status); if (status === "PASS" || /^PASS_/.test(status)) { if (/FINDINGS/.test(status)) { findings.push({ source: name, status, severity: "finding" }); } continue; } findings.push({ source: name, status, severity: isBad(status) ? "finding" : "manual_or_config" }); } const manualGates = [ { id: "WROAD2-BIZ1", status: "PENDING_MANUAL_CONFIRMATION", owner: "SMOKE002/SMOKE101/HYD000006", requiredEvidence: "三天打卡、请假、补卡、详情后审批和 OA 历史查看", }, { id: "WAPPR1", status: "PASS_APPROVER_RULES_READY", owner: "HYD000006", requiredEvidence: "2026-05-21 用户真机确认请假、补卡、外出、加班均为 A:审批人自动带出,申请人不能删除、改人或新增审批人", }, { id: "WEXT1-FORMAL", status: "PENDING_USER_APPROVAL", owner: "human_lead", requiredEvidence: "WAPPR1=A 且 WREMOTE1/WFULLSMOKE2 可解释通过后,是否允许 SMOKE002 人工提交正式模板外出/加班测试单", }, { id: "WREAL1", status: "PENDING_USER_APPROVAL", owner: "human_lead", requiredEvidence: "三天全域主路径、四类测试号证据和 WAPPR1 通过后,是否引入真实员工", }, ]; let status = "PASS_FULL_GOAL_SMOKE_READY"; if (Number(statusInput || 0) !== 0 || findings.some((item) => item.severity === "blocker")) { status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG"; } else if (findings.length || Number(findingCountInput || 0) > 0) { status = "PASS_WITH_FINDINGS"; } else if (manualGates.some((gate) => gate.status.includes("PENDING"))) { status = "PAUSED_MANUAL_CONFIRMATION"; } const summary = { issueId: "WFULLSMOKE1", routeIssueId: "WFULLSMOKE2", mode: "three-day-full-goal-smoke", status, conclusion: status, baseURL, commit, executionMode: process.env.WFULLSMOKE_EXECUTION_MODE || "unknown", remoteContext: process.env.WFULLSMOKE_REMOTE_CONTEXT || "unknown", runtimeHost: process.env.HOSTNAME || "unknown", trustedIpBoundary: "Enterprise WeChat API checks must run on the deployment server; local runs are blocked by default", checkedAt: new Date().toISOString(), reportDir: path.resolve(reportDir), accounts: { applicant: "SMOKE002", approver: "SMOKE101", adminObserverEmployeeNo: "HYD000006", }, scope: { fullDomain: true, wecomHrAttendance: true, oaPrimaryDomains: ["reimbursement", "procurement", "contracts_projects", "payment_batch", "mobile_wecom_entry"], noRealEmployees: true, }, boundaries: { oaTestDataAllowed: true, testMarkerRequired: "TEST-SMOKE-WFULL", noRealPayment: true, noErpWrite: true, noBankStatementImport: true, noWeComWrites: true, noOutingOvertimeFixtureLive: true, }, steps, childReports, findingCount: findings.length, findings, manualGates, nextQuestions: { wappr1: [ "A:审批人自动带出,申请人不能任意选择。", "B:审批人默认带出,但申请人仍可改人。", "C:必须申请人手选审批人。", "D:当前看不到或模板未配置。", ], wext1Formal: [ "A:批准 SMOKE002 在正式模板提交外出+加班低风险测试单,SMOKE101 先看详情再审批,系统只读校验并保留审计。", "B:只测请假/补卡/打卡,不提交外出/加班正式模板测试单。", "C:暂停,先检查企微正式模板配置。", ], finalCloseout: [ "A:接受主路径闭环通过,进入 WEXT1-FORMAL 或 WREAL1 准备问答。", "B:接受 PASS_WITH_FINDINGS,但先处理指定 finding。", "C:暂停,不进入正式模板外出/加班测试、真实员工试用或镜像。", ], }, privacy: "status/count/report-path summaries only; no phone/email/token/Secret/EncodingAESKey/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload/XML/ciphertext", }; fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); console.log(`[wfullsmoke1] summary=${path.join(reportDir, "summary.json")}`); NODE } finish() { write_manual_checklist write_summary_json || STATUS=1 run_privacy_smoke log "[wfullsmoke1] report=$REPORT_DIR" exit "$STATUS" } trap finish EXIT load_default_env run_ready_and_flags run_wecom_and_archive_chain run_oa_business_chain run_mobile_and_wecom_entry