Skip to content

tests: add test coverage with sonar #102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 64 additions & 34 deletions .github/workflows/sonar-qube-scann.yml
Original file line number Diff line number Diff line change
@@ -1,34 +1,64 @@
#SonarQube Configuration
# This is the sonarqube configuration, check readme for instructions
#name: 'sonarqube'
#
#on: push
#
#jobs:
# sonarQubeTrigger:
# name: Sonarqube-Trigger
# runs-on: ubuntu-latest
# steps:
# - uses: dart-lang/setup-dart@v1
# - name: Checkout code
# uses: actions/checkout@v2
# - uses: webfactory/[email protected]
# with:
# ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
# - name: Set up Flutter
# uses: subosito/flutter-action@v2
# with:
# channel: stable
# flutter-version: 3.24.3
# - run: flutter --version
# - name: Get Dependencies
# run: flutter pub get app && flutter pub get modules/domain && flutter pub get modules/data && flutter pub get modules/common
# - name: Analyze App
# #run analyze first
# run: flutter analyze
# - name: Setup Sonarqube Scanner
# uses: warchant/setup-sonar-scanner@v8
# - name: Run Sonarqube Scanner
# run: sonar-scanner
# -Dsonar.token=${{ secrets.SONAR_TOKEN }}
# -Dsonar.host.url=${{ secrets.SONAR_URL }}
# name: sonarqube

# # ────────────────────────────────────────────────────────────────
# # CI TRIGGERS
# # · push on main → historical baseline
# # · pull_request PRs → quality gate before merge
# # ────────────────────────────────────────────────────────────────
# on:
# push:
# branches: [main]
# pull_request:
# types: [opened, synchronize, reopened]

# jobs:
# sonarQubeTrigger:
# name: Sonarqube Trigger
# runs-on: ubuntu-latest

# steps:
# # 1 — Checkout the repo
# - name: Checkout code
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hitnk you are missing the python instalation

  • name: Install Python dependencies
    run: |
    python3 -m pip install --upgrade pip
    # Add any required pip install commands here, e.g.:
    # pip install somepackage

Copy link
Author

@tarruk tarruk Jun 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@amaury901130
Nice catch! Just a heads-up: the ubuntu-latest runners already include Python 3 by default, so we don’t need an extra installation step. Since we’re not using any third-party Python dependencies, there’s nothing else to install right now.

# uses: actions/checkout@v3

# # 2 — SSH agent for any Git-based pub dependencies
# - name: Start ssh-agent
# uses: webfactory/[email protected]
# with:
# ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

# # 3 — Install Dart SDK
# - uses: dart-lang/setup-dart@v1

# # 4 — Install Flutter SDK
# - name: Set up Flutter
# uses: subosito/flutter-action@v2
# with:
# channel: stable
# flutter-version: 3.24.3

# # 5 — Install all pub packages (app + each module)
# - name: Get pub packages
# run: |
# set -e
# for dir in app modules/*; do
# if [ -f "$dir/pubspec.yaml" ]; then
# echo "▶ flutter pub get --directory $dir"
# flutter pub get --directory "$dir"
# fi
# done

# # 6 — Static analysis (kept exactly as before)
# - name: Analyze App
# run: flutter analyze

# # 7 — Install SonarScanner CLI (needed by full_coverage.py)
# - name: Setup Sonarqube Scanner
# uses: warchant/setup-sonar-scanner@v8

# # 8 — Run tests, build combined lcov.info and upload to SonarQube
# - name: Generate coverage & run SonarQube
# run: python3 coverage/full_coverage.py --ci
# env:
# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# SONAR_URL: ${{ secrets.SONAR_URL }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ unlinked_spec.ds

# Coverage
coverage/
+!coverage/full_coverage.py

# Symbols
app.*.symbols
Expand Down
264 changes: 264 additions & 0 deletions coverage/full_coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""
full_coverage.py – Generates a single **lcov.info** for a multi-package Flutter
repository and uploads it to SonarQube.

Workflow
========
1. Read **sonar-project.properties** → use *exactly* the folders listed in
`sonar.sources`.
2. Warn if there are libraries under `modules/*/lib` that are **not** declared
in `sonar.sources` (they would be ignored by SonarQube otherwise).
3. Run tests *per module* (if a `test/` folder exists) and generate one LCOV
report per module.
4. Normalise every `SF:` line so paths start with `app/lib/…` or
`modules/<module>/lib/…` — this guarantees SonarQube can resolve them.
5. Merge all module reports and add **0 % coverage blocks** for every Dart file
that still has no tests.
6. Validate paths before launching **sonar-scanner**.

Usage
-----
Interactive:
python3 coverage/full_coverage.py

CI (no Y/N prompt):
python3 coverage/full_coverage.py --ci

