文件
hyb-oa/scripts/qa/run-three-day-full-goal-smoke.sh

547 行
19 KiB
Bash
可执行文件

#!/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