test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、 check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、 check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、 e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、 dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。 压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个 WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。 度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」 (submitted / created / canExport),而不是 HTTP 200 + code 0。 验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
用 `test` profile 真起一次 —— **CI 用的就是这个 profile**。
|
||||
|
||||
## 为什么单独一个检查
|
||||
|
||||
`application-test.yml` 是流水线里 `e2e` 阶段真正加载的配置,而它是"新写的配置"。
|
||||
**新写的配置从没跑起来过**,正是流水线第一次上线时最典型的红法 ——
|
||||
而且往往红在一个拼错的属性名上,排查半天。
|
||||
|
||||
所以这里用真实的环境变量起一次,确认三件事:
|
||||
① 能起来;② 迁移跑得过;③ `/actuator/health` 可用。
|
||||
|
||||
## 它不验什么
|
||||
|
||||
不验任何业务 —— 业务由 `scripts/e2e-*.py` 打真实接口验。
|
||||
这里只回答一个问题:**"CI 那套配置能不能把服务拉起来"**。
|
||||
|
||||
跑法:python scripts/check-test-profile.py
|
||||
(前提:本地 PG/Redis 在跑、已 `mvn package`)
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RSA_KEY = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0='
|
||||
JAR = os.path.join(ROOT, "backend", "xt-server", "target", "xt-server-1.0.0-SNAPSHOT.jar")
|
||||
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
# 指到本地已有的库(与 dev 用的是同一个库,只验"能不能起来")
|
||||
"PG_HOST": "localhost", "PG_PORT": "5432", "PG_DB": "filldb",
|
||||
"PG_USER": "fill", "PG_PASSWORD": "fill_dev_pwd",
|
||||
"REDIS_HOST": "localhost", "REDIS_PORT": "16379", "REDIS_PASSWORD": "redis_dev_pwd",
|
||||
# ⚠️ 密钥一律用 base64(os.urandom(n)) 生成 —— 手写字面量连错两次:
|
||||
# 一次是 AES 少了 2 字节,一次是 HMAC 那串根本不是合法 base64
|
||||
"XT_JWT_SECRET": base64.b64encode(os.urandom(64)).decode(),
|
||||
"XT_AES_KEY": base64.b64encode(os.urandom(32)).decode(),
|
||||
"XT_HMAC_KEY": base64.b64encode(os.urandom(48)).decode(),
|
||||
"XT_RSA_PRIVATE_KEY": RSA_KEY,
|
||||
})
|
||||
|
||||
|
||||
def kill_tree(proc):
|
||||
"""
|
||||
杀掉整个进程树(Windows 用 taskkill /T)。
|
||||
|
||||
⚠️ 不能用 `proc.kill()`:它只杀直接子进程,而 JVM 可能带着子进程;
|
||||
更重要的是——**外层命令被超时掐掉时 finally 不一定会跑**,
|
||||
所以残留会积累。用 `/T` 至少把"能跑到 finally"的那些清干净,
|
||||
再靠下面的 preclean_port 兜住剩下的。
|
||||
"""
|
||||
if proc is None or proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
capture_output=True, timeout=20)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def preclean_port(port):
|
||||
"""
|
||||
开跑前清掉这个端口上的残留 java。
|
||||
|
||||
⚠️ 为什么必须有它:脚本用的是**随机空闲端口**,所以下一次跑**不会撞端口** ——
|
||||
残留的 JVM 于是静静地占着内存与 CPU,只在某次"起 jar 超时"时才隐约暴露。
|
||||
不清端口 = 残留永远不会被发现。
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
"(Get-NetTCPConnection -State Listen -LocalPort %d -ErrorAction SilentlyContinue)"
|
||||
".OwningProcess" % port],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
for line in (r.stdout or "").split():
|
||||
pid = line.strip()
|
||||
if pid.isdigit():
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", pid],
|
||||
capture_output=True, timeout=20)
|
||||
print(" ⚠️ 端口 %d 上有残留进程 %s,已清掉" % (port, pid))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def free_port() -> int:
|
||||
"""
|
||||
要一个空闲端口。
|
||||
|
||||
⚠️ 不要写死端口:写死的话**第二次跑就撞端口**,而报的错
|
||||
(`Port xxx was already in use`)跟"配置对不对"毫无关系 ——
|
||||
一个只能跑一次的检查等于没有检查。
|
||||
"""
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
|
||||
PORT = free_port()
|
||||
print("用端口 %d(每次随机取,避免重复跑时撞端口)" % PORT)
|
||||
preclean_port(PORT)
|
||||
|
||||
p = subprocess.Popen(
|
||||
["java", "-jar", JAR, "--spring.profiles.active=test",
|
||||
"--server.port=%d" % PORT],
|
||||
cwd=os.path.join(ROOT, "backend"), env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
|
||||
ok = False
|
||||
reason = ""
|
||||
body = ""
|
||||
try:
|
||||
# ⚠️ 就绪判据是 **HTTP 探活**,不是"日志里出现了某一行":
|
||||
# 子进程 stdout 在管道里是**块缓冲**的,启动日志常常不足一个缓冲区,
|
||||
# 于是一行都读不到;而且 readline() 一旦阻塞,超时的判断就轮不到。
|
||||
# 实测:单独跑能过、紧跟在几个 JVM 之后跑就失败 —— 时好时坏正是它的特征。
|
||||
deadline = time.time() + 180
|
||||
while time.time() < deadline:
|
||||
if p.poll() is not None:
|
||||
reason = "进程自己退了(退出码 %s)" % p.returncode
|
||||
break
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
"http://localhost:%d/actuator/health" % PORT, timeout=3) as r:
|
||||
body = r.read().decode()
|
||||
ok = True
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
reason = str(e)
|
||||
time.sleep(1)
|
||||
if not ok and not reason:
|
||||
reason = "180 秒内没探活成功"
|
||||
finally:
|
||||
# ⚠️ 必须杀**进程树**(Windows 用 taskkill /T):JVM 可能带子进程,
|
||||
# 而 p.kill() 只杀直接子进程。**失败的运行恰恰是最需要清理的那些。**
|
||||
kill_tree(p)
|
||||
try:
|
||||
p.wait(timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if ok:
|
||||
print("健康检查:", body[:200])
|
||||
print("\n结果:test profile **能起来**")
|
||||
else:
|
||||
print("\n结果:test profile **起不来**")
|
||||
print(" 原因:" + reason)
|
||||
try:
|
||||
text = (p.stdout.read() or "") if p.stdout else ""
|
||||
except Exception:
|
||||
text = ""
|
||||
if "already in use" in text or ("Port" in text and "in use" in text):
|
||||
print(" ⚠️ 端口被占 —— 这是**环境问题**,不是配置问题")
|
||||
for kw in ("ERROR", "Caused by", "Failed", "migration", "Flyway", "Cannot"):
|
||||
for ln in text.split("\n"):
|
||||
if kw in ln:
|
||||
print(" " + ln.strip()[:160])
|
||||
break
|
||||
print("---- 尾部 ----")
|
||||
print(text[-1500:])
|
||||
|
||||
sys.stdout.flush()
|
||||
raise SystemExit(0 if ok else 1)
|
||||
Reference in New Issue
Block a user