Skip to content

Commit 7c5f134

Browse files
Tarek RadovanTarek Radovan
authored andcommitted
tests: add test coverage with sonar
1 parent 4d7b528 commit 7c5f134

File tree

4 files changed

+342
-36
lines changed

4 files changed

+342
-36
lines changed
Lines changed: 64 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,64 @@
1-
#SonarQube Configuration
2-
# This is the sonarqube configuration, check readme for instructions
3-
#name: 'sonarqube'
4-
#
5-
#on: push
6-
#
7-
#jobs:
8-
# sonarQubeTrigger:
9-
# name: Sonarqube-Trigger
10-
# runs-on: ubuntu-latest
11-
# steps:
12-
# - uses: dart-lang/setup-dart@v1
13-
# - name: Checkout code
14-
# uses: actions/checkout@v2
15-
# - uses: webfactory/[email protected]
16-
# with:
17-
# ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
18-
# - name: Set up Flutter
19-
# uses: subosito/flutter-action@v2
20-
# with:
21-
# channel: stable
22-
# flutter-version: 3.24.3
23-
# - run: flutter --version
24-
# - name: Get Dependencies
25-
# run: flutter pub get app && flutter pub get modules/domain && flutter pub get modules/data && flutter pub get modules/common
26-
# - name: Analyze App
27-
# #run analyze first
28-
# run: flutter analyze
29-
# - name: Setup Sonarqube Scanner
30-
# uses: warchant/setup-sonar-scanner@v8
31-
# - name: Run Sonarqube Scanner
32-
# run: sonar-scanner
33-
# -Dsonar.token=${{ secrets.SONAR_TOKEN }}
34-
# -Dsonar.host.url=${{ secrets.SONAR_URL }}
1+
# name: sonarqube
2+
3+
# # ────────────────────────────────────────────────────────────────
4+
# # CI TRIGGERS
5+
# # · push on main → historical baseline
6+
# # · pull_request PRs → quality gate before merge
7+
# # ────────────────────────────────────────────────────────────────
8+
# on:
9+
# push:
10+
# branches: [main]
11+
# pull_request:
12+
# types: [opened, synchronize, reopened]
13+
14+
# jobs:
15+
# sonarQubeTrigger:
16+
# name: Sonarqube Trigger
17+
# runs-on: ubuntu-latest
18+
19+
# steps:
20+
# # 1 — Checkout the repo
21+
# - name: Checkout code
22+
# uses: actions/checkout@v3
23+
24+
# # 2 — SSH agent for any Git-based pub dependencies
25+
# - name: Start ssh-agent
26+
# uses: webfactory/[email protected]
27+
# with:
28+
# ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
29+
30+
# # 3 — Install Dart SDK
31+
# - uses: dart-lang/setup-dart@v1
32+
33+
# # 4 — Install Flutter SDK
34+
# - name: Set up Flutter
35+
# uses: subosito/flutter-action@v2
36+
# with:
37+
# channel: stable
38+
# flutter-version: 3.24.3
39+
40+
# # 5 — Install all pub packages (app + each module)
41+
# - name: Get pub packages
42+
# run: |
43+
# set -e
44+
# for dir in app modules/*; do
45+
# if [ -f "$dir/pubspec.yaml" ]; then
46+
# echo "▶ flutter pub get --directory $dir"
47+
# flutter pub get --directory "$dir"
48+
# fi
49+
# done
50+
51+
# # 6 — Static analysis (kept exactly as before)
52+
# - name: Analyze App
53+
# run: flutter analyze
54+
55+
# # 7 — Install SonarScanner CLI (needed by full_coverage.py)
56+
# - name: Setup Sonarqube Scanner
57+
# uses: warchant/setup-sonar-scanner@v8
58+
59+
# # 8 — Run tests, build combined lcov.info and upload to SonarQube
60+
# - name: Generate coverage & run SonarQube
61+
# run: python3 coverage/full_coverage.py --ci
62+
# env:
63+
# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
64+
# SONAR_URL: ${{ secrets.SONAR_URL }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ unlinked_spec.ds
110110

111111
# Coverage
112112
coverage/
113+
+!coverage/full_coverage.py
113114

114115
# Symbols
115116
app.*.symbols

