crdt_sync_dart: latest stable + exact pins, 100% coverage, CI for both crdt_sync packages
Some checks are pending
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
design_system tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
github_device_auth tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-28 12:32:24 +02:00
parent 723ac95443
commit 922fb36208
13 changed files with 681 additions and 151 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 <lcov.info> <min_percent>
# ============================================================================
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 "$@"

View File

@ -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<String?> read() async => _contents;
@override
Future<void> 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<RemoteSyncError>());
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"}');
});
});
}

View File

@ -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)',
);
});
});
}

View File

@ -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<String, Field>? 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)',
);
});
});
}

View File

@ -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 <lcov.info> <min_percent>
# ============================================================================
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 "$@"

View File

@ -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');
});
});
}

View File

@ -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 <lcov.info> <min_percent>
# ============================================================================
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 <lcov.info> <min_percent>"
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 "$@"

View File

@ -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 <lcov.info> <min_percent>
# ============================================================================
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 <lcov.info> <min_percent>"
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 "$@"

70
scripts/check_coverage.sh Executable file
View File

@ -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 <lcov.info> <min_percent>
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
usage() {
echo "Usage: $SCRIPT_NAME <lcov.info> <min_percent>"
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

View File

@ -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 <lcov.info> <min_percent>
# ============================================================================
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 <lcov.info> <min_percent>"
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 "$@"