From 922fb362086effce84c38bcf00ea810a91390d2c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 28 Aug 2026 12:32:24 +0200 Subject: [PATCH] crdt_sync_dart: latest stable + exact pins, 100% coverage, CI for both crdt_sync packages Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/crdt-sync-dart-tests.yml | 48 +++++++ .github/workflows/crdt-sync-flutter-tests.yml | 36 +++++ crdt_sync_dart/pubspec.yaml | 8 +- crdt_sync_dart/scripts/check_coverage.sh | 32 +++++ .../test/default_client_and_io_test.dart | 132 ++++++++++++++++++ crdt_sync_dart/test/hlc_value_test.dart | 89 ++++++++++++ crdt_sync_dart/test/record_value_test.dart | 130 +++++++++++++++++ crdt_sync_flutter/scripts/check_coverage.sh | 32 +++++ .../test/descriptor_value_test.dart | 54 +++++++ design_system/scripts/check_coverage.sh | 67 +++------ github_device_auth/scripts/check_coverage.sh | 67 +++------ scripts/check_coverage.sh | 70 ++++++++++ sync_settings_ui/scripts/check_coverage.sh | 67 +++------ 13 files changed, 681 insertions(+), 151 deletions(-) create mode 100644 .github/workflows/crdt-sync-dart-tests.yml create mode 100644 .github/workflows/crdt-sync-flutter-tests.yml create mode 100755 crdt_sync_dart/scripts/check_coverage.sh create mode 100644 crdt_sync_dart/test/default_client_and_io_test.dart create mode 100644 crdt_sync_dart/test/hlc_value_test.dart create mode 100644 crdt_sync_dart/test/record_value_test.dart create mode 100755 crdt_sync_flutter/scripts/check_coverage.sh create mode 100644 crdt_sync_flutter/test/descriptor_value_test.dart create mode 100755 scripts/check_coverage.sh diff --git a/.github/workflows/crdt-sync-dart-tests.yml b/.github/workflows/crdt-sync-dart-tests.yml new file mode 100644 index 0000000..4205c33 --- /dev/null +++ b/.github/workflows/crdt-sync-dart-tests.yml @@ -0,0 +1,48 @@ +name: crdt_sync_dart tests + +on: + push: + branches: [main] + paths: ['crdt_sync_dart/**'] + pull_request: + branches: [main] + paths: ['crdt_sync_dart/**'] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: crdt_sync_dart + + steps: + - uses: actions/checkout@v4 + + # Pure Dart, no Flutter dependency -- so setup-dart, not flutter-action. + - name: Set up Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Install dependencies + run: dart pub get + + - name: Analyze + run: dart analyze --fatal-infos + + - name: Run tests with coverage + run: dart test --coverage=coverage + + # Unlike `flutter test --coverage`, `dart test --coverage` emits raw VM + # JSON and no lcov. Skipping this step leaves a stale lcov.info on disk + # that the gate below happily "passes" -- which is exactly how this + # package drifted to 96% while believing it was at 100%. + - name: Convert coverage to lcov + run: | + dart pub global activate coverage + dart pub global run coverage:format_coverage \ + --lcov --in=coverage/test --out=coverage/lcov.info \ + --report-on=lib --packages=.dart_tool/package_config.json + + - name: Enforce 100% line coverage + run: scripts/check_coverage.sh coverage/lcov.info 100 diff --git a/.github/workflows/crdt-sync-flutter-tests.yml b/.github/workflows/crdt-sync-flutter-tests.yml new file mode 100644 index 0000000..b6a8f83 --- /dev/null +++ b/.github/workflows/crdt-sync-flutter-tests.yml @@ -0,0 +1,36 @@ +name: crdt_sync_flutter tests + +on: + push: + branches: [main] + paths: ['crdt_sync_flutter/**', 'crdt_sync_dart/**'] + pull_request: + branches: [main] + paths: ['crdt_sync_flutter/**', 'crdt_sync_dart/**'] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: crdt_sync_flutter + + steps: + - uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Analyze + run: flutter analyze + + - name: Run tests with coverage + run: flutter test --coverage + + - name: Enforce 100% line coverage + run: scripts/check_coverage.sh coverage/lcov.info 100 diff --git a/crdt_sync_dart/pubspec.yaml b/crdt_sync_dart/pubspec.yaml index 55c2944..e639abc 100644 --- a/crdt_sync_dart/pubspec.yaml +++ b/crdt_sync_dart/pubspec.yaml @@ -9,9 +9,9 @@ environment: sdk: ^3.12.2 dependencies: - crypto: ^3.0.7 - http: ^1.6.0 + crypto: 3.0.7 + http: 1.6.0 dev_dependencies: - lints: ^5.0.0 - test: ^1.25.0 + lints: 6.1.0 + test: 1.31.2 diff --git a/crdt_sync_dart/scripts/check_coverage.sh b/crdt_sync_dart/scripts/check_coverage.sh new file mode 100755 index 0000000..40d0933 --- /dev/null +++ b/crdt_sync_dart/scripts/check_coverage.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# ============================================================================ +# Fail if lcov line coverage drops below a threshold. +# +# Thin delegate to the shared gate in ~/utils/scripts, which owns the parsing +# and the threshold comparison. This file used to be a byte-identical copy of +# that logic; three such copies existed, which is how one package's idea of +# "covered" drifts from every other package's. +# +# Usage: check_coverage.sh +# ============================================================================ + +set -euo pipefail + +# Repo-relative, resolved from this script's own location: an absolute +# /home/... path exits 127 on an Actions runner. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SHARED_GATE="${SCRIPT_DIR}/../../scripts/check_coverage.sh" + +main() { + if [[ ! -f "$SHARED_GATE" ]]; then + echo "Error: shared coverage gate not found at $SHARED_GATE" >&2 + echo " It lives in the utils repo at scripts/check_coverage.sh." >&2 + exit 1 + fi + + exec bash "$SHARED_GATE" "$@" +} + +main "$@" diff --git a/crdt_sync_dart/test/default_client_and_io_test.dart b/crdt_sync_dart/test/default_client_and_io_test.dart new file mode 100644 index 0000000..74d898e --- /dev/null +++ b/crdt_sync_dart/test/default_client_and_io_test.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:crdt_sync/crdt_sync_io.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:test/test.dart'; + +/// An in-memory [LogPersistence] so store tests need no filesystem. +class _MemoryPersistence implements LogPersistence { + _MemoryPersistence([this._contents]); + + String? _contents; + + @override + Future read() async => _contents; + + @override + Future write(String contents) async => _contents = contents; +} + +void main() { + group('default http client', () { + // These constructors fall back to a real http.Client() when none is + // injected. Nothing here issues a request -- the point is only that the + // default branch is taken and the resulting client closes cleanly. + test('GitHubClient builds one when none is injected', () { + final client = GitHubClient(owner: 'o', repo: 'r', token: 't'); + addTearDown(client.close); + expect(client.owner, 'o'); + expect(client.repo, 'r'); + }); + + test('GitHubClient.close is safe to call', () { + GitHubClient(owner: 'o', repo: 'r', token: 't').close(); + }); + + test('accountFromWrapper builds and closes its own client', () async { + // Nothing listens on this port, so the GET throws and the `on Exception` + // path returns null -- while the `finally` still closes the client the + // function created for itself. + final account = await accountFromWrapper( + Uri.parse('http://127.0.0.1:1'), + ); + expect(account, isNull); + }); + + test('accountFromWrapper leaves an injected client open', () async { + final mock = http_testing.MockClient( + (_) async => http.Response(jsonEncode({}), 404), + ); + expect(await accountFromWrapper(Uri.parse('http://x'), client: mock), + isNull); + // Still usable: the function must not close a client it did not create. + expect((await mock.get(Uri.parse('http://x'))).statusCode, 404); + }); + }); + + group('RemoteNotFoundError', () { + test('carries its message and is a RemoteSyncError', () { + final error = RemoteNotFoundError('nothing synced yet'); + expect(error, isA()); + expect(error.toString(), contains('nothing synced yet')); + }); + }); + + group('LogStore tombstone clock tracking', () { + test('a loaded tombstone advances the clock past its deletedHlc', () async { + // deletedHlc is far ahead of any field clock, so the next local write + // can only outrank it if load() fed the tombstone's clock in too. + final deletedHlc = Hlc( + wallTimeMs: DateTime.utc(2030).millisecondsSinceEpoch, + counter: 7, + nodeId: 'node-remote', + ); + final log = { + 'r1': Record( + id: 'r1', + fields: { + 'title': ('old', Hlc(wallTimeMs: 1, counter: 0, nodeId: 'node-a')), + }, + deleted: true, + deletedHlc: deletedHlc, + ), + }; + final store = LogStore( + persistence: _MemoryPersistence(logToJson(log)), + nodeId: 'node-a', + ); + addTearDown(store.close); + await store.load(); + + expect(store.nextHlc() > deletedHlc, isTrue); + }); + }); + + group('FileLogPersistence', () { + late Directory dir; + + setUp(() => dir = Directory.systemTemp.createTempSync('crdt_sync_test')); + tearDown(() => dir.deleteSync(recursive: true)); + + test('read returns null when the file does not exist', () async { + final store = FileLogPersistence(File('${dir.path}/absent.json')); + expect(await store.read(), isNull); + }); + + test('read returns null when the file cannot be read', () async { + // A mode-000 file still passes existsSync, so the guard does not fire + // and readAsString throws PathAccessException (a FileSystemException). + // A directory would NOT work here: File.existsSync() is false for one, + // so read() would return early down the guard path instead. + final file = File('${dir.path}/unreadable.json')..writeAsStringSync('{}'); + final chmod = Process.runSync('chmod', ['000', file.path]); + expect(chmod.exitCode, 0); + addTearDown(() => Process.runSync('chmod', ['644', file.path])); + + expect(await FileLogPersistence(file).read(), isNull); + }, + // root ignores the permission bits, so the exception never fires. + skip: Platform.environment['USER'] == 'root' + ? 'permission bits do not apply to root' + : null); + + test('round-trips what it wrote', () async { + final store = FileLogPersistence(File('${dir.path}/log.json')); + await store.write('{"hello":"world"}'); + expect(await store.read(), '{"hello":"world"}'); + }); + }); +} diff --git a/crdt_sync_dart/test/hlc_value_test.dart b/crdt_sync_dart/test/hlc_value_test.dart new file mode 100644 index 0000000..b615a8e --- /dev/null +++ b/crdt_sync_dart/test/hlc_value_test.dart @@ -0,0 +1,89 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:test/test.dart'; + +Hlc _make(int wallTimeMs, {int counter = 0, String nodeId = 'node-a'}) => + Hlc(wallTimeMs: wallTimeMs, counter: counter, nodeId: nodeId); + +void main() { + group('comparison operators', () { + // compareTo is exercised elsewhere; these cover the four operators that + // wrap it, each of which has its own boundary at "exactly equal". + test('> is false for equal clocks', () { + expect(_make(100) > _make(100), isFalse); + }); + + test('>= is true for equal clocks', () { + expect(_make(100) >= _make(100), isTrue); + }); + + test('>= is true for a greater clock', () { + expect(_make(200) >= _make(100), isTrue); + }); + + test('>= is false for a lesser clock', () { + expect(_make(100) >= _make(200), isFalse); + }); + + test('< is false for equal clocks', () { + expect(_make(100) < _make(100), isFalse); + }); + + test('< is true for a lesser clock', () { + expect(_make(100) < _make(200), isTrue); + }); + + test('<= is true for equal clocks', () { + expect(_make(100) <= _make(100), isTrue); + }); + + test('<= is false for a greater clock', () { + expect(_make(200) <= _make(100), isFalse); + }); + }); + + group('equality', () { + test('a non-Hlc is never equal', () { + // ignore: unrelated_type_equality_checks + expect(_make(100) == 'not an hlc', isFalse); + }); + + test('differing wall time is not equal', () { + expect(_make(100), isNot(equals(_make(200)))); + }); + + test('differing counter is not equal', () { + expect(_make(100), isNot(equals(_make(100, counter: 1)))); + }); + + test('differing node id is not equal', () { + expect(_make(100), isNot(equals(_make(100, nodeId: 'node-b')))); + }); + }); + + group('hashCode', () { + test('equal clocks hash equally', () { + expect(_make(100, counter: 2).hashCode, _make(100, counter: 2).hashCode); + }); + + test('a differing field changes the hash', () { + expect( + _make(100, counter: 2).hashCode, + isNot(_make(100, counter: 3).hashCode), + ); + }); + + test('clocks survive a round trip through a Set', () { + final set = {_make(100), _make(100), _make(200)}; + expect(set, hasLength(2)); + }); + }); + + group('toString', () { + test('names every field so a failed expect is readable', () { + expect( + _make(100, counter: 2, nodeId: 'node-z').toString(), + 'Hlc(wallTimeMs: 100, counter: 2, nodeId: node-z)', + ); + }); + }); +} diff --git a/crdt_sync_dart/test/record_value_test.dart b/crdt_sync_dart/test/record_value_test.dart new file mode 100644 index 0000000..a6e33ba --- /dev/null +++ b/crdt_sync_dart/test/record_value_test.dart @@ -0,0 +1,130 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:test/test.dart'; + +Hlc _make(int wallTimeMs, {int counter = 0, String nodeId = 'node-a'}) => + Hlc(wallTimeMs: wallTimeMs, counter: counter, nodeId: nodeId); + +Record _record({ + String id = 'r1', + Map? fields, + bool deleted = false, + Hlc? deletedHlc, +}) => Record( + id: id, + fields: fields ?? {'title': ('hello', _make(100))}, + deleted: deleted, + deletedHlc: deletedHlc, +); + +void main() { + group('equality', () { + test('a non-Record is never equal', () { + // ignore: unrelated_type_equality_checks + expect(_record() == 'not a record', isFalse); + }); + + test('same id, fields and tombstone are equal', () { + expect(_record(), equals(_record())); + }); + + test('a differing id is not equal', () { + expect(_record(), isNot(equals(_record(id: 'r2')))); + }); + + test('a differing tombstone is not equal', () { + expect(_record(), isNot(equals(_record(deleted: true)))); + }); + + test('a differing deletedHlc is not equal', () { + expect( + _record(deleted: true, deletedHlc: _make(100)), + isNot(equals(_record(deleted: true, deletedHlc: _make(200)))), + ); + }); + }); + + group('field comparison', () { + // _fieldsEqual is the one part of == that cannot short-circuit on a + // scalar, so each of its exits gets its own case. + test('a differing field count is not equal', () { + expect( + _record(), + isNot( + equals( + _record( + fields: {'title': ('hello', _make(100)), 'body': ('x', _make(1))}, + ), + ), + ), + ); + }); + + test('a differing field value is not equal', () { + expect( + _record(), + isNot(equals(_record(fields: {'title': ('goodbye', _make(100))}))), + ); + }); + + test('a differing field clock is not equal', () { + expect( + _record(), + isNot(equals(_record(fields: {'title': ('hello', _make(200))}))), + ); + }); + + test('a renamed field of the same count is not equal', () { + expect( + _record(), + isNot(equals(_record(fields: {'heading': ('hello', _make(100))}))), + ); + }); + + test('field order does not affect equality', () { + final a = _record( + fields: {'a': ('1', _make(100)), 'b': ('2', _make(200))}, + ); + final b = _record( + fields: {'b': ('2', _make(200)), 'a': ('1', _make(100))}, + ); + expect(a, equals(b)); + }); + + test('an empty field map equals another empty one', () { + expect(_record(fields: {}), equals(_record(fields: {}))); + }); + }); + + group('hashCode', () { + test('equal records hash equally', () { + expect(_record().hashCode, _record().hashCode); + }); + + test('a differing id changes the hash', () { + expect(_record().hashCode, isNot(_record(id: 'r2').hashCode)); + }); + + test('hashing does not depend on field order', () { + final a = _record( + fields: {'a': ('1', _make(100)), 'b': ('2', _make(200))}, + ); + final b = _record( + fields: {'b': ('2', _make(200)), 'a': ('1', _make(100))}, + ); + expect(a.hashCode, b.hashCode); + }); + + test('records survive a round trip through a Set', () { + expect({_record(), _record(), _record(id: 'r2')}, hasLength(2)); + }); + }); + + group('toString', () { + test('names every field so a failed expect is readable', () { + expect( + _record(fields: {}).toString(), + 'Record(id: r1, fields: {}, deleted: false, deletedHlc: null)', + ); + }); + }); +} diff --git a/crdt_sync_flutter/scripts/check_coverage.sh b/crdt_sync_flutter/scripts/check_coverage.sh new file mode 100755 index 0000000..40d0933 --- /dev/null +++ b/crdt_sync_flutter/scripts/check_coverage.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# ============================================================================ +# Fail if lcov line coverage drops below a threshold. +# +# Thin delegate to the shared gate in ~/utils/scripts, which owns the parsing +# and the threshold comparison. This file used to be a byte-identical copy of +# that logic; three such copies existed, which is how one package's idea of +# "covered" drifts from every other package's. +# +# Usage: check_coverage.sh +# ============================================================================ + +set -euo pipefail + +# Repo-relative, resolved from this script's own location: an absolute +# /home/... path exits 127 on an Actions runner. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SHARED_GATE="${SCRIPT_DIR}/../../scripts/check_coverage.sh" + +main() { + if [[ ! -f "$SHARED_GATE" ]]; then + echo "Error: shared coverage gate not found at $SHARED_GATE" >&2 + echo " It lives in the utils repo at scripts/check_coverage.sh." >&2 + exit 1 + fi + + exec bash "$SHARED_GATE" "$@" +} + +main "$@" diff --git a/crdt_sync_flutter/test/descriptor_value_test.dart b/crdt_sync_flutter/test/descriptor_value_test.dart new file mode 100644 index 0000000..c39ca36 --- /dev/null +++ b/crdt_sync_flutter/test/descriptor_value_test.dart @@ -0,0 +1,54 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Built at runtime, not as a top-level `const`. +/// +/// Other suites describe their app with a compile-time `const SyncApp(...)`, +/// which the VM folds into a constant before any test body runs -- so the +/// constructors never register as executed and read as uncovered. These +/// helpers force real invocation. +SyncApp _app({WrapperRoutes? routes}) => SyncApp( + project: const FirebaseProject( + apiKey: 'test-key', + databaseUrl: 'https://example-rtdb.europe-west1.firebasedatabase.app', + ), + expectedUid: 'uid-123', + routes: routes, +); + +void main() { + group('SyncApp', () { + test('keeps the project and uid it was built with', () { + final app = _app(); + expect(app.project.apiKey, 'test-key'); + expect(app.expectedUid, 'uid-123'); + }); + + test('routes default to null for an app with no desktop wrapper', () { + expect(_app().routes, isNull); + }); + + test('keeps the routes it was given', () { + final routes = WrapperRoutes(credentialsPath: '/sync-credentials'); + expect(_app(routes: routes).routes, same(routes)); + }); + }); + + group('WrapperRoutes', () { + test('accountPath defaults to null', () { + final routes = WrapperRoutes(credentialsPath: '/sync-credentials'); + expect(routes.credentialsPath, '/sync-credentials'); + expect(routes.accountPath, isNull); + }); + + test('keeps both paths when the legacy one is supplied', () { + final routes = WrapperRoutes( + credentialsPath: '/sync-credentials', + accountPath: '/sync-account', + ); + expect(routes.credentialsPath, '/sync-credentials'); + expect(routes.accountPath, '/sync-account'); + }); + }); +} diff --git a/design_system/scripts/check_coverage.sh b/design_system/scripts/check_coverage.sh index 2c0ee6f..40d0933 100755 --- a/design_system/scripts/check_coverage.sh +++ b/design_system/scripts/check_coverage.sh @@ -3,61 +3,30 @@ # ============================================================================ # Fail if lcov line coverage drops below a threshold. # -# No Dart package in this repo has coverage enforced in CI yet -- crdt_sync_dart -# sits at 100% by discipline, not a gate. This script is that gate for -# sync_settings_ui, which ships as a 100%-covered shared UI package by -# requirement, not by convention. +# Thin delegate to the shared gate in ~/utils/scripts, which owns the parsing +# and the threshold comparison. This file used to be a byte-identical copy of +# that logic; three such copies existed, which is how one package's idea of +# "covered" drifts from every other package's. # # Usage: check_coverage.sh # ============================================================================ set -euo pipefail -SCRIPT_NAME="$(basename "$0")" -readonly SCRIPT_NAME +# Repo-relative, resolved from this script's own location: an absolute +# /home/... path exits 127 on an Actions runner. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SHARED_GATE="${SCRIPT_DIR}/../../scripts/check_coverage.sh" -usage() { - echo "Usage: $SCRIPT_NAME " - echo " Fails when line coverage in lcov.info is below min_percent." - exit 0 +main() { + if [[ ! -f "$SHARED_GATE" ]]; then + echo "Error: shared coverage gate not found at $SHARED_GATE" >&2 + echo " It lives in the utils repo at scripts/check_coverage.sh." >&2 + exit 1 + fi + + exec bash "$SHARED_GATE" "$@" } -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage -fi - -if [[ $# -ne 2 ]]; then - usage -fi - -lcov_file="$1" -min_percent="$2" - -if [[ ! -f "$lcov_file" ]]; then - echo "Error: $lcov_file not found. Run 'flutter test --coverage' first." >&2 - exit 1 -fi - -# Sum LH (lines hit) and LF (lines found) across every SF (source file) -# block rather than trusting a single top-level total -- lcov.info has no -# such total line, only per-file LH/LF pairs. -read -r hit found <<< "$(awk ' - /^LH:/ { hit += substr($0, 4) } - /^LF:/ { found += substr($0, 4) } - END { print hit, found } -' "$lcov_file")" - -if [[ "$found" -eq 0 ]]; then - echo "Error: $lcov_file reports zero coverable lines." >&2 - exit 1 -fi - -percent=$(awk -v h="$hit" -v f="$found" 'BEGIN { printf "%.2f", (h / f) * 100 }') -meets_threshold=$(awk -v p="$percent" -v m="$min_percent" 'BEGIN { print (p + 0 >= m + 0) ? 1 : 0 }') - -echo "Line coverage: $hit/$found ($percent%), threshold: $min_percent%" - -if [[ "$meets_threshold" -ne 1 ]]; then - echo "Error: coverage $percent% is below the required $min_percent%." >&2 - exit 1 -fi +main "$@" diff --git a/github_device_auth/scripts/check_coverage.sh b/github_device_auth/scripts/check_coverage.sh index 2c0ee6f..40d0933 100755 --- a/github_device_auth/scripts/check_coverage.sh +++ b/github_device_auth/scripts/check_coverage.sh @@ -3,61 +3,30 @@ # ============================================================================ # Fail if lcov line coverage drops below a threshold. # -# No Dart package in this repo has coverage enforced in CI yet -- crdt_sync_dart -# sits at 100% by discipline, not a gate. This script is that gate for -# sync_settings_ui, which ships as a 100%-covered shared UI package by -# requirement, not by convention. +# Thin delegate to the shared gate in ~/utils/scripts, which owns the parsing +# and the threshold comparison. This file used to be a byte-identical copy of +# that logic; three such copies existed, which is how one package's idea of +# "covered" drifts from every other package's. # # Usage: check_coverage.sh # ============================================================================ set -euo pipefail -SCRIPT_NAME="$(basename "$0")" -readonly SCRIPT_NAME +# Repo-relative, resolved from this script's own location: an absolute +# /home/... path exits 127 on an Actions runner. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SHARED_GATE="${SCRIPT_DIR}/../../scripts/check_coverage.sh" -usage() { - echo "Usage: $SCRIPT_NAME " - echo " Fails when line coverage in lcov.info is below min_percent." - exit 0 +main() { + if [[ ! -f "$SHARED_GATE" ]]; then + echo "Error: shared coverage gate not found at $SHARED_GATE" >&2 + echo " It lives in the utils repo at scripts/check_coverage.sh." >&2 + exit 1 + fi + + exec bash "$SHARED_GATE" "$@" } -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage -fi - -if [[ $# -ne 2 ]]; then - usage -fi - -lcov_file="$1" -min_percent="$2" - -if [[ ! -f "$lcov_file" ]]; then - echo "Error: $lcov_file not found. Run 'flutter test --coverage' first." >&2 - exit 1 -fi - -# Sum LH (lines hit) and LF (lines found) across every SF (source file) -# block rather than trusting a single top-level total -- lcov.info has no -# such total line, only per-file LH/LF pairs. -read -r hit found <<< "$(awk ' - /^LH:/ { hit += substr($0, 4) } - /^LF:/ { found += substr($0, 4) } - END { print hit, found } -' "$lcov_file")" - -if [[ "$found" -eq 0 ]]; then - echo "Error: $lcov_file reports zero coverable lines." >&2 - exit 1 -fi - -percent=$(awk -v h="$hit" -v f="$found" 'BEGIN { printf "%.2f", (h / f) * 100 }') -meets_threshold=$(awk -v p="$percent" -v m="$min_percent" 'BEGIN { print (p + 0 >= m + 0) ? 1 : 0 }') - -echo "Line coverage: $hit/$found ($percent%), threshold: $min_percent%" - -if [[ "$meets_threshold" -ne 1 ]]; then - echo "Error: coverage $percent% is below the required $min_percent%." >&2 - exit 1 -fi +main "$@" diff --git a/scripts/check_coverage.sh b/scripts/check_coverage.sh new file mode 100755 index 0000000..7555b42 --- /dev/null +++ b/scripts/check_coverage.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# ============================================================================ +# Fail if lcov line coverage drops below a threshold. +# +# THE shared coverage gate for every package in this repo. Three byte-identical +# copies of this file used to live under design_system/, github_device_auth/ +# and sync_settings_ui/scripts/ -- each claiming in its own header to exist for +# one particular package. Those are now thin delegates to this file, for the +# same reason check_file_length.sh is shared: a per-package copy is how one +# package's idea of "covered" drifts from every other package's. +# +# Note for pure-Dart packages: `dart test --coverage` writes raw VM JSON, NOT +# lcov. Run coverage:format_coverage before this gate, or it will happily pass +# against a stale lcov.info from a previous run. crdt_sync_dart really did sit +# at 96% for three weeks that way while believing it was at 100%. +# +# Usage: check_coverage.sh +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME + +usage() { + echo "Usage: $SCRIPT_NAME " + echo " Fails when line coverage in lcov.info is below min_percent." + exit 0 +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage +fi + +if [[ $# -ne 2 ]]; then + usage +fi + +lcov_file="$1" +min_percent="$2" + +if [[ ! -f "$lcov_file" ]]; then + echo "Error: $lcov_file not found. Run 'flutter test --coverage' first." >&2 + exit 1 +fi + +# Sum LH (lines hit) and LF (lines found) across every SF (source file) +# block rather than trusting a single top-level total -- lcov.info has no +# such total line, only per-file LH/LF pairs. +read -r hit found <<< "$(awk ' + /^LH:/ { hit += substr($0, 4) } + /^LF:/ { found += substr($0, 4) } + END { print hit, found } +' "$lcov_file")" + +if [[ "$found" -eq 0 ]]; then + echo "Error: $lcov_file reports zero coverable lines." >&2 + exit 1 +fi + +percent=$(awk -v h="$hit" -v f="$found" 'BEGIN { printf "%.2f", (h / f) * 100 }') +meets_threshold=$(awk -v p="$percent" -v m="$min_percent" 'BEGIN { print (p + 0 >= m + 0) ? 1 : 0 }') + +echo "Line coverage: $hit/$found ($percent%), threshold: $min_percent%" + +if [[ "$meets_threshold" -ne 1 ]]; then + echo "Error: coverage $percent% is below the required $min_percent%." >&2 + exit 1 +fi diff --git a/sync_settings_ui/scripts/check_coverage.sh b/sync_settings_ui/scripts/check_coverage.sh index 2c0ee6f..40d0933 100755 --- a/sync_settings_ui/scripts/check_coverage.sh +++ b/sync_settings_ui/scripts/check_coverage.sh @@ -3,61 +3,30 @@ # ============================================================================ # Fail if lcov line coverage drops below a threshold. # -# No Dart package in this repo has coverage enforced in CI yet -- crdt_sync_dart -# sits at 100% by discipline, not a gate. This script is that gate for -# sync_settings_ui, which ships as a 100%-covered shared UI package by -# requirement, not by convention. +# Thin delegate to the shared gate in ~/utils/scripts, which owns the parsing +# and the threshold comparison. This file used to be a byte-identical copy of +# that logic; three such copies existed, which is how one package's idea of +# "covered" drifts from every other package's. # # Usage: check_coverage.sh # ============================================================================ set -euo pipefail -SCRIPT_NAME="$(basename "$0")" -readonly SCRIPT_NAME +# Repo-relative, resolved from this script's own location: an absolute +# /home/... path exits 127 on an Actions runner. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SHARED_GATE="${SCRIPT_DIR}/../../scripts/check_coverage.sh" -usage() { - echo "Usage: $SCRIPT_NAME " - echo " Fails when line coverage in lcov.info is below min_percent." - exit 0 +main() { + if [[ ! -f "$SHARED_GATE" ]]; then + echo "Error: shared coverage gate not found at $SHARED_GATE" >&2 + echo " It lives in the utils repo at scripts/check_coverage.sh." >&2 + exit 1 + fi + + exec bash "$SHARED_GATE" "$@" } -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage -fi - -if [[ $# -ne 2 ]]; then - usage -fi - -lcov_file="$1" -min_percent="$2" - -if [[ ! -f "$lcov_file" ]]; then - echo "Error: $lcov_file not found. Run 'flutter test --coverage' first." >&2 - exit 1 -fi - -# Sum LH (lines hit) and LF (lines found) across every SF (source file) -# block rather than trusting a single top-level total -- lcov.info has no -# such total line, only per-file LH/LF pairs. -read -r hit found <<< "$(awk ' - /^LH:/ { hit += substr($0, 4) } - /^LF:/ { found += substr($0, 4) } - END { print hit, found } -' "$lcov_file")" - -if [[ "$found" -eq 0 ]]; then - echo "Error: $lcov_file reports zero coverable lines." >&2 - exit 1 -fi - -percent=$(awk -v h="$hit" -v f="$found" 'BEGIN { printf "%.2f", (h / f) * 100 }') -meets_threshold=$(awk -v p="$percent" -v m="$min_percent" 'BEGIN { print (p + 0 >= m + 0) ? 1 : 0 }') - -echo "Line coverage: $hit/$found ($percent%), threshold: $min_percent%" - -if [[ "$meets_threshold" -ne 1 ]]; then - echo "Error: coverage $percent% is below the required $min_percent%." >&2 - exit 1 -fi +main "$@"