coverage/full_coverage.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
#!/usr/bin/env python3
2+
"""
3+
full_coverage.py – Generates a single **lcov.info** for a multi-package Flutter
4+
repository and uploads it to SonarQube.
5+
6+
Workflow
7+
========
8+
1. Read **sonar-project.properties** → use *exactly* the folders listed in
9+
`sonar.sources`.
10+
2. Warn if there are libraries under `modules/*/lib` that are **not** declared
11+
in `sonar.sources` (they would be ignored by SonarQube otherwise).
12+
3. Run tests *per module* (if a `test/` folder exists) and generate one LCOV
13+
report per module.
14+
4. Normalise every `SF:` line so paths start with `app/lib/…` or
15+
`modules/<module>/lib/…` — this guarantees SonarQube can resolve them.
16+
5. Merge all module reports and add **0 % coverage blocks** for every Dart file
17+
that still has no tests.
18+
6. Validate paths before launching **sonar-scanner**.
19+
20+
Usage
21+
-----
22+
Interactive:
23+
python3 coverage/full_coverage.py
24+
25+
CI (no Y/N prompt):
26+
python3 coverage/full_coverage.py --ci
27+
28+
Dry-run (show commands, don’t execute):
29+
python3 coverage/full_coverage.py --dry-run
30+
"""
31+
from __future__ import annotations
32+
33+
import argparse
34+
import configparser
35+
import fnmatch
36+
import getpass
37+
import os
38+
import re
39+
import shutil
40+
import subprocess
41+
from pathlib import Path
42+
from typing import Dict, List, Set
43+
44+
# Basic paths
45+
PROJECT_ROOT = Path.cwd()
46+
COVERAGE_DIR = PROJECT_ROOT / "coverage"
47+
LCOV_MERGED_FILE = COVERAGE_DIR / "lcov.merged.info"
48+
LCOV_FULL_FILE = COVERAGE_DIR / "lcov.info"
49+
50+
# 1 · Read `sonar.sources` → build MODULE_PATHS
51+
def load_sonar_sources(props: Path = PROJECT_ROOT / "sonar-project.properties") -> List[str]:
52+
"""Return the comma/semicolon-separated folders configured in sonar.sources."""
53+
if not props.exists():
54+
return []
55+
# ConfigParser needs a header, so prepend a dummy section
56+
text = "[dummy]\n" + props.read_text(encoding="utf-8")
57+
cfg = configparser.ConfigParser()
58+
cfg.read_string(text)
59+
raw = cfg.get("dummy", "sonar.sources", fallback="")
60+
return [p.strip() for p in re.split(r"[;,]", raw) if p.strip()]
61+
62+
SONAR_SOURCES: List[str] = load_sonar_sources()
63+
64+
# Map friendly module name → lib path
65+
MODULE_PATHS: Dict[str, Path] = {}
66+
for src in SONAR_SOURCES:
67+
parts = src.split("/")
68+
if parts[0] == "app":
69+
MODULE_PATHS["app"] = PROJECT_ROOT / src
70+
elif parts[0] == "modules" and len(parts) >= 3:
71+
MODULE_PATHS[parts[1]] = PROJECT_ROOT / src
72+
73+
# 1.1 · Warn if there are libs not declared in sonar.sources
74+
def warn_untracked_libs() -> None:
75+
detected: Set[str] = set()
76+
modules_dir = PROJECT_ROOT / "modules"
77+
if not modules_dir.exists():
78+
return
79+
80+
for pkg in modules_dir.iterdir():
81+
if not pkg.is_dir():
82+
continue
83+
if (pkg / "lib").exists():
84+
detected.add(f"modules/{pkg.name}/lib")
85+
86+
missing = detected - set(SONAR_SOURCES)
87+
if missing:
88+
print("\n⚠️ Libraries found in the repo but **not** listed in sonar.sources:")
89+
for m in sorted(missing):
90+
print(f" • {m}")
91+
print(" ➜ Add them to sonar.sources if you want them analysed and covered,\n"
92+
" otherwise they will be ignored by SonarQube.\n")
93+
94+
warn_untracked_libs()
95+
96+
# 2 · Ignore patterns and helper functions
97+
IGNORE_PATTERNS = [
98+
"**/*.g.dart", "**/*.freezed.dart", "**/*.mocks.dart", "**/*.gr.dart",
99+
"**/*.gql.dart", "**/*.graphql.dart", "**/*.graphql.schema.*",
100+
"**/*.arb", "messages_*.dart", "lib/presenter/**", "**/generated/**",
101+
]
102+
IGNORED_CLASS_TYPES = ["abstract class", "mixin", "enum"]
103+
104+
def run(cmd: List[str], *, cwd: Path | None = None, dry: bool = False) -> None:
105+
"""subprocess.run with an optional DRY-RUN mode."""
106+
if dry:
107+
print("DRY $", " ".join(cmd))
108+
return
109+
subprocess.run(cmd, cwd=cwd, check=True)
110+
111+
# 3 · Test + coverage per module
112+
def run_coverage_for_module(name: str, lib_path: Path, *, dry: bool = False) -> None:
113+
print(f"\n📦 Running coverage for module: {name}")
114+
module_dir, test_dir = lib_path.parent, lib_path.parent / "test"
115+
116+
if not test_dir.exists():
117+
print(f"⚠️ '{name}' has no test directory → marked as 0 %")
118+
return
119+
120+
run(["flutter", "test", "--coverage"], cwd=module_dir, dry=dry)
121+
122+
src = module_dir / "coverage/lcov.info"
123+
dst = COVERAGE_DIR / f"lcov_{name}.info"
124+
if src.exists() and not dry:
125+
shutil.move(src, dst)
126+
print(f"✅ Coverage for {name}{dst.relative_to(PROJECT_ROOT)}")
127+
128+
# 4 · Merge and normalise paths
129+
def norm_path(module: str, original: str) -> str:
130+
"""Convert `lib/foo.dart` → `app/lib/foo.dart` or `modules/<module>/lib/foo.dart`."""
131+
return f"app/{original}" if module == "app" else f"modules/{module}/{original}"
132+
133+
def merge_lcov_files(*, dry: bool = False) -> None:
134+
print("\n🔗 Merging module reports… (normalising SF: paths)")
135+
COVERAGE_DIR.mkdir(exist_ok=True)
136+
if dry:
137+
print("DRY would merge individual LCOV files here")
138+
return
139+
140+
with LCOV_MERGED_FILE.open("w", encoding="utf-8") as merged:
141+
for module in MODULE_PATHS:
142+
file = COVERAGE_DIR / f"lcov_{module}.info"
143+
if not file.exists():
144+
continue
145+
for line in file.read_text(encoding="utf-8").splitlines():
146+
if line.startswith("SF:"):
147+
p = line[3:].strip()
148+
if p.startswith("lib/"):
149+
p = norm_path(module, p)
150+
merged.write(f"SF:{p}\n")
151+
else:
152+
merged.write(line + "\n")
153+
print(f"✅ Merged → {LCOV_MERGED_FILE.relative_to(PROJECT_ROOT)}")
154+
155+
# 5 · Add 0 % blocks for uncovered files
156+
def ignore_file(path: Path) -> bool:
157+
rel = path.relative_to(PROJECT_ROOT).as_posix()
158+
return any(fnmatch.fnmatch(rel, pat) for pat in IGNORE_PATTERNS)
159+
160+
def ignore_entire_file(lines: List[str]) -> bool:
161+
if any("// coverage:ignore-file" in l for l in lines):
162+
return True
163+
return any(l.startswith(t) for t in IGNORED_CLASS_TYPES for l in lines)
164+
165+
def is_executable(line: str) -> bool:
166+
line = line.strip()
167+
if not line or line.startswith(("//", "/*", "*", "@", "import", "export", "part ")):
168+
return False
169+
if "override" in line:
170+
return False
171+
return True # simplified: good enough for 0-coverage entries
172+
173+
def existing_covered() -> Set[Path]:
174+
covered: Set[Path] = set()
175+
if LCOV_MERGED_FILE.exists():
176+
for l in LCOV_MERGED_FILE.read_text(encoding="utf-8").splitlines():
177+
if l.startswith("SF:"):
178+
covered.add((PROJECT_ROOT / l[3:].strip()).resolve())
179+
return covered
180+
181+
def write_full_coverage() -> None:
182+
print("\n🧠 Writing final lcov.info (filling 0 % files)…")
183+
covered = existing_covered()
184+
all_files: Set[Path] = set()
185+
for src in MODULE_PATHS.values():
186+
all_files.update({f.resolve() for f in src.rglob("*.dart") if not ignore_file(f)})
187+
188+
with LCOV_FULL_FILE.open("w", encoding="utf-8") as out:
189+
if LCOV_MERGED_FILE.exists():
190+
out.write(LCOV_MERGED_FILE.read_text(encoding="utf-8"))
191+
192+
for f in sorted(all_files - covered):
193+
lines = f.read_text(encoding="utf-8").splitlines()
194+
if ignore_entire_file(lines):
195+
continue
196+
rel = f.relative_to(PROJECT_ROOT).as_posix()
197+
da = [f"DA:{i},0" for i, l in enumerate(lines, 1) if is_executable(l)]
198+
if da:
199+
entry = ["SF:" + rel, *da, f"LF:{len(da)}", "LH:0", "end_of_record"]
200+
out.write("\n".join(entry) + "\n")
201+
print(f"✅ Final lcov.info → {LCOV_FULL_FILE.relative_to(PROJECT_ROOT)}")
202+
203+
# 6 · Coverage summary
204+
def coverage_summary() -> None:
205+
total = hits = 0
206+
for line in LCOV_FULL_FILE.read_text(encoding="utf-8").splitlines():
207+
if line.startswith("LF:"):
208+
total += int(line.split(":")[1])
209+
elif line.startswith("LH:"):
210+
hits += int(line.split(":")[1])
211+
pct = 0 if total == 0 else hits / total * 100
212+
print(f"\n📊 Global coverage: {hits}/{total} lines ({pct:.2f} %)")
213+
214+
# 7 · Validate paths before running sonar-scanner
215+
def lcov_paths_valid() -> bool:
216+
for line in LCOV_FULL_FILE.read_text(encoding="utf-8").splitlines():
217+
if line.startswith("SF:"):
218+
p = line[3:].strip()
219+
if not any(p.startswith(src) for src in SONAR_SOURCES):
220+
print(f"⚠️ Path outside sonar.sources: {p}")
221+
return False
222+
return True
223+
224+
# MAIN
225+
def main() -> None:
226+
parser = argparse.ArgumentParser()
227+
parser.add_argument("--ci", action="store_true", help="Non-interactive mode (always run sonar-scanner and fail on prompts)")
228+
parser.add_argument("--dry-run", action="store_true", help="Show what would happen without executing tests or sonar-scanner")
229+
args = parser.parse_args()
230+
231+
# Clean previous coverage artefacts
232+
print("\n🧹 Cleaning coverage/")
233+
COVERAGE_DIR.mkdir(exist_ok=True)
234+
for f in COVERAGE_DIR.glob("lcov*.info"):
235+
f.unlink()
236+
237+
# Generate coverage per module
238+
for name, lib in MODULE_PATHS.items():
239+
run_coverage_for_module(name, lib, dry=args.dry_run)
240+
241+
merge_lcov_files(dry=args.dry_run)
242+
if not args.dry_run:
243+
write_full_coverage()
244+
coverage_summary()
245+
246+
# SonarQube
247+
if not args.ci and input("\n🤖 Run sonar-scanner now? (y/n): ").lower() != "y":
248+
print("👋 Done without scanning.")
249+
return
250+
251+
if not args.dry_run and not lcov_paths_valid():
252+
print("❌ Fix the paths before scanning.")
253+
return
254+
255+
if not args.dry_run:
256+
token = os.environ.get("SONAR_TOKEN") or getpass.getpass("SONAR_TOKEN: ")
257+
os.environ["SONAR_TOKEN"] = token
258+
259+
print("\n📡 Launching sonar-scanner…")
260+
run(["sonar-scanner"], dry=args.dry_run)
261+
262+
if __name__ == "__main__":
263+
main()
264+

sonar-project.properties

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ sonar.host.url=https://your-sonarqube-server.net
44
sonar.projectVersion=1.0
55
sonar.sourceEncoding=UTF-8
66
# Main source directories
7-
sonar.sources=app/lib,modules/domain,modules/data,modules/common
8-
sonar.dart.exclusions=pubspec.yaml
7+
sonar.sources=app/lib,modules/domain/lib,modules/data/lib,modules/common/lib
8+
99
sonar.dart.analyzer.report.mode=LEGACY
10+
# Exclude generated code from both analysis *and* coverage
11+
sonar.exclusions=**/*.g.dart,**/generated/**,**/*.freezed.dart, pubspec.yaml, coverage/**
12+
sonar.coverage.exclusions=**/*.g.dart,**/generated/**,**/*.freezed.dart, pubspec.yaml, coverage/**
13+
14+
# common & data have no tests yet
15+
sonar.tests=app/test,modules/domain/test
16+
17+
# Coverage report – property understood by the **sonar-flutter** plugin
18+
sonar.flutter.coverage.reportPath=coverage/lcov.info
19+
20+

0 commit comments

Comments
 (0)