diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 67bc884a..a2f049a3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,12 +15,13 @@ repos: hooks: - id: dart-format name: dart format - entry: dart format --set-exit-if-changed - language: system + entry: scripts/dart-format.sh --set-exit-if-changed + language: script types: [dart] exclude: ^packages/soliplex_client/lib/src/schema/ - id: flutter-analyze name: flutter analyze - entry: flutter analyze --fatal-infos - language: system + entry: scripts/flutter-analyze.sh --fatal-infos + language: script + types: [dart] pass_filenames: false diff --git a/lib/core/logging/loggers.dart b/lib/core/logging/loggers.dart index 604ad43c..571e45bd 100644 --- a/lib/core/logging/loggers.dart +++ b/lib/core/logging/loggers.dart @@ -43,4 +43,7 @@ abstract final class Loggers { /// General UI events. static final ui = LogManager.instance.getLogger('UI'); + + /// Riverpod provider state transitions. + static final state = LogManager.instance.getLogger('State'); } diff --git a/lib/core/logging/logging_provider_observer.dart b/lib/core/logging/logging_provider_observer.dart new file mode 100644 index 00000000..1260da9b --- /dev/null +++ b/lib/core/logging/logging_provider_observer.dart @@ -0,0 +1,27 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:soliplex_frontend/core/logging/loggers.dart'; + +/// Logs all Riverpod provider state changes to soliplex_logging. +/// +/// This gives LLMs (via `get_app_logs`) and integration tests (via +/// `TestLogHarness` / `MemorySink`) full visibility into provider state +/// transitions at debug level. +/// +/// Wire into `ProviderScope` at app startup: +/// ```dart +/// ProviderScope( +/// observers: [LoggingProviderObserver()], +/// child: const SoliplexApp(), +/// ) +/// ``` +base class LoggingProviderObserver extends ProviderObserver { + @override + void didUpdateProvider( + ProviderObserverContext context, + Object? previousValue, + Object? newValue, + ) { + final name = context.provider.name ?? '${context.provider.runtimeType}'; + Loggers.state.debug('$name: $newValue'); + } +} diff --git a/lib/run_soliplex_app.dart b/lib/run_soliplex_app.dart index cdf0f05e..86a6a22d 100644 --- a/lib/run_soliplex_app.dart +++ b/lib/run_soliplex_app.dart @@ -9,6 +9,7 @@ import 'package:soliplex_frontend/core/auth/auth_storage.dart'; import 'package:soliplex_frontend/core/auth/web_auth_callback.dart'; import 'package:soliplex_frontend/core/logging/loggers.dart'; import 'package:soliplex_frontend/core/logging/logging_provider.dart'; +import 'package:soliplex_frontend/core/logging/logging_provider_observer.dart'; import 'package:soliplex_frontend/core/models/soliplex_config.dart'; import 'package:soliplex_frontend/core/providers/config_provider.dart'; import 'package:soliplex_frontend/core/providers/shell_config_provider.dart'; @@ -104,6 +105,7 @@ Future runSoliplexApp({ runApp( ProviderScope( + observers: [LoggingProviderObserver()], overrides: [ // Inject shell configuration via ProviderScope (no global state) shellConfigProvider.overrideWithValue(config), diff --git a/scripts/dart-format.sh b/scripts/dart-format.sh new file mode 100755 index 00000000..f3800498 --- /dev/null +++ b/scripts/dart-format.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Workaround for Dart SDK resolution in git worktrees. +# See flutter-analyze.sh for details. +unset GIT_DIR +unset GIT_WORK_TREE +exec dart format "$@" diff --git a/scripts/flutter-analyze.sh b/scripts/flutter-analyze.sh new file mode 100755 index 00000000..b7c84178 --- /dev/null +++ b/scripts/flutter-analyze.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Workaround for Flutter SDK version resolution in git worktrees. +# Git sets GIT_DIR in worktrees, which confuses Flutter's version detection +# (reports 0.0.0-unknown). Unsetting it restores correct behavior. +unset GIT_DIR +unset GIT_WORK_TREE +exec flutter analyze "$@" diff --git a/test/core/logging/logging_provider_observer_test.dart b/test/core/logging/logging_provider_observer_test.dart new file mode 100644 index 00000000..d8b7a6c1 --- /dev/null +++ b/test/core/logging/logging_provider_observer_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soliplex_frontend/core/logging/logging_provider_observer.dart'; +import 'package:soliplex_logging/soliplex_logging.dart'; + +final _counter = NotifierProvider<_Counter, int>( + _Counter.new, + name: 'counter', +); + +class _Counter extends Notifier { + @override + int build() => 0; + + void increment() => state = state + 1; +} + +final _unnamed = NotifierProvider<_Unnamed, int>(_Unnamed.new); + +class _Unnamed extends Notifier { + @override + int build() => 0; + + void increment() => state = state + 1; +} + +void main() { + late MemorySink sink; + + setUp(() { + LogManager.instance.reset(); + sink = MemorySink(); + LogManager.instance + ..minimumLevel = LogLevel.debug + ..addSink(sink); + }); + + tearDown(() { + LogManager.instance + ..removeSink(sink) + ..reset(); + sink.close(); + }); + + group('LoggingProviderObserver', () { + test('logs state changes to State logger', () { + final container = ProviderContainer( + observers: [LoggingProviderObserver()], + ); + addTearDown(container.dispose); + + // Initialize and update. + container.read(_counter.notifier).increment(); + + final stateRecords = + sink.records.where((r) => r.loggerName == 'State').toList(); + expect(stateRecords, isNotEmpty); + expect(stateRecords.last.message, contains('counter')); + expect(stateRecords.last.message, contains('1')); + expect(stateRecords.last.level, LogLevel.debug); + }); + + test('uses runtimeType when provider has no name', () { + final container = ProviderContainer( + observers: [LoggingProviderObserver()], + ); + addTearDown(container.dispose); + + container.read(_unnamed.notifier).increment(); + + final stateRecords = + sink.records.where((r) => r.loggerName == 'State').toList(); + expect(stateRecords, isNotEmpty); + expect(stateRecords.last.message, contains('1')); + }); + }); +}