#!/usr/bin/env bash # Run the Rust test suite, publish CI test metrics when configured, and retain artifacts. set -euo pipefail ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) REPORT_DIR="${ROOT_DIR}/target/test-gate" TEST_LOG="${REPORT_DIR}/cargo-test.log" SUMMARY_JSON="${REPORT_DIR}/summary.json" SUMMARY_TXT="${REPORT_DIR}/summary.txt" METRICS_FILE="${REPORT_DIR}/metrics.prom" PUSHGATEWAY_URL=${QUALITY_GATE_PUSHGATEWAY_URL:-} PUSHGATEWAY_JOB=${LESAVKA_TEST_GATE_PUSHGATEWAY_JOB:-platform-quality-ci} mkdir -p "${REPORT_DIR}" cd "${ROOT_DIR}" branch=${BRANCH_NAME:-${GIT_BRANCH:-}} if [[ -z "${branch}" ]]; then branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown) fi commit=${GIT_COMMIT:-} if [[ -z "${commit}" ]]; then commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) fi build_url=${BUILD_URL:-} start_seconds=$(date +%s) status=0 set +e cargo build --workspace --bins --color never 2>&1 | tee "${TEST_LOG}" build_status=${PIPESTATUS[0]} if [[ "${build_status}" -eq 0 ]]; then RUST_TEST_THREADS="${RUST_TEST_THREADS:-1}" cargo test --workspace --all-targets --color never 2>&1 | tee -a "${TEST_LOG}" status=${PIPESTATUS[0]} else status=${build_status} fi set -e end_seconds=$(date +%s) duration_seconds=$((end_seconds - start_seconds)) python3 - \ "${TEST_LOG}" \ "${SUMMARY_JSON}" \ "${SUMMARY_TXT}" \ "${METRICS_FILE}" \ "${status}" \ "${duration_seconds}" \ "${branch}" \ "${commit}" \ "${build_url}" \ "${BUILD_NUMBER:-unknown}" \ "${JOB_NAME:-lesavka}" <<'PY' import json import pathlib import re import sys from datetime import datetime, timezone log_path = pathlib.Path(sys.argv[1]) summary_json_path = pathlib.Path(sys.argv[2]) summary_txt_path = pathlib.Path(sys.argv[3]) metrics_path = pathlib.Path(sys.argv[4]) status = int(sys.argv[5]) duration_seconds = int(sys.argv[6]) branch = sys.argv[7] or 'unknown' commit = sys.argv[8] or 'unknown' build_url = sys.argv[9] build_number = sys.argv[10] or 'unknown' jenkins_job = sys.argv[11] or 'lesavka' result_re = re.compile( r'test result: (?:ok|FAILED)\. ' r'(?P\d+) passed; ' r'(?P\d+) failed; ' r'(?P\d+) ignored; ' r'(?P\d+) measured; ' r'(?P\d+) filtered out;' ) running_re = re.compile(r'^\s*Running (?P.+?)(?: \(|$)') test_re = re.compile(r'^test (?P.+?) \.\.\. (?Pok|FAILED|ignored)$') counts = {'passed': 0, 'failed': 0, 'ignored': 0, 'measured': 0, 'filtered': 0} test_cases = [] current_target = '' manifest_path = pathlib.Path('tests/test-taxonomy-manifest.json') category_by_path = {} test_categories = set() if manifest_path.exists(): for item in json.loads(manifest_path.read_text(encoding='utf-8')): path = item.get('new', '') category = item.get('category', '') if path and category: category_by_path[path] = category if category not in {'fixtures', 'golden', 'helpers'}: test_categories.add(category) def category_for_target(target: str) -> str: if target in category_by_path: return category_by_path[target] parts = pathlib.PurePosixPath(target).parts if len(parts) >= 2 and parts[0] == 'tests': return parts[1] if target.startswith('src/'): return 'unit' return 'uncategorized' for raw in log_path.read_text(encoding='utf-8', errors='replace').splitlines(): running = running_re.search(raw) if running: current_target = running.group('target') continue test_match = test_re.search(raw.strip()) if test_match: raw_result = test_match.group('result') test_cases.append({ 'test': f'{current_target or "unknown"}::{test_match.group("name")}', 'category': category_for_target(current_target or 'unknown'), 'status': {'ok': 'passed', 'FAILED': 'failed', 'ignored': 'skipped'}[raw_result], }) continue match = result_re.search(raw) if not match: continue for key in counts: counts[key] += int(match.group(key)) outcome = 'ok' if status == 0 else 'failed' if status != 0 and not test_cases: fallback_categories = sorted(test_categories) or ['uncategorized'] for category in fallback_categories: test_cases.append({ 'test': '__test_collection_failed__', 'category': category, 'status': 'failed', }) summary = { 'suite': 'lesavka', 'branch': branch, 'commit': commit, 'build_url': build_url, 'outcome': outcome, 'exit_code': status, 'duration_seconds': duration_seconds, 'generated_at': datetime.now(timezone.utc).isoformat(), 'tests': counts, } summary_json_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + '\n', encoding='utf-8') summary_txt_path.write_text( '\n'.join([ f"lesavka test gate: {outcome}", f"branch: {branch}", f"commit: {commit}", f"duration: {duration_seconds}s", f"passed: {counts['passed']}", f"failed: {counts['failed']}", f"ignored: {counts['ignored']}", f"filtered: {counts['filtered']}", ]) + '\n', encoding='utf-8', ) def label_value(value: str) -> str: return value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') labels = f'suite="lesavka",branch="{label_value(branch)}"' build_labels = ( f'suite="lesavka",branch="{label_value(branch)}",commit="{label_value(commit)}",' f'build_number="{label_value(build_number)}",jenkins_job="{label_value(jenkins_job)}"' ) success = 1 if outcome == 'ok' else 0 failure = 1 - success lines = [ '# HELP lesavka_test_gate_last_run_success Whether the latest lesavka cargo test gate run succeeded.', '# TYPE lesavka_test_gate_last_run_success gauge', f'lesavka_test_gate_last_run_success{{{labels}}} {success}', '# HELP lesavka_test_gate_duration_seconds Duration of the latest lesavka cargo test gate run.', '# TYPE lesavka_test_gate_duration_seconds gauge', f'lesavka_test_gate_duration_seconds{{{labels}}} {duration_seconds}', '# HELP lesavka_test_gate_tests Number of Rust tests reported by the latest lesavka test gate run.', '# TYPE lesavka_test_gate_tests gauge', ] for result, value in counts.items(): lines.append(f'lesavka_test_gate_tests{{{labels},result="{result}"}} {value}') lines.extend([ '# HELP platform_quality_gate_build_info Build metadata for the latest lesavka gate run.', '# TYPE platform_quality_gate_build_info gauge', f'platform_quality_gate_build_info{{{build_labels}}} 1', '# HELP platform_quality_gate_tests_total Test result counts from the latest lesavka gate run.', '# TYPE platform_quality_gate_tests_total gauge', f'platform_quality_gate_tests_total{{{labels},result="passed"}} {counts["passed"]}', f'platform_quality_gate_tests_total{{{labels},result="failed"}} {counts["failed"]}', f'platform_quality_gate_tests_total{{{labels},result="ignored"}} {counts["ignored"]}', '# HELP platform_quality_gate_checks_total Check outcomes from the latest lesavka gate run.', '# TYPE platform_quality_gate_checks_total gauge', f'platform_quality_gate_checks_total{{{labels},check="tests",status="ok"}} {success}', f'platform_quality_gate_checks_total{{{labels},check="tests",status="failed"}} {failure}', '# HELP platform_quality_gate_test_case_result Per-test result from the latest lesavka gate run.', '# TYPE platform_quality_gate_test_case_result gauge', ]) for case in test_cases: case_labels = ( f'{build_labels},category="{label_value(case["category"])}",' f'test="{label_value(case["test"])}",status="{label_value(case["status"])}"' ) lines.append(f'platform_quality_gate_test_case_result{{{case_labels}}} 1') metrics_path.write_text('\n'.join(lines) + '\n', encoding='utf-8') PY publish_metrics() { if [[ -z "${PUSHGATEWAY_URL}" ]]; then echo "Skipping test metrics publish: QUALITY_GATE_PUSHGATEWAY_URL is not set" return 0 fi curl --fail --silent --show-error \ --data-binary @"${METRICS_FILE}" \ "${PUSHGATEWAY_URL%/}/metrics/job/${PUSHGATEWAY_JOB}/suite/lesavka/gate/tests" } publish_status=0 publish_metrics || publish_status=$? if [[ "${status}" -ne 0 ]]; then exit "${status}" fi exit "${publish_status}"