feat(ldd): property-based, mutation и браузерный E2E

This commit is contained in:
2026-10-28 00:01:56 +03:00
parent 80ccf26b61
commit 905feb440b
18 changed files with 1432 additions and 2 deletions

113
scripts/ldd-browser.sh Executable file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# LDD browser E2E: полный стек + Playwright (Chromium) на одноразовой БД.
set -euo pipefail
cd "$(dirname "$0")/.."
DB="lms_e2e_$(date -u +%H%M%S)_$$"
POSTGRES_WAS_RUNNING=0
STACK_STARTED=0
E2E_STATUS=1
log() { echo ">> $*"; }
cleanup() {
local code=$?
if ((STACK_STARTED)); then
log "Останавливаю тестовый стек..."
./run.sh stop >/dev/null 2>&1 || true
fi
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS $DB;" >/dev/null 2>&1 || true
if ((POSTGRES_WAS_RUNNING == 0)); then
docker compose stop postgres >/dev/null 2>&1 || true
fi
exit $code
}
trap cleanup EXIT INT TERM
for port in 8080 8081 8082 8083 8090; do
if ss -ltn 2>/dev/null | grep -q ":$port "; then
echo "Порт $port занят. Остановите текущий стек (./run.sh stop) и повторите." >&2
exit 1
fi
done
command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; }
command -v npx >/dev/null 2>&1 || { echo "Нужен Node.js/npx" >&2; exit 1; }
JAVA_BIN="${JAVA_BIN:-/usr/lib/jvm/java-21-openjdk/bin/java}"
[[ -x "$JAVA_BIN" ]] || { echo "Не найден Java 21: $JAVA_BIN" >&2; exit 1; }
[[ -d e2e/node_modules ]] || { echo "Выполните: cd e2e && npm install" >&2; exit 1; }
if docker compose ps --status running --services 2>/dev/null | grep -qx postgres; then
POSTGRES_WAS_RUNNING=1
fi
log "Запускаю PostgreSQL..."
docker compose up -d postgres >/dev/null
for _ in $(seq 1 40); do
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null 2>&1 && break
sleep 1
done
docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null
docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE $DB;" >/dev/null
log "Собираю bootJar..."
./gradlew bootJar --console=plain >/tmp/lms-ldd-e2e-bootjar.log
log "Запускаю стек..."
export LMS_DB_URL="jdbc:postgresql://localhost:5432/$DB"
export LMS_DB_NAME="$DB"
export LMS_DB_USER="lms"
export LMS_DB_PASSWORD="lms"
export LMS_INTERNAL_TOKEN="ldd-internal-token"
export LMS_SESSION_COOKIE_SECURE="false"
export JAVA_BIN
STACK_STARTED=1
./run.sh start
mkdir -p build/evidence
log "Запускаю Playwright..."
set +e
(
cd e2e
LDD_WEB_URL="http://localhost:8090" \
LDD_API_URL="http://localhost:8080" \
npx playwright test --config=playwright.config.ts
)
E2E_STATUS=$?
set -e
python3 - "$E2E_STATUS" <<'PY'
import json
import os
import sys
status_code = int(sys.argv[1])
playwright_path = "build/evidence/playwright.json"
detail = "Playwright exit=" + str(status_code)
if os.path.isfile(playwright_path):
try:
root = json.load(open(playwright_path, encoding="utf-8"))
stats = root.get("stats", {})
detail += (
f"; expected={stats.get('expected', 0)}"
f", unexpected={stats.get('unexpected', 0)}"
f", flaky={stats.get('flaky', 0)}"
f", skipped={stats.get('skipped', 0)}"
)
except Exception as exc:
detail += "; не удалось разобрать playwright.json: " + str(exc)
status = "passed" if status_code == 0 else "failed"
with open("build/evidence/browser.json", "w", encoding="utf-8") as fh:
now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat()
json.dump(
{"generatedAt": now, "checks": [{"id": "browser.e2e", "status": status, "detail": detail}]},
fh,
ensure_ascii=False,
indent=2,
)
print(("PASS " if status == "passed" else "FAIL ") + "browser.e2e: " + detail)
sys.exit(0 if status == "passed" else 1)
PY

68
scripts/ldd-mutation.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# LDD mutation evidence: PIT для критичных P0-компонентов.
# JUnit 4 harness используется потому, что JUnit5-плагин PIT несовместим с JUnit Platform 6.
set -euo pipefail
cd "$(dirname "$0")/.."
./gradlew \
:services:academic-service:pitestCourseAccess \
:services:identity-service:pitestPromotionPlanner \
--console=plain
mkdir -p build/evidence
python3 - <<'PY'
import json
import os
import xml.etree.ElementTree as ET
checks = []
def parse(path):
root = ET.parse(path).getroot()
total = killed = no_coverage = 0
for mutation in root.findall("mutation"):
total += 1
status = mutation.attrib.get("status")
if status == "KILLED":
killed += 1
elif status == "NO_COVERAGE":
no_coverage += 1
covered = total - no_coverage
strength = (killed / covered * 100) if covered else 0.0
return total, killed, no_coverage, strength
def check(check_id, path, threshold):
if not os.path.isfile(path):
checks.append({"id": check_id, "status": "failed", "detail": "нет отчёта " + path})
return
total, killed, no_coverage, strength = parse(path)
status = "passed" if strength >= threshold else "failed"
detail = (
f"mutations={total}, killed={killed}, no_coverage={no_coverage}, "
f"test_strength={strength:.1f}% (threshold {threshold:.0f}%)"
)
checks.append({"id": check_id, "status": status, "detail": detail})
print(("PASS " if status == "passed" else "FAIL ") + check_id + ": " + detail)
check(
"mutation.course_access",
"services/academic-service/build/reports/pitest-course-access/mutations.xml",
70.0,
)
check(
"mutation.promotion_planner",
"services/identity-service/build/reports/pitest-promotion-planner/mutations.xml",
60.0,
)
with open("build/evidence/mutation.json", "w", encoding="utf-8") as fh:
json.dump({"generatedAt": __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat(), "checks": checks}, fh, ensure_ascii=False, indent=2)
if any(c["status"] != "passed" for c in checks):
raise SystemExit(1)
PY