Files
xiaotutongxue/scripts/check-test-profile.py
T
lovife 30c3714d35 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。
2026-09-30 09:02:57 +08:00

175 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)