screen-locker/scripts/check_flutter_coverage.sh
Krzysztof kuhy Rudnicki 7ae4c13407
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
CI under 60 s: uv, xdist, split pre-commit, sharded Flutter coverage
Tests (~214 s, gated by flutter-coverage):
- Flutter suite runs as 8 parallel buckets of whole test files
  (check_flutter_coverage.sh --shard I/N); flutter-coverage merges their
  lcov files (--merged N) and applies the unchanged 100% bar and
  every-lib-file-present guard. `flutter test --total-shards` splits test
  cases inside each file, so every shard would still compile all 77 files.
  Proved locally: the merged lcov matches an unsharded run line for line
  (3312/3312), and a missing shard fails. flutter-action caches the SDK.
- pytest: uv install, -n auto -q -p no:sugar (local: 25.9 s -> 10.6 s,
  100% coverage both ways).
- lock-popup-safety: uv, and no apt step -- xvfb-run and setup-python's
  tkinter are already on the runner (screens-fit relies on both already).

pre-commit (~122 s): matrix of mypy / pylint / flutter-analyze / rest, each
installing only its own toolchain, with a per-group hook cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188kZdx3bb56QM8JFUnbHuM
2026-09-26 19:36:32 +02:00

213 lines
7.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# Enforces 100% line coverage for the Flutter workout app.
#
# Run from the repo root. Fails if:
# 1. Any lib/**/*.dart file is missing from coverage/lcov.info (unreachable
# file — the 100% would be fake without this check).
# 2. Line coverage across all lib/ files is below 100%.
#
# Usage:
# scripts/check_flutter_coverage.sh whole suite, then both checks
# scripts/check_flutter_coverage.sh --shard I/N test bucket I of N (0-based)
# only; leaves its lcov.info,
# checks nothing
# scripts/check_flutter_coverage.sh --merged N LCOV...
# union N shards' lcov files
# into coverage/lcov.info,
# then both checks
#
# Why shards: the suite took ~150 s on one 4-core runner, almost all of it
# compiling 77 test files. `flutter test --total-shards` cannot help -- it
# splits the test CASES inside every file, so each shard still compiles all
# 77 -- hence buckets of whole files, one per CI runner, and a fan-in job that
# runs the same two checks on their union.
set -euo pipefail
APP_DIR="stronglift_replacement/workout_app"
LCOV_INFO="$APP_DIR/coverage/lcov.info"
die() {
echo "ERROR: $*" >&2
exit 1
}
# Runs `flutter test --coverage` in the app, on the given test files (none =
# the whole suite).
run_flutter_tests() {
echo "Running flutter test --coverage ..."
# env -u GIT_DIR: git exports it to hooks, and the flutter tool shells out to
# git to identify its own SDK. With it set, flutter reads THIS repository as
# the SDK -- reporting our HEAD as the framework revision -- and pub then
# resolves every version constraint against "0.0.0-unknown" and fails.
# FLUTTER_TEST_CONCURRENCY: one test isolate per core is the default and
# peaks well over 4 GiB on this suite, which the 4 GiB resource cap
# (capped.sh) OOM-kills. Set it to 1-2 under the cap; unset keeps the default.
local concurrency=()
if [[ -n "${FLUTTER_TEST_CONCURRENCY:-}" ]]; then
concurrency=(--concurrency "$FLUTTER_TEST_CONCURRENCY")
fi
(cd "$APP_DIR" && env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
flutter test --coverage "${concurrency[@]}" "$@")
}
# Prints bucket INDEX of TOTAL: every TOTAL-th test file, in a fixed sort
# order, so the N buckets partition the suite exactly.
shard_files() {
local index=$1 total=$2 i=0 file
while IFS= read -r file; do
if (( i % total == index )); then
printf '%s\n' "$file"
fi
i=$(( i + 1 ))
done < <(cd "$APP_DIR" && find test -name '*_test.dart' | LC_ALL=C sort)
}
# Writes to OUT the union of the given lcov files: per source file, every
# line any shard instrumented, hit if any shard hit it -- which is what one
# unsharded run reports, since flutter merges its test isolates the same way.
# LF/LH are recomputed from that union rather than summed, because a lib file
# loaded by two shards would otherwise count its lines twice.
merge_lcov() {
local out=$1 file line sf="" rest ln hits key lf lh
shift
local -A hit=() lines_of=() seen=()
local -a order=()
for file in "$@"; do
while IFS= read -r line; do
case $line in
SF:*)
sf=${line#SF:}
if [[ -z ${seen[$sf]:-} ]]; then
seen[$sf]=1
order+=("$sf")
fi
;;
DA:*)
rest=${line#DA:}
ln=${rest%%,*}
rest=${rest#*,}
hits=${rest%%,*}
key="$sf|$ln"
if [[ -z ${hit[$key]:-} ]]; then
hit[$key]=$hits
lines_of[$sf]+="$ln "
else
hit[$key]=$(( hit[$key] + hits ))
fi
;;
esac
done < "$file"
done
mkdir -p "$(dirname "$out")"
for sf in "${order[@]}"; do
echo "SF:$sf"
lf=0
lh=0
for ln in ${lines_of[$sf]:-}; do
echo "DA:$ln,${hit[$sf|$ln]}"
lf=$(( lf + 1 ))
if (( hit[$sf|$ln] > 0 )); then
lh=$(( lh + 1 ))
fi
done
echo "LF:$lf"
echo "LH:$lh"
echo "end_of_record"
done > "$out"
}
case "${1:-}" in
"")
run_flutter_tests
;;
--shard)
[[ ${2:-} =~ ^([0-9]+)/([1-9][0-9]*)$ ]] || die "--shard wants I/N, got '${2:-}'"
index=${BASH_REMATCH[1]}
total=${BASH_REMATCH[2]}
(( index < total )) || die "shard index $index is not below $total"
mapfile -t files < <(shard_files "$index" "$total")
(( ${#files[@]} > 0 )) || die "shard $index/$total has no test files"
echo "Shard $index/$total: ${#files[@]} test files"
run_flutter_tests "${files[@]}"
exit 0
;;
--merged)
[[ ${2:-} =~ ^[1-9][0-9]*$ ]] || die "--merged wants a shard count first"
expected=$2
shift 2
# A shard whose artifact went missing would quietly shrink the union.
(( $# == expected )) || die "expected $expected shard lcov files, got $#"
merge_lcov "$LCOV_INFO" "$@"
;;
*)
die "unknown argument '$1' (see the usage at the top of $0)"
;;
esac
if [[ ! -f "$LCOV_INFO" ]]; then
echo "ERROR: $LCOV_INFO not found — did flutter test run?" >&2
exit 1
fi
# ── Guard: every lib/**/*.dart must appear in the coverage report ──────────────
# Flutter only reports files that are transitively imported by a test.
# Unreferenced files silently score 100% by absence — catch that here.
#
# Exception: a file whose only content is a conditional `export` (a
# platform-gate barrel like google_platform.dart, re-exporting google_platform_io.dart
# or google_platform_web.dart per dart.library.js_interop) has zero executable
# statements. Dart's coverage instrumentation never emits an SF: entry for such
# a file regardless of whether it was imported -- there is no "silently 100% by
# absence" for it to hide, since there is nothing to cover. Importing it (even
# transitively) is what makes its *resolved* sibling appear in lcov instead.
barrel_pattern="^(library;)?[[:space:]]*export[[:space:]]+'[^']+'[[:space:]]+if[[:space:]]*\([^)]+\)[[:space:]]+'[^']+';[[:space:]]*\$"
missing=0
while IFS= read -r dart_file; do
rel="${dart_file#"$APP_DIR"/}"
# Strip //-comments, ///-doc-comments and blank lines, then collapse
# whitespace, so a wrapped `export 'x' if (...) 'y';` statement (the
# platform-gate barrel shape) becomes matchable on one logical line
# regardless of how it's line-wrapped in source.
code_only=$(grep -vE '^[[:space:]]*(///|//|$)' "$dart_file" | tr '\n' ' ' | tr -s ' ')
if [[ $code_only =~ $barrel_pattern ]]; then
continue
fi
if ! grep -qF "SF:$rel" "$LCOV_INFO" && \
! grep -qF "SF:lib/${rel#lib/}" "$LCOV_INFO"; then
echo "MISSING FROM COVERAGE: $dart_file" >&2
missing=1
fi
done < <(find "$APP_DIR/lib" -name "*.dart" ! -path "*/generated/*")
if [[ $missing -eq 1 ]]; then
echo ""
echo "ERROR: Some lib/ files are not covered. Import them in a test." >&2
exit 1
fi
# ── Compute line coverage percentage from lcov.info ───────────────────────────
total_found=0
total_hit=0
while IFS= read -r line; do
if [[ $line == LF:* ]]; then
total_found=$(( total_found + ${line#LF:} ))
elif [[ $line == LH:* ]]; then
total_hit=$(( total_hit + ${line#LH:} ))
fi
done < "$LCOV_INFO"
if [[ $total_found -eq 0 ]]; then
echo "ERROR: No line coverage data found in $LCOV_INFO" >&2
exit 1
fi
if [[ $total_hit -lt $total_found ]]; then
missed=$(( total_found - total_hit ))
echo "ERROR: Line coverage is not 100% — $missed/$total_found lines not covered." >&2
echo "Run: flutter test --coverage && genhtml coverage/lcov.info -o coverage/html" >&2
exit 1
fi
echo "Coverage OK: $total_hit/$total_found lines covered (100%)."