diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 5e71408..1256aaf 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -114,12 +114,42 @@ jobs:
.
- name: Run unit tests (EUnit)
- run: >
- docker run --rm eventhub-tests:latest
- rebar3 eunit --sname ci_eunit --verbose
+ id: eunit
+ run: |
+ set -euo pipefail
+ mkdir -p ci-out/eunit ci-out/eunit-html
+ set +e
+ docker run --rm \
+ -v "$PWD/ci-out/eunit:/app/logs/test/eunit" \
+ eventhub-tests:latest \
+ rebar3 eunit --sname ci_eunit --verbose
+ EUNIT_RC=$?
+ set -e
+ python3 scripts/junit-xml-to-html.py ci-out/eunit -o ci-out/eunit-html --title "EUnit CI" || true
+ exit "${EUNIT_RC}"
- name: Run API tests (local CT)
- run: docker run --rm eventhub-tests:latest
+ id: ct
+ run: |
+ set -euo pipefail
+ mkdir -p ci-out/ct
+ docker run --rm \
+ -v "$PWD/ci-out/ct:/app/logs/test/ct" \
+ eventhub-tests:latest
+
+ - name: Publish HTML reports (ci-reports)
+ if: always()
+ run: |
+ set -euo pipefail
+ RUN_ID="${GITHUB_RUN_NUMBER:-local}"
+ mkdir -p /var/eventhub/ci-reports || sudo mkdir -p /var/eventhub/ci-reports || true
+ if [[ -d ci-out/eunit-html ]] && compgen -G 'ci-out/eunit-html/*' >/dev/null; then
+ bash scripts/publish-ci-report.sh EventHubBack "${RUN_ID}" eunit ci-out/eunit-html || true
+ fi
+ if [[ -d ci-out/ct ]] && compgen -G 'ci-out/ct/*' >/dev/null; then
+ bash scripts/publish-ci-report.sh EventHubBack "${RUN_ID}" ct ci-out/ct || true
+ fi
+ bash scripts/prune-ci-reports.sh || true
- name: Push tested eventhub image
if: github.event_name == 'push'
diff --git a/.gitignore b/.gitignore
index 3690937..671f9cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ erl_crash.dump
.rebar
logs
data
+ci-out
.idea
*.iml
rebar3.crashdump
diff --git a/docker/ci-reports/nginx.conf b/docker/ci-reports/nginx.conf
new file mode 100644
index 0000000..c7aecd3
--- /dev/null
+++ b/docker/ci-reports/nginx.conf
@@ -0,0 +1,15 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ autoindex on;
+ charset utf-8;
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+
+ location ~* /allure/ {
+ try_files $uri $uri/ /index.html =404;
+ }
+}
diff --git a/docker/docker-compose.swarm.yml b/docker/docker-compose.swarm.yml
index 887015e..836a02f 100644
--- a/docker/docker-compose.swarm.yml
+++ b/docker/docker-compose.swarm.yml
@@ -90,6 +90,19 @@ services:
labels:
- "traefik.enable=true"
+ # ================== CI HTML reports (local runner) ==================
+ ci-reports:
+ image: nginx:1.27-alpine
+ volumes:
+ - "${CI_REPORTS_HOST_DIR:-/var/eventhub/ci-reports}:/usr/share/nginx/html:ro"
+ - "./ci-reports/nginx.conf:/etc/nginx/conf.d/default.conf:ro"
+ networks:
+ - eventhub-net
+ deploy:
+ replicas: 1
+ restart_policy:
+ condition: any
+
# ================== Admin UI ==================
admin-ui:
image: eventhub-admin-ui:latest
diff --git a/docker/traefik/dynamic_conf.yml b/docker/traefik/dynamic_conf.yml
index 3c08599..3f78c46 100644
--- a/docker/traefik/dynamic_conf.yml
+++ b/docker/traefik/dynamic_conf.yml
@@ -116,6 +116,17 @@ http:
tls: true
service: "client-ui-service"
+ ci-reports:
+ rule: "Host(`ci-reports.dev.eventhub.local`)"
+ entryPoints: ["web"]
+ middlewares: ["redirect-to-https"]
+ service: "ci-reports"
+ ci-reports-secure:
+ rule: "Host(`ci-reports.dev.eventhub.local`)"
+ entryPoints: ["websecure"]
+ tls: true
+ service: "ci-reports"
+
services:
api:
failover:
@@ -172,3 +183,8 @@ http:
loadbalancer:
servers:
- url: "http://client-ui:80"
+
+ ci-reports:
+ loadbalancer:
+ servers:
+ - url: "http://ci-reports:80"
diff --git a/rebar.config b/rebar.config
index 9d919ec..46de3e6 100644
--- a/rebar.config
+++ b/rebar.config
@@ -49,6 +49,11 @@
{verbose, true} % Print more info to console
]}.
+{eunit_opts, [
+ verbose,
+ {report, {eunit_surefire, [{dir, "logs/test/eunit"}]}}
+]}.
+
{ct_compile_opts, [
{i, "include"}, % Include directory
{d, 'DEBUG'} % Define macros
diff --git a/scripts/junit-xml-to-html.py b/scripts/junit-xml-to-html.py
new file mode 100644
index 0000000..03d257c
--- /dev/null
+++ b/scripts/junit-xml-to-html.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+"""Convert Surefire/JUnit XML (eunit_surefire) into a simple HTML report."""
+from __future__ import annotations
+
+import argparse
+import html
+import sys
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+
+def load_suites(paths: list[Path]) -> list[ET.Element]:
+ suites: list[ET.Element] = []
+ for path in paths:
+ try:
+ root = ET.parse(path).getroot()
+ except ET.ParseError as exc:
+ print(f"skip {path}: {exc}", file=sys.stderr)
+ continue
+ if root.tag == "testsuite":
+ suites.append(root)
+ elif root.tag == "testsuites":
+ suites.extend(list(root.findall("testsuite")))
+ return suites
+
+
+def render(suites: list[ET.Element], title: str) -> str:
+ total = failures = errors = skipped = 0
+ rows: list[str] = []
+ for suite in suites:
+ s_name = suite.attrib.get("name", "suite")
+ total += int(suite.attrib.get("tests", "0") or 0)
+ failures += int(suite.attrib.get("failures", "0") or 0)
+ errors += int(suite.attrib.get("errors", "0") or 0)
+ skipped += int(suite.attrib.get("skipped", "0") or 0)
+ for case in suite.findall("testcase"):
+ name = case.attrib.get("name", "?")
+ classname = case.attrib.get("classname", s_name)
+ time_s = case.attrib.get("time", "")
+ fail = case.find("failure")
+ err = case.find("error")
+ skip = case.find("skipped")
+ if fail is not None:
+ status, detail = "FAIL", fail.attrib.get("message") or (fail.text or "")
+ elif err is not None:
+ status, detail = "ERROR", err.attrib.get("message") or (err.text or "")
+ elif skip is not None:
+ status, detail = "SKIP", skip.attrib.get("message") or ""
+ else:
+ status, detail = "PASS", ""
+ cls = status.lower()
+ rows.append(
+ "
| {status} | {suite} | {case} | "
+ "{time} | {detail} |
".format(
+ cls=html.escape(cls),
+ status=html.escape(status),
+ suite=html.escape(classname),
+ case=html.escape(name),
+ time=html.escape(time_s),
+ detail=html.escape(detail.strip()[:4000]),
+ )
+ )
+
+ ok = total - failures - errors - skipped
+ return f"""
+
+
+
+{html.escape(title)}
+
+
+
+{html.escape(title)}
+
+ tests: {total}
+ pass: {ok}
+ fail: {failures}
+ error: {errors}
+ skip: {skipped}
+
+
+| Status | Suite | Case | Time | Detail |
+
+{''.join(rows) if rows else '| No testcases |
'}
+
+
+
+
+"""
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("xml_dir", type=Path, help="Directory with TEST-*.xml")
+ ap.add_argument("-o", "--out", type=Path, required=True, help="Output HTML file or dir")
+ ap.add_argument("--title", default="EUnit report")
+ args = ap.parse_args()
+
+ xml_files = sorted(args.xml_dir.glob("TEST-*.xml")) + sorted(args.xml_dir.glob("*.xml"))
+ # de-dupe
+ seen = set()
+ unique: list[Path] = []
+ for p in xml_files:
+ if p.resolve() in seen:
+ continue
+ seen.add(p.resolve())
+ unique.append(p)
+
+ suites = load_suites(unique)
+ out = args.out
+ if out.suffix.lower() != ".html":
+ out.mkdir(parents=True, exist_ok=True)
+ out = out / "index.html"
+ else:
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(render(suites, args.title), encoding="utf-8")
+ print(f"wrote {out} ({len(suites)} suites, {len(unique)} xml files)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/prune-ci-reports.sh b/scripts/prune-ci-reports.sh
new file mode 100644
index 0000000..167c294
--- /dev/null
+++ b/scripts/prune-ci-reports.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Удаляет каталоги отчётов старше CI_REPORTS_KEEP_DAYS (default 14).
+# Usage: prune-ci-reports.sh [/var/eventhub/ci-reports]
+set -euo pipefail
+
+ROOT="${1:-${CI_REPORTS_DIR:-/var/eventhub/ci-reports}}"
+KEEP_DAYS="${CI_REPORTS_KEEP_DAYS:-14}"
+
+if [[ ! -d "${ROOT}" ]]; then
+ echo "ci-reports: ${ROOT} отсутствует — пропуск"
+ exit 0
+fi
+
+echo "ci-reports prune: root=${ROOT} keep_days=${KEEP_DAYS}"
+find "${ROOT}" -mindepth 2 -maxdepth 2 -type d -mtime "+${KEEP_DAYS}" -print -exec rm -rf {} + 2>/dev/null || true
+echo "ci-reports prune: done"
diff --git a/scripts/publish-ci-report.sh b/scripts/publish-ci-report.sh
new file mode 100644
index 0000000..4039213
--- /dev/null
+++ b/scripts/publish-ci-report.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+# Publish HTML CI report tree to local runner host and print HTTPS URL.
+# Usage: publish-ci-report.sh
+# repo: EventHubBack | EventHubFront | …
+# run_id: usually GITHUB_RUN_NUMBER or sha
+# kind: eunit | ct | allure | e2e-ift | e2e-stage | …
+# src_dir: directory with HTML to copy
+set -euo pipefail
+
+REPO="${1:?repo}"
+RUN_ID="${2:?run_id}"
+KIND="${3:?kind}"
+SRC="${4:?src_dir}"
+
+REPORTS_DIR="${CI_REPORTS_DIR:-/var/eventhub/ci-reports}"
+STAND="${RUNNER_STAND:-}"
+
+detect_stand() {
+ if [[ -n "${CI_REPORTS_BASE_URL:-}" ]]; then
+ echo ""
+ return 0
+ fi
+ if [[ -n "${STAND}" ]]; then
+ echo "${STAND}"
+ return 0
+ fi
+ if getent hosts ci-reports.ift.eventhub.local >/dev/null 2>&1 \
+ || getent ahostsv4 ci-reports.ift.eventhub.local >/dev/null 2>&1; then
+ # Prefer ift if this host resolves ift reports (IFT runner DNS)
+ if [[ -f /opt/eventhub-ift/.env ]] || [[ -d /opt/eventhub-ift ]]; then
+ echo "ift"
+ return 0
+ fi
+ fi
+ if [[ -d /opt/eventhub-ift ]]; then
+ echo "ift"
+ return 0
+ fi
+ echo "dev"
+}
+
+if [[ -n "${CI_REPORTS_BASE_URL:-}" ]]; then
+ BASE_URL="${CI_REPORTS_BASE_URL%/}"
+else
+ STAND="$(detect_stand)"
+ BASE_URL="https://ci-reports.${STAND}.eventhub.local"
+fi
+
+if [[ ! -d "${SRC}" ]]; then
+ echo "publish-ci-report: src missing: ${SRC}" >&2
+ exit 1
+fi
+
+DEST="${REPORTS_DIR}/${REPO}/${RUN_ID}/${KIND}"
+mkdir -p "${REPORTS_DIR}/${REPO}/${RUN_ID}"
+rm -rf "${DEST}"
+mkdir -p "${DEST}"
+cp -a "${SRC}/." "${DEST}/"
+
+# Convenience: CT puts index under ct_run.*; expose DEST/index.html
+if [[ ! -f "${DEST}/index.html" ]]; then
+ CT_INDEX="$(find "${DEST}" -type f -name index.html 2>/dev/null | head -n 1 || true)"
+ if [[ -n "${CT_INDEX}" ]]; then
+ REL="${CT_INDEX#"${DEST}/"}"
+ cat > "${DEST}/index.html" <
+
+
+
+CT report
+
+Open Common Test report
+
+EOF
+ fi
+fi
+
+# Refresh run index
+INDEX="${REPORTS_DIR}/${REPO}/${RUN_ID}/index.html"
+{
+ echo ""
+ echo "${REPO} #${RUN_ID}"
+ echo ""
+ echo ""
+ echo "${REPO} — run ${RUN_ID}
"
+ echo ""
+ for d in "${REPORTS_DIR}/${REPO}/${RUN_ID}"/*/; do
+ [[ -d "${d}" ]] || continue
+ name="$(basename "${d}")"
+ if [[ -f "${d}index.html" ]]; then
+ echo "- ${name}
"
+ else
+ echo "- ${name}/
"
+ fi
+ done
+ echo "
"
+} > "${INDEX}"
+
+URL="${BASE_URL}/${REPO}/${RUN_ID}/${KIND}/"
+INDEX_URL="${BASE_URL}/${REPO}/${RUN_ID}/"
+echo "Report: ${URL}"
+echo "Report index: ${INDEX_URL}"
+if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
+ {
+ echo "### Test report"
+ echo "- [${KIND}](${URL})"
+ echo "- [index](${INDEX_URL})"
+ } >> "${GITHUB_STEP_SUMMARY}"
+fi
diff --git a/scripts/run-stand-api-tests.sh b/scripts/run-stand-api-tests.sh
index 63fae27..78c2ba7 100644
--- a/scripts/run-stand-api-tests.sh
+++ b/scripts/run-stand-api-tests.sh
@@ -114,8 +114,13 @@ if [[ "${have_image}" -ne 1 ]]; then
.
fi
+CT_OUT="${CT_REPORT_DIR:-$(pwd)/ci-out/ct-${STAND}}"
+mkdir -p "${CT_OUT}"
+
+set +e
docker run --rm --network=host \
${docker_add_hosts[@]+"${docker_add_hosts[@]}"} \
+ -v "${CT_OUT}:/app/logs/test/ct" \
-e CT_MODE=remote \
-e "API_HOST=${API_HOST}" \
-e "ADMIN_API_HOST=${ADMIN_API_HOST}" \
@@ -130,5 +135,18 @@ docker run --rm --network=host \
-e "ADMIN_SUPPORT_EMAIL=${ADMIN_SUPPORT_EMAIL:-}" \
-e "ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD:-}" \
"${IMAGE}"
+CT_RC=$?
+set -e
+
+if [[ -d "${CT_OUT}" ]] && compgen -G "${CT_OUT}/*" >/dev/null; then
+ RUN_ID="${GITHUB_RUN_NUMBER:-local}"
+ mkdir -p /var/eventhub/ci-reports 2>/dev/null || true
+ bash scripts/publish-ci-report.sh EventHubBack "${RUN_ID}" "e2e-${STAND}" "${CT_OUT}" || true
+fi
+
+if [[ "${CT_RC}" -ne 0 ]]; then
+ echo "=== API CT FAILED (${STAND}) rc=${CT_RC} ===" >&2
+ exit "${CT_RC}"
+fi
echo "=== API CT passed (${STAND}) ==="