Dry-run (show commands, don’t execute):
python3 coverage/full_coverage.py --dry-run
"""
from __future__ import annotations

import argparse
import configparser
import fnmatch
import getpass
import os
import re
import shutil
import subprocess
from pathlib import Path
from typing import Dict, List, Set

# Basic paths
PROJECT_ROOT = Path.cwd()
COVERAGE_DIR = PROJECT_ROOT / "coverage"
LCOV_MERGED_FILE = COVERAGE_DIR / "lcov.merged.info"
LCOV_FULL_FILE = COVERAGE_DIR / "lcov.info"

# 1 · Read `sonar.sources` → build MODULE_PATHS
def load_sonar_sources(props: Path = PROJECT_ROOT / "sonar-project.properties") -> List[str]:
"""Return the comma/semicolon-separated folders configured in sonar.sources."""
if not props.exists():
return []
# ConfigParser needs a header, so prepend a dummy section
text = "[dummy]\n" + props.read_text(encoding="utf-8")
cfg = configparser.ConfigParser()
cfg.read_string(text)
raw = cfg.get("dummy", "sonar.sources", fallback="")
return [p.strip() for p in re.split(r"[;,]", raw) if p.strip()]

SONAR_SOURCES: List[str] = load_sonar_sources()

# Map friendly module name → lib path
MODULE_PATHS: Dict[str, Path] = {}
for src in SONAR_SOURCES:
parts = src.split("/")
if parts[0] == "app":
MODULE_PATHS["app"] = PROJECT_ROOT / src
elif parts[0] == "modules" and len(parts) >= 3:
MODULE_PATHS[parts[1]] = PROJECT_ROOT / src

# 1.1 · Warn if there are libs not declared in sonar.sources
def warn_untracked_libs() -> None:
detected: Set[str] = set()
modules_dir = PROJECT_ROOT / "modules"
if not modules_dir.exists():
return

for pkg in modules_dir.iterdir():
if not pkg.is_dir():
continue
if (pkg / "lib").exists():
detected.add(f"modules/{pkg.name}/lib")

missing = detected - set(SONAR_SOURCES)
if missing:
print("\n⚠️ Libraries found in the repo but **not** listed in sonar.sources:")
for m in sorted(missing):
print(f" • {m}")
print(" ➜ Add them to sonar.sources if you want them analysed and covered,\n"
" otherwise they will be ignored by SonarQube.\n")

warn_untracked_libs()

# 2 · Ignore patterns and helper functions
IGNORE_PATTERNS = [
"**/*.g.dart", "**/*.freezed.dart", "**/*.mocks.dart", "**/*.gr.dart",
"**/*.gql.dart", "**/*.graphql.dart", "**/*.graphql.schema.*",
"**/*.arb", "messages_*.dart", "lib/presenter/**", "**/generated/**",
]
IGNORED_CLASS_TYPES = ["abstract class", "mixin", "enum"]

def run(cmd: List[str], *, cwd: Path | None = None, dry: bool = False) -> None:
"""subprocess.run with an optional DRY-RUN mode."""
if dry:
print("DRY $", " ".join(cmd))
return
subprocess.run(cmd, cwd=cwd, check=True)

# 3 · Test + coverage per module
def run_coverage_for_module(name: str, lib_path: Path, *, dry: bool = False) -> None:
print(f"\n📦 Running coverage for module: {name}")
module_dir, test_dir = lib_path.parent, lib_path.parent / "test"

if not test_dir.exists():
print(f"⚠️ '{name}' has no test directory → marked as 0 %")
return

run(["flutter", "test", "--coverage"], cwd=module_dir, dry=dry)

src = module_dir / "coverage/lcov.info"
dst = COVERAGE_DIR / f"lcov_{name}.info"
if src.exists() and not dry:
shutil.move(src, dst)
print(f"✅ Coverage for {name} → {dst.relative_to(PROJECT_ROOT)}")

# 4 · Merge and normalise paths
def norm_path(module: str, original: str) -> str:
"""Convert `lib/foo.dart` → `app/lib/foo.dart` or `modules/<module>/lib/foo.dart`."""
return f"app/{original}" if module == "app" else f"modules/{module}/{original}"

def merge_lcov_files(*, dry: bool = False) -> None:
print("\n🔗 Merging module reports… (normalising SF: paths)")
COVERAGE_DIR.mkdir(exist_ok=True)
if dry:
print("DRY would merge individual LCOV files here")
return

with LCOV_MERGED_FILE.open("w", encoding="utf-8") as merged:
for module in MODULE_PATHS:
file = COVERAGE_DIR / f"lcov_{module}.info"
if not file.exists():
continue
for line in file.read_text(encoding="utf-8").splitlines():
if line.startswith("SF:"):
p = line[3:].strip()
if p.startswith("lib/"):
p = norm_path(module, p)
merged.write(f"SF:{p}\n")
else:
merged.write(line + "\n")
print(f"✅ Merged → {LCOV_MERGED_FILE.relative_to(PROJECT_ROOT)}")

# 5 · Add 0 % blocks for uncovered files
def ignore_file(path: Path) -> bool:
rel = path.relative_to(PROJECT_ROOT).as_posix()
return any(fnmatch.fnmatch(rel, pat) for pat in IGNORE_PATTERNS)

def ignore_entire_file(lines: List[str]) -> bool:
if any("// coverage:ignore-file" in l for l in lines):
return True
return any(l.startswith(t) for t in IGNORED_CLASS_TYPES for l in lines)

def is_executable(line: str) -> bool:
line = line.strip()
if not line or line.startswith(("//", "/*", "*", "@", "import", "export", "part ")):
return False
if "override" in line:
return False
return True # simplified: good enough for 0-coverage entries

def existing_covered() -> Set[Path]:
covered: Set[Path] = set()
if LCOV_MERGED_FILE.exists():
for l in LCOV_MERGED_FILE.read_text(encoding="utf-8").splitlines():
if l.startswith("SF:"):
covered.add((PROJECT_ROOT / l[3:].strip()).resolve())
return covered

def write_full_coverage() -> None:
print("\n🧠 Writing final lcov.info (filling 0 % files)…")
covered = existing_covered()
all_files: Set[Path] = set()
for src in MODULE_PATHS.values():
all_files.update({f.resolve() for f in src.rglob("*.dart") if not ignore_file(f)})

with LCOV_FULL_FILE.open("w", encoding="utf-8") as out:
if LCOV_MERGED_FILE.exists():
out.write(LCOV_MERGED_FILE.read_text(encoding="utf-8"))

for f in sorted(all_files - covered):
lines = f.read_text(encoding="utf-8").splitlines()
if ignore_entire_file(lines):
continue
rel = f.relative_to(PROJECT_ROOT).as_posix()
da = [f"DA:{i},0" for i, l in enumerate(lines, 1) if is_executable(l)]
if da:
entry = ["SF:" + rel, *da, f"LF:{len(da)}", "LH:0", "end_of_record"]
out.write("\n".join(entry) + "\n")
print(f"✅ Final lcov.info → {LCOV_FULL_FILE.relative_to(PROJECT_ROOT)}")

# 6 · Coverage summary
def coverage_summary() -> None:
total = hits = 0
for line in LCOV_FULL_FILE.read_text(encoding="utf-8").splitlines():
if line.startswith("LF:"):
total += int(line.split(":")[1])
elif line.startswith("LH:"):
hits += int(line.split(":")[1])
pct = 0 if total == 0 else hits / total * 100
print(f"\n📊 Global coverage: {hits}/{total} lines ({pct:.2f} %)")

# 7 · Validate paths before running sonar-scanner
def lcov_paths_valid() -> bool:
for line in LCOV_FULL_FILE.read_text(encoding="utf-8").splitlines():
if line.startswith("SF:"):
p = line[3:].strip()
if not any(p.startswith(src) for src in SONAR_SOURCES):
print(f"⚠️ Path outside sonar.sources: {p}")
return False
return True

# MAIN
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--ci", action="store_true", help="Non-interactive mode (always run sonar-scanner and fail on prompts)")
parser.add_argument("--dry-run", action="store_true", help="Show what would happen without executing tests or sonar-scanner")
args = parser.parse_args()

# Clean previous coverage artefacts
print("\n🧹 Cleaning coverage/")
COVERAGE_DIR.mkdir(exist_ok=True)
for f in COVERAGE_DIR.glob("lcov*.info"):
f.unlink()

# Generate coverage per module
for name, lib in MODULE_PATHS.items():
run_coverage_for_module(name, lib, dry=args.dry_run)

merge_lcov_files(dry=args.dry_run)
if not args.dry_run:
write_full_coverage()
coverage_summary()

# SonarQube
if not args.ci and input("\n🤖 Run sonar-scanner now? (y/n): ").lower() != "y":
print("👋 Done without scanning.")
return

if not args.dry_run and not lcov_paths_valid():
print("❌ Fix the paths before scanning.")
return

if not args.dry_run:
token = os.environ.get("SONAR_TOKEN") or getpass.getpass("SONAR_TOKEN: ")
os.environ["SONAR_TOKEN"] = token

print("\n📡 Launching sonar-scanner…")
run(["sonar-scanner"], dry=args.dry_run)

if __name__ == "__main__":
main()

15 changes: 13 additions & 2 deletions sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ sonar.host.url=https://your-sonarqube-server.net
sonar.projectVersion=1.0
sonar.sourceEncoding=UTF-8
# Main source directories
sonar.sources=app/lib,modules/domain,modules/data,modules/common
sonar.dart.exclusions=pubspec.yaml
sonar.sources=app/lib,modules/domain/lib,modules/data/lib,modules/common/lib

sonar.dart.analyzer.report.mode=LEGACY
# Exclude generated code from both analysis *and* coverage
sonar.exclusions=**/*.g.dart,**/generated/**,**/*.freezed.dart, pubspec.yaml, coverage/**
sonar.coverage.exclusions=**/*.g.dart,**/generated/**,**/*.freezed.dart, pubspec.yaml, coverage/**

# common & data have no tests yet
sonar.tests=app/test,modules/domain/test

# Coverage report – property understood by the **sonar-flutter** plugin
sonar.flutter.coverage.reportPath=coverage/lcov.info


Loading