diff --git a/.cursor/rules/dart-test-conventions.mdc b/.cursor/rules/dart-test-conventions.mdc new file mode 100644 index 0000000..6f3161d --- /dev/null +++ b/.cursor/rules/dart-test-conventions.mdc @@ -0,0 +1,93 @@ +--- +description: Given-when-then naming and structure for Dart tests in this repo +globs: packages/**/test/**/*_test.dart +alwaysApply: false +--- + +# Dart test conventions (Given – when – then) + +Use the **Given – when – then** pattern for test names and structure. +This matches existing tests such as `std_out_logger_test.dart`, `progress_test.dart`, and `configuration_test.dart`. + +## Structure + +| Part | Role | Where it lives | +|------|------|----------------| +| **Given** | Preconditions and shared setup | Top-level `group('Given …')` | +| **when** | The action or trigger | Nested `group('when …')` and/or start of `test` name | +| **then** | Expected outcome | End of `test` name; verified with `expect` in the body | + +Prefer **nested groups** when several tests share the same Given or when: + +```dart +group('Given a StdOutLogger with info log level', () { + final logger = StdOutLogger(LogLevel.info); + + group('when calling progressStream', () { + test( + 'when stream has a single event ' + 'then the event is returned and progress is marked successful', + () async { + // arrange/act in body, assert with expect + }); + }); +}); +``` + +When a parent `group` already states **when**, child tests may use only **then**. + +Prefer to have multiple **then** tests for multiple outcome verifications, +there should be one test method per expected outcome condition. +This means that for each **then** test, there should only be one or very few +expect calls, which are directly related to the same outcome verification: + +```dart +group('when running with no command', () { + setUp(() async => await runner.run([])); + + test('then usage message is logged to info.', () async { + expect(infos, hasLength(1)); + expect(infos[0], contains('usage')); + }); + + test('then error message is logged to errors.', () async { + expect(errors, hasLength(1)); + expect(errors[0], contains('erroneous usage')); + }); + + test('then no warning message is logged', () async { + expect(errors, isEmpty); + }); +}); +``` + +For isolated cases without shared setup, put the full pattern in one test name: + +```dart +test( + 'Given confirm prompt' + ' when providing valid input "yes"' + ' then should return true', () async { + // ... +}); +``` + +## Naming rules + +- Start Given groups with `Given …` (e.g. `Given a StdOutLogger with default settings`). +- Use lowercase **when** and **then** in names. +- Describe behaviour, not implementation (e.g. `then progress is marked failed`, not `then fail() is called`). +- Split long names across adjacent string literals; do not use one unreadable line. + +## Test body + +- Put shared fixtures, loggers, and runners in the **Given** group scope. +- Run the action under test inside the test body (or in a **when** group's `setUp`). +- Use `expect` / `expectLater` for **then** assertions only—do not restate expectations in the name alone. +- For stdout/stderr tests, use `collectOutput` from `test_utils/io_helper.dart`. + +## Examples to follow + +- `packages/cli_tools/test/std_out_logger_test.dart` — Given group + `when … then …` tests +- `packages/cli_tools/test/logger/progress_test.dart` — nested Given / when groups +- `packages/config/test/config/configuration_test.dart` — nested Given / when / then groups diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26142f7..62f33da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,13 @@ jobs: strategy: fail-fast: false matrix: - dart: [3.3, 3.9] - package: [cli_tools, config] + dart: [3.6, 3.12] + package: + - cli_tools + - config + - isolated_object + - serverpod_logging + - serverpod_logging_cli runs-on: ubuntu-latest defaults: run: @@ -34,16 +39,21 @@ jobs: strategy: fail-fast: false matrix: - dart: [3.3, 3.9] - package: [cli_tools, config] + dart: [3.6, 3.12] + package: + - cli_tools + - config + - isolated_object + - serverpod_logging + - serverpod_logging_cli platform: [ubuntu-latest] include: - package: cli_tools platform: windows-latest - dart: 3.3 + dart: 3.6 - package: cli_tools platform: macos-latest - dart: 3.3 + dart: 3.6 runs-on: ${{ matrix.platform }} defaults: run: diff --git a/.github/workflows/publish-isolated_object.yaml b/.github/workflows/publish-isolated_object.yaml new file mode 100644 index 0000000..d55bb09 --- /dev/null +++ b/.github/workflows/publish-isolated_object.yaml @@ -0,0 +1,16 @@ +name: Publish isolated_object package + +on: + push: + tags: + # Matches tags like isolated_object-v1.2.3 and isolated_object-v1.2.3-pre.1 + - 'isolated_object-v[0-9]+.[0-9]+.[0-9]+' + - 'isolated_object-v[0-9]+.[0-9]+.[0-9]+-*' + +jobs: + publish: + permissions: + id-token: write + uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 + with: + working-directory: packages/isolated_object diff --git a/.github/workflows/publish-serverpod_logging.yaml b/.github/workflows/publish-serverpod_logging.yaml new file mode 100644 index 0000000..6afda0b --- /dev/null +++ b/.github/workflows/publish-serverpod_logging.yaml @@ -0,0 +1,16 @@ +name: Publish serverpod_logging package + +on: + push: + tags: + # Matches tags like serverpod_logging-v1.2.3 and serverpod_logging-v1.2.3-pre.1 + - 'serverpod_logging-v[0-9]+.[0-9]+.[0-9]+' + - 'serverpod_logging-v[0-9]+.[0-9]+.[0-9]+-*' + +jobs: + publish: + permissions: + id-token: write + uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 + with: + working-directory: packages/serverpod_logging diff --git a/.github/workflows/publish-serverpod_logging_cli.yaml b/.github/workflows/publish-serverpod_logging_cli.yaml new file mode 100644 index 0000000..fae0c5b --- /dev/null +++ b/.github/workflows/publish-serverpod_logging_cli.yaml @@ -0,0 +1,16 @@ +name: Publish serverpod_logging_cli package + +on: + push: + tags: + # Matches tags like serverpod_logging_cli-v1.2.3 and serverpod_logging_cli-v1.2.3-pre.1 + - 'serverpod_logging_cli-v[0-9]+.[0-9]+.[0-9]+' + - 'serverpod_logging_cli-v[0-9]+.[0-9]+.[0-9]+-*' + +jobs: + publish: + permissions: + id-token: write + uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 + with: + working-directory: packages/serverpod_logging_cli diff --git a/.gitignore b/.gitignore index 996f812..c95388e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .idea/ *.iml +# Workspace of library packages; the root lockfile is regenerated by CI. pubspec.lock diff --git a/melos.yaml b/melos.yaml index 9f54402..b9f02f8 100644 --- a/melos.yaml +++ b/melos.yaml @@ -1,3 +1,4 @@ +# This file is used for melos 6.3.3, but deprecated for users of melos 7.x. name: cli_tools_repo packages: diff --git a/packages/cli_tools/CHANGELOG.md b/packages/cli_tools/CHANGELOG.md index 1cd3c89..2162ecb 100644 --- a/packages/cli_tools/CHANGELOG.md +++ b/packages/cli_tools/CHANGELOG.md @@ -1,3 +1,23 @@ +## 0.13.2 + +- **FIX**(cli_tools): Prevent analytics send failures from crashing the host process. ([#125](https://github.com/serverpod/cli_tools/pull/125)). ([2dcce5b5](https://github.com/serverpod/cli_tools/commit/2dcce5b588caa3df8db8943a48de2036ddbc836e)) + +## 0.13.1 + + - **FEAT**(cli_tools): Make progress display more configurable ([#113](https://github.com/serverpod/cli_tools/issues/113)). ([8df886e5](https://github.com/serverpod/cli_tools/commit/8df886e59db5f59bc0ab8780803e1a977adf4017)) + +## 0.13.0 + + - **BREAKING** **FEAT**(cli_tools): Adds a `flush` method to the `Analytics` base class to ensure all analytics events are sent before the program exits ([#107](https://github.com/serverpod/cli_tools/issues/107)). ([87b037e2](https://github.com/serverpod/cli_tools/commit/87b037e2b8172252e399cb103d103054ede64267)) + - FIX(cli_tools): Make VoidLogger.progressStream throw same exception as StdOutLogger does on empty stream. ([861fe7f0](https://github.com/serverpod/cli_tools/commit/861fe7f025e973fb510d24f9750c8246eb25926e)) + - FIX(cli_tools): Fixed regression in progress spinner on unsuccessful runner. ([861fe7f0](https://github.com/serverpod/cli_tools/commit/861fe7f025e973fb510d24f9750c8246eb25926e)) + +## 0.12.0 + + - Bumped minimum Dart SDK version to 3.6 + + - **FEAT**(cli_tools): Added Logger.progressStream method ([#108](https://github.com/serverpod/cli_tools/issues/108)). ([fac111a3](https://github.com/serverpod/cli_tools/commit/fac111a3f4583e2856fc399496a029d501b9821c)) + ## 0.11.0 - **FEAT**: Make library name configurable in PostHog analytics. (#101). diff --git a/packages/cli_tools/lib/src/analytics/analytics.dart b/packages/cli_tools/lib/src/analytics/analytics.dart index d3367af..6633025 100644 --- a/packages/cli_tools/lib/src/analytics/analytics.dart +++ b/packages/cli_tools/lib/src/analytics/analytics.dart @@ -1,16 +1,49 @@ -/// Interface for analytics services. -abstract interface class Analytics { +/// Base class for analytics services. +abstract class Analytics { + final _pendingTracks = >{}; + /// Clean up resources. - void cleanUp(); + void cleanUp() {} + + /// Flush pending analytics events. + Future flush() async { + while (_pendingTracks.isNotEmpty) { + await Future.wait([ + for (final pendingTrack in _pendingTracks) + pendingTrack.catchError((final _) {}), + ]); + } + } /// Track an event. void track({ required final String event, final Map properties = const {}, + }) { + late final Future pendingTrack; + pendingTrack = _quietSendEvent(event: event, properties: properties) + .whenComplete(() => _pendingTracks.remove(pendingTrack)); + _pendingTracks.add(pendingTrack); + } + + /// Sends an event, swallowing any errors. + Future _quietSendEvent({ + required final String event, + required final Map properties, + }) async { + try { + await sendEvent(event: event, properties: properties); + } catch (_) {} + } + + /// Send an event to the analytics service. + Future sendEvent({ + required final String event, + final Map properties = const {}, }); } -class CompoundAnalytics implements Analytics { +class CompoundAnalytics extends Analytics { final List providers; CompoundAnalytics(this.providers); @@ -23,10 +56,18 @@ class CompoundAnalytics implements Analytics { } @override - void track({ + Future flush() async { + await Future.wait([ + for (final provider in providers) + Future.sync(provider.flush).catchError((final _) {}), + ]); + } + + @override + Future sendEvent({ required final String event, final Map properties = const {}, - }) { + }) async { for (final provider in providers) { provider.track( event: event, diff --git a/packages/cli_tools/lib/src/analytics/mixpanel.dart b/packages/cli_tools/lib/src/analytics/mixpanel.dart index b1040be..c3ff0fc 100644 --- a/packages/cli_tools/lib/src/analytics/mixpanel.dart +++ b/packages/cli_tools/lib/src/analytics/mixpanel.dart @@ -8,7 +8,7 @@ import 'analytics.dart'; import 'helpers.dart'; /// Analytics service for MixPanel. -class MixPanelAnalytics implements Analytics { +class MixPanelAnalytics extends Analytics { static const _defaultEndpoint = 'https://api.mixpanel.com/track'; static const _defaultTimeout = Duration(seconds: 2); @@ -52,13 +52,10 @@ class MixPanelAnalytics implements Analytics { } @override - void cleanUp() {} - - @override - void track({ + Future sendEvent({ required final String event, final Map properties = const {}, - }) { + }) async { final payload = jsonEncode({ 'event': event, 'properties': { @@ -71,21 +68,13 @@ class MixPanelAnalytics implements Analytics { }, }); - _quietPost(payload); - } - - Future _quietPost(final String payload) async { - try { - await http.post( - _endpoint, - body: 'data=$payload', - headers: { - 'Accept': 'text/plain', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - ).timeout(_timeout); - } catch (e) { - return; - } + await http.post( + _endpoint, + body: 'data=$payload', + headers: { + 'Accept': 'text/plain', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ).timeout(_timeout); } } diff --git a/packages/cli_tools/lib/src/analytics/posthog.dart b/packages/cli_tools/lib/src/analytics/posthog.dart index b645168..339ccad 100644 --- a/packages/cli_tools/lib/src/analytics/posthog.dart +++ b/packages/cli_tools/lib/src/analytics/posthog.dart @@ -8,7 +8,7 @@ import 'analytics.dart'; import 'helpers.dart'; /// Analytics service for PostHog. -class PostHogAnalytics implements Analytics { +class PostHogAnalytics extends Analytics { static const _defaultHost = 'https://eu.i.posthog.com'; static const _defaultTimeout = Duration(seconds: 2); static const _defaultLibName = 'cli_tools'; @@ -36,13 +36,10 @@ class PostHogAnalytics implements Analytics { _libName = libName; @override - void cleanUp() {} - - @override - void track({ + Future sendEvent({ required final String event, final Map properties = const {}, - }) { + }) async { final eventData = { 'api_key': _projectApiKey, 'event': event, @@ -57,20 +54,12 @@ class PostHogAnalytics implements Analytics { }, }; - _quietPost(eventData); - } - - Future _quietPost(final Map eventData) async { - try { - await http - .post( - _endpoint, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(eventData), - ) - .timeout(_timeout); - } catch (e) { - return; - } + await http + .post( + _endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(eventData), + ) + .timeout(_timeout); } } diff --git a/packages/cli_tools/lib/src/logger/helpers/progress.dart b/packages/cli_tools/lib/src/logger/helpers/progress.dart index 5b5561a..611e206 100644 --- a/packages/cli_tools/lib/src/logger/helpers/progress.dart +++ b/packages/cli_tools/lib/src/logger/helpers/progress.dart @@ -24,10 +24,14 @@ import 'ansi_style.dart'; /// {@endtemplate} class ProgressOptions { /// {@macro progress_options} - const ProgressOptions({this.animation = const ProgressAnimation()}); + const ProgressOptions( + {this.animation = const ProgressAnimation(), this.ellipsis = '...'}); /// The progress animation configuration. final ProgressAnimation animation; + + /// The ellipsis to append to the progress message while in progress. + final String ellipsis; } /// {@template progress_animation} @@ -79,7 +83,7 @@ class Progress { final char = frames.isEmpty ? '' : frames.first; final prefix = char.isEmpty ? char : '${AnsiStyle.lightGreen.wrap(char)} '; - _write('$prefix$_message...\n'); + _write('$prefix$_message${_options.ellipsis}\n'); return; } @@ -157,7 +161,7 @@ class Progress { final char = frames.isEmpty ? '' : frames[_index % frames.length]; final prefix = char.isEmpty ? char : '${AnsiStyle.lightGreen.wrap(char)} '; - _write('$_clearLine$prefix$_message... $_time'); + _write('$_clearLine$prefix$_message${_options.ellipsis} $_time'); } void _write(final String object) { diff --git a/packages/cli_tools/lib/src/logger/logger.dart b/packages/cli_tools/lib/src/logger/logger.dart index 44a54d2..d5bfcd6 100644 --- a/packages/cli_tools/lib/src/logger/logger.dart +++ b/packages/cli_tools/lib/src/logger/logger.dart @@ -48,17 +48,45 @@ abstract class Logger { final LogType type, }); - /// Display a progress message on [LogLevel.info] while running [runner] - /// function. + /// Display a progress spinner and message on [LogLevel.info] while running + /// [runner] function. /// - /// Uses return value from [runner] to print set progress success status. - /// Returns return value from [runner]. + /// Upon success, change the message to the [successMessage] if provided. + /// + /// Uses the return value from [runner] to print success or failure status. + /// Returns the return value from [runner]. Future progress( final String message, final Future Function() runner, { + final String? successMessage, final bool newParagraph, }); + /// Display a progress spinner and a stream of messages on [LogLevel.info]. + /// When the stream is ended, the progress spinner is completed, with a + /// failure status if the stream ends with an error. + /// + /// First [initialMessage] is displayed, then the message is updated with each + /// event. A custom [toMessage] can be provided to convert each event to an + /// appropriate message. If not provided, [toString] is called on the event. + /// + /// If [isSuccess] is provided, it is used to determine if the progress should + /// be marked as successful or failed. If not provided, the progress is marked + /// as successful if the stream ends without an error. + /// + /// For streams with a single event this method behaves like [progress]. + /// + /// Returns the last event from the stream. + /// If the stream ends with an error, that error is rethrown. + /// If the stream is empty, [StateError] is thrown. + Future progressStream( + final String initialMessage, + final Stream stream, { + final String Function(T)? toMessage, + final bool Function(T)? isSuccess, + final bool newParagraph = false, + }); + /// Directly write a [message] to the output. /// Generally the other methods should be used instead of this. But this /// method can be used for more direct control of the output. diff --git a/packages/cli_tools/lib/src/logger/loggers/std_out_logger.dart b/packages/cli_tools/lib/src/logger/loggers/std_out_logger.dart index 60fbe48..aa46495 100644 --- a/packages/cli_tools/lib/src/logger/loggers/std_out_logger.dart +++ b/packages/cli_tools/lib/src/logger/loggers/std_out_logger.dart @@ -114,26 +114,61 @@ class StdOutLogger extends Logger { Future progress( final String message, final Future Function() runner, { + final String? successMessage, + final bool newParagraph = false, + }) async { + return await progressStream( + '$message...', + Stream.fromFuture(runner()), + toMessage: (final r) => r ? successMessage ?? message : message, + isSuccess: (final result) => result, + newParagraph: newParagraph, + ); + } + + @override + Future progressStream( + final String initialMessage, + final Stream stream, { + final String Function(T)? toMessage, + final bool Function(T)? isSuccess, final bool newParagraph = false, }) async { if (logLevel.index > LogLevel.info.index) { - return await runner(); + return stream.last; } _stopAnimationInProgress(); - // Write an empty line before the progress message if a new paragraph is - // requested. + // First write an empty line if a new paragraph is requested. if (newParagraph) { write('', LogLevel.info, newParagraph: false, newLine: true); } - final progress = Progress(message, stdout); + final progress = Progress(initialMessage, stdout, + options: const ProgressOptions(ellipsis: '')); trackedAnimationInProgress = progress; - final bool success = await runner(); - trackedAnimationInProgress = null; - success ? progress.complete() : progress.fail(); - return success; + try { + bool hasEvent = false; + T? finalEvent; + await for (final event in stream) { + hasEvent = true; + finalEvent = event; + final message = toMessage?.call(event) ?? event.toString(); + progress.update(message); + } + if (!hasEvent || finalEvent is! T) { + throw StateError('No events in stream'); + } + final success = isSuccess?.call(finalEvent) ?? true; + success ? progress.complete() : progress.fail(); + return finalEvent; + } catch (error) { + progress.fail(); + rethrow; + } finally { + trackedAnimationInProgress = null; + } } @override diff --git a/packages/cli_tools/lib/src/logger/loggers/void_logger.dart b/packages/cli_tools/lib/src/logger/loggers/void_logger.dart index e915e3f..cd63b74 100644 --- a/packages/cli_tools/lib/src/logger/loggers/void_logger.dart +++ b/packages/cli_tools/lib/src/logger/loggers/void_logger.dart @@ -55,11 +55,27 @@ class VoidLogger extends Logger { Future progress( final String message, final Future Function() runner, { + final String? successMessage, final bool newParagraph = true, }) async { return await runner(); } + @override + Future progressStream( + final String initialMessage, + final Stream stream, { + final String Function(T)? toMessage, + final bool Function(T)? isSuccess, + final bool newParagraph = false, + }) async { + try { + return await stream.last; + } on StateError catch (_) { + throw StateError('No events in stream'); + } + } + @override void write( final String message, diff --git a/packages/cli_tools/pubspec.yaml b/packages/cli_tools/pubspec.yaml index c79fe15..16e4e25 100644 --- a/packages/cli_tools/pubspec.yaml +++ b/packages/cli_tools/pubspec.yaml @@ -1,5 +1,5 @@ name: cli_tools -version: 0.11.0 +version: 0.13.2 description: A collection of tools for building great command-line interfaces. repository: https://github.com/serverpod/cli_tools homepage: https://serverpod.dev @@ -11,14 +11,16 @@ topics: - command-line - dart +resolution: workspace + environment: - sdk: ^3.3.0 + sdk: ^3.6.0 dependencies: args: ^2.7.0 async: ^2.10.0 ci: ^0.1.0 - config: ^0.8.4 + config: ^0.9.0 http: '>=0.13.0 <2.0.0' path: ^1.8.2 pub_api_client: '>=2.4.0 <4.0.0' diff --git a/packages/cli_tools/pubspec_overrides.yaml b/packages/cli_tools/pubspec_overrides.yaml deleted file mode 100644 index 598045e..0000000 --- a/packages/cli_tools/pubspec_overrides.yaml +++ /dev/null @@ -1,3 +0,0 @@ -dependency_overrides: - config: - path: ../config diff --git a/packages/cli_tools/test/analytics/analytics_test.dart b/packages/cli_tools/test/analytics/analytics_test.dart new file mode 100644 index 0000000..0a6b9e2 --- /dev/null +++ b/packages/cli_tools/test/analytics/analytics_test.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:cli_tools/analytics.dart'; +import 'package:test/test.dart'; + +import '../test_utils/test_utils.dart' show flushEventQueue; + +void main() { + test( + 'Given analytics with a pending event, ' + 'when flushing before the event send completes, ' + 'then flush waits until the event send completes.', + () async { + final analytics = PendingAnalytics(); + analytics.track(event: 'test'); + + var isFlushed = false; + final flush = analytics.flush().then((final _) { + isFlushed = true; + }); + + await flushEventQueue(); + expect(isFlushed, isFalse); + + analytics.completers.single.complete(); + await flush; + expect(isFlushed, isTrue); + }, + ); + + test( + 'Given analytics with a pending event, ' + 'when the event send fails while flushing, ' + 'then flush waits for the event and ignores the send failure.', + () async { + final analytics = PendingAnalytics(); + + analytics.track(event: 'test'); + + final flush = expectLater(analytics.flush(), completes); + await flushEventQueue(); + + analytics.completers.single.completeError( + StateError('Failed to send event.'), + ); + + await expectLater(flush, completes); + }, + ); + + test( + 'Given an analytics implementation whose sendEvent returns a future with a non-void reified type, ' + 'when the event send fails, ' + 'then the failure is ignored and flush completes.', + () async { + final analytics = NonVoidFutureAnalytics(); + + analytics.track(event: 'test'); + + final flush = expectLater(analytics.flush(), completes); + await flushEventQueue(); + + analytics.completers.single.completeError( + StateError('Failed to send event.'), + ); + + await expectLater(flush, completes); + }, + ); + + test( + 'Given compound analytics with a provider that fails to flush, ' + 'when flushing the compound analytics, ' + 'then all providers are flushed and the failure is ignored.', + () async { + final failingProvider = FailingFlushAnalytics(); + final recordingProvider = RecordingFlushAnalytics(); + final analytics = CompoundAnalytics([failingProvider, recordingProvider]); + + await expectLater(analytics.flush(), completes); + + expect(failingProvider.didFlush, isTrue); + expect(recordingProvider.didFlush, isTrue); + }, + ); +} + +class PendingAnalytics extends Analytics { + final completers = >[]; + + @override + Future sendEvent({ + required final String event, + final Map properties = const {}, + }) { + final completer = Completer(); + completers.add(completer); + return completer.future; + } +} + +/// Returns futures from [sendEvent] whose reified type is not `Future`. +/// +/// This an example of how NOT to do it. In general don't try to be smart and +/// skip the async on a method returning `Future`. This is just one pitfall +/// of many. +class NonVoidFutureAnalytics extends Analytics { + final completers = >[]; + + @override + Future sendEvent /* no async */ ({ + required final String event, + final Map properties = const {}, + }) { + // note the mismatch Future vs Future + final completer = Completer(); + completers.add(completer); + return completer.future; // no await + } +} + +class FailingFlushAnalytics extends Analytics { + var didFlush = false; + + @override + Future flush() { + didFlush = true; + throw StateError('Failed to flush events.'); + } + + @override + Future sendEvent({ + required final String event, + final Map properties = const {}, + }) async {} +} + +class RecordingFlushAnalytics extends Analytics { + var didFlush = false; + + @override + Future flush() async { + didFlush = true; + } + + @override + Future sendEvent({ + required final String event, + final Map properties = const {}, + }) async {} +} diff --git a/packages/cli_tools/test/logger/progress_test.dart b/packages/cli_tools/test/logger/progress_test.dart new file mode 100644 index 0000000..69ff8fd --- /dev/null +++ b/packages/cli_tools/test/logger/progress_test.dart @@ -0,0 +1,250 @@ +import 'package:cli_tools/cli_tools.dart'; +import 'package:test/test.dart'; + +import '../test_utils/io_helper.dart'; + +void main() { + group('Given a StdOutLogger with info log level', () { + final logger = StdOutLogger(LogLevel.info); + + group('when calling progress', () { + test( + 'when runner completes successfully ' + 'then runner result is returned and progress is marked successful', + () async { + bool? result; + + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + result = await logger.progress('Working', () async => true); + }); + + expect(result, isTrue); + expect(stdout.output, contains('Working')); + expect(stdout.output, contains('✓')); + expect(stderr.output, isEmpty); + expect(logger.trackedAnimationInProgress, isNull); + }); + + test( + 'when runner completes unsuccessfully ' + 'then runner result is returned and progress is marked unsuccessful', + () async { + bool? result; + + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + result = await logger.progress('Working', () async => false); + }); + + expect(result, isFalse); + expect(stdout.output, contains('Working')); + expect(stdout.output, contains('✗')); + expect(stderr.output, isEmpty); + expect(logger.trackedAnimationInProgress, isNull); + }); + + test( + 'when runner throws an exception ' + 'then progress is marked failed and the exception is rethrown', + () async { + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + await expectLater( + logger.progress('Working', () async => throw Exception('failed')), + throwsA(isA()), + ); + }); + + expect(stdout.output, contains('Working')); + expect(stdout.output, contains('✗')); + expect(stderr.output, isEmpty); + expect(logger.trackedAnimationInProgress, isNull); + }); + }); + + group('when calling progressStream', () { + test( + 'when stream has no events ' + 'then StateError is thrown and progress is marked failed', () async { + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + await expectLater( + logger.progressStream('Starting', const Stream.empty()), + throwsA( + isA().having( + (final e) => e.message, + 'message', + 'No events in stream', + ), + ), + ); + }); + + expect(stdout.output, contains('Starting')); + expect(stdout.output, contains('✗')); + expect(stderr.output, isEmpty); + expect(logger.trackedAnimationInProgress, isNull); + }); + + test( + 'when stream has a single event ' + 'then the event is returned and progress is marked successful', + () async { + int? result; + + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + result = await logger.progressStream('Starting', Stream.value(42)); + }); + + expect(result, 42); + expect(stdout.output, contains('Starting')); + expect(stdout.output, contains('42')); + expect(stdout.output, contains('✓')); + expect(stderr.output, isEmpty); + expect(logger.trackedAnimationInProgress, isNull); + }); + + test( + 'when stream has several events ' + 'then each event updates the message and the last event is returned', + () async { + int? result; + + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + result = await logger.progressStream( + 'Starting', + Stream.fromIterable([1, 2, 3]), + toMessage: (final step) => 'Step $step', + ); + }); + + expect(result, 3); + expect(stdout.output, contains('Starting')); + expect(stdout.output, contains('Step 1')); + expect(stdout.output, contains('Step 2')); + expect(stdout.output, contains('Step 3')); + expect(stdout.output, contains('✓')); + expect(stderr.output, isEmpty); + expect(logger.trackedAnimationInProgress, isNull); + }); + + test( + 'when stream completes successfully ' + 'then progress is marked successful', () async { + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + await logger.progressStream( + 'Deploying', + Stream.fromIterable(['build', 'deploy']), + toMessage: (final phase) => phase, + ); + }); + + expect(stdout.output, contains('Deploying')); + expect(stdout.output, contains('deploy')); + expect(stdout.output, contains('✓')); + expect(stdout.output, isNot(contains('✗'))); + expect(logger.trackedAnimationInProgress, isNull); + }); + + test( + 'when stream completes and result is successful ' + 'then progress is marked successful', () async { + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + await logger.progressStream( + 'Deploying', + Stream.fromIterable(['build', 'deploy']), + toMessage: (final phase) => phase, + isSuccess: (final phase) => phase == 'deploy', + ); + }); + + print(stdout.output); + expect(stdout.output, contains('Deploying')); + expect(stdout.output, contains('deploy')); + expect(stdout.output, contains('✓')); + expect(stdout.output, isNot(contains('✗'))); + expect(logger.trackedAnimationInProgress, isNull); + }); + + test( + 'when stream completes but result is not successful ' + 'then progress is marked unsuccessful', () async { + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + await logger.progressStream( + 'Deploying', + Stream.fromIterable(['build', 'deploy']), + toMessage: (final phase) => phase, + isSuccess: (final phase) => phase == 'some-other-phase', + ); + }); + + print(stdout.output); + expect(stdout.output, contains('Deploying')); + expect(stdout.output, contains('deploy')); + expect(stdout.output, contains('✗')); + expect(stdout.output, isNot(contains('✓'))); + expect(logger.trackedAnimationInProgress, isNull); + }); + + test( + 'when stream throws an exception ' + 'then progress is marked failed and the exception is rethrown', + () async { + Stream failingStream() async* { + yield 1; + throw Exception('stream failed'); + } + + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + await expectLater( + logger.progressStream('Starting', failingStream()), + throwsA(isA()), + ); + }); + + expect(stdout.output, contains('Starting')); + expect(stdout.output, contains('1')); + expect(stdout.output, contains('✗')); + expect(stdout.output, isNot(contains('✓'))); + expect(stderr.output, isEmpty); + expect(logger.trackedAnimationInProgress, isNull); + }); + + test( + 'when newParagraph is true ' + 'then a leading newline is written before progress output', () async { + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + await logger.progressStream( + 'Starting', + Stream.value('done'), + newParagraph: true, + ); + }); + + expect(stdout.output, startsWith('\n')); + expect(stdout.output, contains('✓')); + expect(logger.trackedAnimationInProgress, isNull); + }); + }); + }); + + group('Given a StdOutLogger with warning log level', () { + final logger = StdOutLogger(LogLevel.warning); + + test( + 'when calling progressStream ' + 'then no progress output is written and the last event is returned', + () async { + int? result; + + final (:stdout, :stderr, :stdin) = await collectOutput(() async { + result = await logger.progressStream( + 'Hidden', + Stream.fromIterable([1, 2, 3]), + ); + }); + + expect(result, 3); + expect(stdout.output, isEmpty); + expect(stderr.output, isEmpty); + expect(logger.trackedAnimationInProgress, isNull); + }); + }); +} diff --git a/packages/cli_tools/test/prompts/confirm_test.dart b/packages/cli_tools/test/prompts/confirm_test.dart index 3b15dd9..3dd2131 100644 --- a/packages/cli_tools/test/prompts/confirm_test.dart +++ b/packages/cli_tools/test/prompts/confirm_test.dart @@ -1,6 +1,3 @@ -// ignore required for Dart 3.3 -// ignore_for_file: unused_local_variable - import 'package:cli_tools/cli_tools.dart'; import 'package:test/test.dart'; diff --git a/packages/cli_tools/test/prompts/input_test.dart b/packages/cli_tools/test/prompts/input_test.dart index e67e9bf..9546871 100644 --- a/packages/cli_tools/test/prompts/input_test.dart +++ b/packages/cli_tools/test/prompts/input_test.dart @@ -1,6 +1,3 @@ -// ignore required for Dart 3.3 -// ignore_for_file: unused_local_variable - import 'package:cli_tools/cli_tools.dart'; import 'package:test/test.dart'; diff --git a/packages/cli_tools/test/prompts/multiple_select_test.dart b/packages/cli_tools/test/prompts/multiple_select_test.dart index 5c7e715..081f642 100644 --- a/packages/cli_tools/test/prompts/multiple_select_test.dart +++ b/packages/cli_tools/test/prompts/multiple_select_test.dart @@ -1,6 +1,3 @@ -// ignore required for Dart 3.3 -// ignore_for_file: unused_local_variable - import 'package:cli_tools/cli_tools.dart'; import 'package:cli_tools/src/prompts/key_codes.dart' show KeyCodes; import 'package:cli_tools/src/prompts/select.dart' show underline; diff --git a/packages/cli_tools/test/prompts/select_test.dart b/packages/cli_tools/test/prompts/select_test.dart index e4eb557..f696c7d 100644 --- a/packages/cli_tools/test/prompts/select_test.dart +++ b/packages/cli_tools/test/prompts/select_test.dart @@ -1,6 +1,3 @@ -// ignore required for Dart 3.3 -// ignore_for_file: unused_local_variable - import 'package:cli_tools/cli_tools.dart'; import 'package:cli_tools/src/prompts/key_codes.dart' show KeyCodes; import 'package:cli_tools/src/prompts/select.dart' show underline; diff --git a/packages/cli_tools/test/std_out_logger_test.dart b/packages/cli_tools/test/std_out_logger_test.dart index d30f81f..d6d5ace 100644 --- a/packages/cli_tools/test/std_out_logger_test.dart +++ b/packages/cli_tools/test/std_out_logger_test.dart @@ -1,6 +1,3 @@ -// ignore required for Dart 3.3 -// ignore_for_file: unused_local_variable - import 'package:cli_tools/cli_tools.dart'; import 'package:test/test.dart'; diff --git a/packages/cli_tools/test/test_utils/mock_stdout.dart b/packages/cli_tools/test/test_utils/mock_stdout.dart index 326901b..46e5140 100644 --- a/packages/cli_tools/test/test_utils/mock_stdout.dart +++ b/packages/cli_tools/test/test_utils/mock_stdout.dart @@ -8,8 +8,6 @@ class MockStdout implements Stdout { Encoding encoding = utf8; @override - // ignore required for Dart 3.3 - // ignore: override_on_non_overriding_member String lineTerminator = '\n'; String get output => _buffer.toString(); diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md index 7c25f02..b2dce33 100644 --- a/packages/config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.0 + + - Bumped minimum Dart SDK version to 3.6 + ## 0.8.4 - **FIX**(config): Created a public OptionResolution interface (#91). diff --git a/packages/config/pubspec.yaml b/packages/config/pubspec.yaml index a7924d1..2858afa 100644 --- a/packages/config/pubspec.yaml +++ b/packages/config/pubspec.yaml @@ -1,5 +1,5 @@ name: config -version: 0.8.4 +version: 0.9.0 description: A package for parsing command-line arguments and configuration files. repository: https://github.com/serverpod/cli_tools issue_tracker: https://github.com/serverpod/cli_tools/issues @@ -12,8 +12,10 @@ topics: - configuration - yaml +resolution: workspace + environment: - sdk: ^3.3.0 + sdk: ^3.6.0 dependencies: args: ^2.7.0 diff --git a/packages/isolated_object/CHANGELOG.md b/packages/isolated_object/CHANGELOG.md new file mode 100644 index 0000000..a0712a7 --- /dev/null +++ b/packages/isolated_object/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial version. diff --git a/packages/isolated_object/LICENSE b/packages/isolated_object/LICENSE new file mode 100644 index 0000000..dcdffe5 --- /dev/null +++ b/packages/isolated_object/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, Serverpod + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/isolated_object/README.md b/packages/isolated_object/README.md new file mode 100644 index 0000000..f3cfd18 --- /dev/null +++ b/packages/isolated_object/README.md @@ -0,0 +1,24 @@ +# isolated_object + +Wraps an object in a dedicated isolate and forwards method calls to it, so +timer- or event-loop-driven work (e.g. progress spinners) keeps animating even +when the calling isolate's event loop is blocked by heavy synchronous work. + +Dependency-free. + +## Usage + +```dart +import 'package:isolated_object/isolated_object.dart'; + +final counter = IsolatedObject(() => Counter()); + +await counter.evaluate((c) => c.increment()); +final value = await counter.evaluate((c) => c.value); + +await counter.close(); +``` + +The factory runs inside the child isolate; each `evaluate` forwards a closure +to that isolate and returns the result. `close()` shuts the isolate down, +failing any in-flight calls rather than letting them hang. diff --git a/packages/isolated_object/analysis_options.yaml b/packages/isolated_object/analysis_options.yaml new file mode 100644 index 0000000..c249a90 --- /dev/null +++ b/packages/isolated_object/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:serverpod_lints/cli.yaml + +analyzer: + language: + strict-raw-types: false + errors: + inference_failure_on_instance_creation: ignore + inference_failure_on_function_invocation: ignore + inference_failure_on_untyped_parameter: ignore + prefer_final_parameters: ignore + +linter: + rules: + prefer_relative_imports: true diff --git a/packages/isolated_object/lib/isolated_object.dart b/packages/isolated_object/lib/isolated_object.dart new file mode 100644 index 0000000..0636d61 --- /dev/null +++ b/packages/isolated_object/lib/isolated_object.dart @@ -0,0 +1,4 @@ +/// Wraps an object in a dedicated isolate, forwarding method calls to it. +library; + +export 'src/isolated_object.dart'; diff --git a/packages/isolated_object/lib/src/isolated_object.dart b/packages/isolated_object/lib/src/isolated_object.dart new file mode 100644 index 0000000..429677b --- /dev/null +++ b/packages/isolated_object/lib/src/isolated_object.dart @@ -0,0 +1,156 @@ +import 'dart:async'; +import 'dart:isolate'; + +/// Synchronous or asynchronous producer of a [T]. Used by [IsolatedObject] +/// to create the wrapped object inside the child isolate. +typedef Factory = FutureOr Function(); + +typedef _Action = ({int id, dynamic Function(T) function}); +typedef _Response = ({int id, dynamic result}); +typedef _Inflight = Map; +typedef _Setup = (SendPort, ReceivePort, _Inflight); + +/// Wraps an object of type [T] in a dedicated isolate, allowing method calls +/// to be forwarded via [evaluate]. +class IsolatedObject { + final Future<_Setup> _connected; + + /// Spawns a dedicated isolate, constructs a [T] there via [create], and + /// returns immediately. Subsequent [evaluate] calls forward work to that + /// isolate. + /// + /// If [keepIsolateAlive] is true (the default), the parent isolate stays + /// alive as long as this object is open, matching the usual service-style + /// lifecycle. Set to false for fire-and-forget isolates that shouldn't + /// block process shutdown on their own. + IsolatedObject(Factory create, {bool keepIsolateAlive = true}) + : _connected = _connect(create, keepIsolateAlive); + + static Future<_Setup> _connect( + Factory create, + bool keepIsolateAlive, + ) async { + final parentPort = RawReceivePort()..keepIsolateAlive = keepIsolateAlive; + final setupDone = Completer<_Setup>(); + + parentPort.handler = (dynamic message) async { + if (message case final RemoteError e) { + parentPort.close(); + setupDone.completeError(e, e.stackTrace); + } else { + final toChild = message as SendPort; + final fromChild = ReceivePort.fromRawReceivePort(parentPort); + final inflight = _Inflight(); + setupDone.complete((toChild, fromChild, inflight)); + } + }; + + try { + await _spawn(create, parentPort.sendPort); + } catch (_) { + parentPort.close(); + rethrow; + } + + final result = await setupDone.future; + final (toChild, fromChild, inflight) = result; + + fromChild.listen( + (dynamic message) async { + if (message case final _Response response) { + final completer = inflight.remove(response.id); + assert(completer != null, 'PROTOCOL BUG. No such ID ${response.id}'); + if (completer == null) return; + switch (response.result) { + case final RemoteError e: + completer.completeError(e, e.stackTrace); + default: + completer.complete(await response.result); + } + } + }, + onDone: () { + // ReceivePort closed. Fail any pending requests to avoid hangs. + for (final c in inflight.values) { + if (!c.isCompleted) { + c.completeError(StateError('IsolatedObject<$T> channel closed')); + } + } + inflight.clear(); + }, + ); + + return result; + } + + static Future _spawn(Factory create, SendPort toParent) { + return Isolate.spawn((SendPort toParent) async { + final childPort = ReceivePort(); + final T isolatedObject; + try { + isolatedObject = await create(); + toParent.send(childPort.sendPort); + } catch (e, st) { + toParent.send(RemoteError('$e', '$st')); + return; + } + + await for (final message in childPort) { + if (message == null) { + childPort.close(); + break; + } else if (message case final _Action action) { + try { + final result = await action.function(isolatedObject); + toParent.send((id: action.id, result: result)); + } catch (e, st) { + toParent.send((id: action.id, result: RemoteError('$e', '$st'))); + } + } + } + }, toParent); + } + + int _nextId = 0; + bool _isClosed = false; + + /// Whether [close] has been called. Once true, further [evaluate] calls + /// throw a [StateError]. + bool get isClosed => _isClosed; + + /// Evaluates [function] on the isolated object and returns the result. + Future evaluate(FutureOr Function(T) function) async { + if (_isClosed) { + throw StateError('IsolatedObject<$T> is closed'); + } + + final (toChild, _, inflight) = await _connected; + + final id = _nextId++; + final completer = Completer(); + inflight[id] = completer; + + toChild.send((id: id, function: function)); + return completer.future; + } + + /// Shuts down the isolate. Idempotent. Any outstanding [evaluate] calls are + /// failed with a [StateError] rather than left to hang. + Future close() async { + if (_isClosed) return; + _isClosed = true; + + final (toChild, fromChild, inflight) = await _connected; + + // Fail any outstanding requests. + for (final c in inflight.values) { + if (!c.isCompleted) { + c.completeError(StateError('$runtimeType is closed')); + } + } + inflight.clear(); + + toChild.send(null); + fromChild.close(); + } +} diff --git a/packages/isolated_object/pubspec.yaml b/packages/isolated_object/pubspec.yaml new file mode 100644 index 0000000..ee8a1b5 --- /dev/null +++ b/packages/isolated_object/pubspec.yaml @@ -0,0 +1,18 @@ +name: isolated_object +description: >- + Wraps an object in a dedicated isolate and forwards method calls to it, so + timer- or event-loop-driven work (e.g. progress spinners) keeps running even + when the calling isolate's event loop is blocked. Dependency-free. +version: 0.1.0 +repository: https://github.com/serverpod/serverpod +homepage: https://serverpod.dev +issue_tracker: https://github.com/serverpod/serverpod/issues + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dev_dependencies: + serverpod_lints: '>=2.7.0' + test: ^1.25.6 diff --git a/packages/isolated_object/test/close_test.dart b/packages/isolated_object/test/close_test.dart new file mode 100644 index 0000000..3c567d6 --- /dev/null +++ b/packages/isolated_object/test/close_test.dart @@ -0,0 +1,55 @@ +// Ported from relic's isolated_object_close_test.dart. The pending-operation +// message assertion is relaxed to `contains('closed')` so it holds for the +// canonical (serverpod) teardown, which fails in-flight calls with +// " is closed" rather than relic's "channel closed". +import 'dart:async'; + +import 'package:isolated_object/isolated_object.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'Given an IsolatedObject, ' + 'when close is called multiple times, ' + 'then it handles it gracefully', () async { + final isolated = IsolatedObject<_Counter>(() => _Counter(0)); + + await isolated.close(); + await isolated.close(); // Second close should not throw. + + expect(isolated.isClosed, isTrue); + }); + + test( + 'Given an IsolatedObject with pending operations, ' + 'when it is closed, ' + 'then pending operations fail with a closed error', () async { + final isolated = IsolatedObject<_Counter>(() => _Counter(0)); + + final pendingOperation = isolated.evaluate((counter) async { + await Future.delayed(const Duration(seconds: 10)); + return counter.value; + }); + + // Give the operation time to register as inflight. + await Future.delayed(const Duration(milliseconds: 50)); + + await isolated.close(); + + await expectLater( + pendingOperation, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('closed'), + ), + ), + ); + }); +} + +class _Counter { + int value; + _Counter(this.value); +} diff --git a/packages/isolated_object/test/create_test.dart b/packages/isolated_object/test/create_test.dart new file mode 100644 index 0000000..e830ef9 --- /dev/null +++ b/packages/isolated_object/test/create_test.dart @@ -0,0 +1,55 @@ +// Ported from relic's isolated_object_create_test.dart. +import 'dart:async'; + +import 'package:isolated_object/isolated_object.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'Given a synchronous factory, ' + 'when IsolatedObject is created, ' + 'then it successfully initializes', () async { + final isolated = IsolatedObject<_Counter>(() => _Counter(0)); + + final result = await isolated.evaluate((counter) => counter.value); + expect(result, 0); + + await isolated.close(); + }); + + test( + 'Given an async factory, ' + 'when IsolatedObject is created, ' + 'then it successfully initializes', () async { + final isolated = IsolatedObject<_Counter>(() async { + await Future.delayed(const Duration(milliseconds: 10)); + return _Counter(42); + }); + + final result = await isolated.evaluate((counter) => counter.value); + expect(result, 42); + + await isolated.close(); + }); + + test( + 'Given a factory that throws, ' + 'when IsolatedObject is created, ' + 'then subsequent operations fail gracefully', () async { + final isolated = IsolatedObject<_Counter>(() { + throw Exception('Factory failed'); + }); + + await expectLater( + isolated.evaluate((counter) => counter.value), + throwsA(anything), + ); + }); +} + +class _Counter { + int value; + _Counter(this.value); + + void increment() => value++; +} diff --git a/packages/isolated_object/test/evaluate_test.dart b/packages/isolated_object/test/evaluate_test.dart new file mode 100644 index 0000000..cfa8424 --- /dev/null +++ b/packages/isolated_object/test/evaluate_test.dart @@ -0,0 +1,255 @@ +// Ported from relic's isolated_object_evaluate_test.dart. +import 'dart:async'; +import 'dart:isolate'; + +import 'package:isolated_object/isolated_object.dart'; +import 'package:test/test.dart'; + +void main() { + group('evaluate', () { + late IsolatedObject<_Counter> isolated; + + setUp(() { + isolated = IsolatedObject<_Counter>(() => _Counter(0)); + }); + + tearDown(() async { + await isolated.close(); + }); + + test( + 'Given an IsolatedObject, ' + 'when evaluate is called with a getter, ' + 'then it returns the correct value', () async { + final result = await isolated.evaluate((counter) => counter.value); + expect(result, 0); + }); + + test( + 'Given an IsolatedObject, ' + 'when evaluate is called with a mutation, ' + 'then the state persists across evaluations', () async { + await isolated.evaluate((counter) { + counter.increment(); + return null; + }); + + final result = await isolated.evaluate((counter) => counter.value); + expect(result, 1); + }); + + test( + 'Given an IsolatedObject, ' + 'when evaluate is called multiple times sequentially, ' + 'then all operations execute correctly', () async { + await isolated.evaluate((counter) => counter.increment()); + await isolated.evaluate((counter) => counter.increment()); + await isolated.evaluate((counter) => counter.increment()); + + final result = await isolated.evaluate((counter) => counter.value); + expect(result, 3); + }); + + test( + 'Given an IsolatedObject, ' + 'when evaluate is called with multiple concurrent operations, ' + 'then all operations complete successfully', () async { + final futures = List.generate( + 10, + (i) => isolated.evaluate((counter) => counter.increment()), + ); + + await futures.wait; + + final result = await isolated.evaluate((counter) => counter.value); + expect(result, 10); + }); + + test( + 'Given an IsolatedObject, ' + 'when evaluate is called with different return types, ' + 'then it returns the correct types', () async { + final intResult = await isolated.evaluate( + (counter) => counter.value, + ); + expect(intResult, isA()); + expect(intResult, 0); + + final stringResult = await isolated.evaluate( + (counter) => 'Value: ${counter.value}', + ); + expect(stringResult, isA()); + expect(stringResult, 'Value: 0'); + + final boolResult = await isolated.evaluate( + (counter) => counter.value > 0, + ); + expect(boolResult, isA()); + expect(boolResult, false); + }); + + test( + 'Given an IsolatedObject, ' + 'when evaluate is called with async function, ' + 'then it awaits the result correctly', () async { + await isolated.evaluate((counter) async { + await Future.delayed(const Duration(milliseconds: 10)); + counter.increment(); + }); + + final result = await isolated.evaluate((counter) => counter.value); + expect(result, 1); + }); + + test( + 'Given an IsolatedObject, ' + 'when evaluate is called with a void function that throws, ' + 'then it propagates the error', () async { + await expectLater( + isolated.evaluate((counter) { + throw Exception('Test error'); + }), + throwsA(isA()), + ); + }); + }); + + group('error handling', () { + test( + 'Given an IsolatedObject, ' + 'when a function throws in the isolate, ' + 'then RemoteError contains error information', () async { + final isolated = IsolatedObject<_Counter>(() => _Counter(0)); + + try { + await isolated.evaluate((counter) { + throw StateError('Custom error message'); + }); + } catch (e) { + expect(e, isA()); + expect(e.toString(), contains('Custom error message')); + } finally { + await isolated.close(); + } + }); + + test( + 'Given an IsolatedObject, ' + 'when an error occurs, ' + 'then subsequent operations still work', () async { + final isolated = IsolatedObject<_Counter>(() => _Counter(5)); + + try { + await isolated.evaluate((counter) { + throw Exception('First error'); + }); + } catch (_) { + // Expected. + } + + final result = await isolated.evaluate((counter) => counter.value); + expect(result, 5); + + await isolated.close(); + }); + }); + + group('concurrent operations', () { + test( + 'Given an IsolatedObject, ' + 'when many operations are queued concurrently, ' + 'then all complete with unique IDs', () async { + final isolated = IsolatedObject<_Counter>(() => _Counter(0)); + + final futures = >[]; + for (var i = 0; i < 100; i++) { + futures.add( + isolated.evaluate((counter) { + counter.increment(); + return counter.value; + }), + ); + } + + final results = await futures.wait; + expect(results.length, 100); + + final finalValue = await isolated.evaluate((counter) => counter.value); + expect(finalValue, 100); + + await isolated.close(); + }); + + test( + 'Given an IsolatedObject, ' + 'when concurrent operations include some that throw, ' + 'then successful operations still complete', () async { + final isolated = IsolatedObject<_Counter>(() => _Counter(0)); + + const ops = 10; + try { + await [ + for (var i = 0; i < ops; i++) + i % 2 == 0 + ? isolated.evaluate((counter) => counter.increment()) + : isolated.evaluate((_) => throw Exception('Error $i')), + ].wait; + } on ParallelWaitError> catch (e) { + expect(e.errors.nonNulls.length, ops / 2); + expect(e.values.nonNulls.length, ops / 2); + } + + final result = await isolated.evaluate((counter) => counter.value); + expect(result, ops / 2); + + await isolated.close(); + }); + }); + + group('complex objects', () { + test( + 'Given an IsolatedObject with a complex state object, ' + 'when operations modify nested state, ' + 'then changes persist correctly', () async { + final isolated = IsolatedObject<_ComplexObject>(() => _ComplexObject()); + + await isolated.evaluate((obj) { + obj.data['key1'] = 'value1'; + obj.data['key2'] = 'value2'; + }); + + final result = await isolated.evaluate((obj) => obj.data); + expect(result, {'key1': 'value1', 'key2': 'value2'}); + + await isolated.close(); + }); + + test( + 'Given an IsolatedObject with state, ' + 'when evaluating functions that return lists, ' + 'then lists are correctly transferred', () async { + final isolated = IsolatedObject<_ComplexObject>(() => _ComplexObject()); + + await isolated.evaluate((obj) { + obj.items.addAll([1, 2, 3, 4, 5]); + }); + + final result = await isolated.evaluate((obj) => obj.items); + expect(result, [1, 2, 3, 4, 5]); + + await isolated.close(); + }); + }); +} + +class _Counter { + int value; + _Counter(this.value); + + void increment() => value++; +} + +class _ComplexObject { + final Map data = {}; + final List items = []; +} diff --git a/packages/isolated_object/test/lifecycle_test.dart b/packages/isolated_object/test/lifecycle_test.dart new file mode 100644 index 0000000..890f8f6 --- /dev/null +++ b/packages/isolated_object/test/lifecycle_test.dart @@ -0,0 +1,49 @@ +// New tests covering serverpod's superset semantics that relic's suite (built +// against the leaner copy) does not exercise: the isClosed guard, use-after- +// close, and the keepIsolateAlive flag. +import 'package:isolated_object/isolated_object.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'Given an open IsolatedObject, ' + 'then isClosed is false until close is called', () async { + final isolated = IsolatedObject<_Counter>(() => _Counter(0)); + + expect(isolated.isClosed, isFalse); + await isolated.close(); + expect(isolated.isClosed, isTrue); + }); + + test( + 'Given a closed IsolatedObject, ' + 'when evaluate is called, ' + 'then it throws a StateError', () async { + final isolated = IsolatedObject<_Counter>(() => _Counter(0)); + await isolated.close(); + + expect( + () => isolated.evaluate((counter) => counter.value), + throwsA(isA()), + ); + }); + + test( + 'Given keepIsolateAlive: false, ' + 'when the object is used, ' + 'then it still evaluates correctly', () async { + final isolated = IsolatedObject<_Counter>( + () => _Counter(7), + keepIsolateAlive: false, + ); + + expect(await isolated.evaluate((counter) => counter.value), 7); + + await isolated.close(); + }); +} + +class _Counter { + int value; + _Counter(this.value); +} diff --git a/packages/serverpod_logging/.gitignore b/packages/serverpod_logging/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/serverpod_logging/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/serverpod_logging/CHANGELOG.md b/packages/serverpod_logging/CHANGELOG.md new file mode 100644 index 0000000..477f1e8 --- /dev/null +++ b/packages/serverpod_logging/CHANGELOG.md @@ -0,0 +1,9 @@ +## 0.1.0 + +- Add `IsolatedLogWriter`, a `LogWriter` that runs a wrapped writer on a + dedicated isolate (via `package:isolated_object`). +- Lower the SDK constraint to `^3.6.0`. + +## 0.0.1 + +- Initial version. diff --git a/packages/serverpod_logging/LICENSE b/packages/serverpod_logging/LICENSE new file mode 100644 index 0000000..dcdffe5 --- /dev/null +++ b/packages/serverpod_logging/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, Serverpod + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/serverpod_logging/README.md b/packages/serverpod_logging/README.md new file mode 100644 index 0000000..621f6c3 --- /dev/null +++ b/packages/serverpod_logging/README.md @@ -0,0 +1,5 @@ +![Serverpod banner](https://github.com/serverpod/serverpod/raw/main/misc/images/github-header.webp) + +# Serverpod Logging + +This package contains tools for bridging logging between Serverpod framework and tooling. diff --git a/packages/serverpod_logging/analysis_options.yaml b/packages/serverpod_logging/analysis_options.yaml new file mode 100644 index 0000000..c249a90 --- /dev/null +++ b/packages/serverpod_logging/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:serverpod_lints/cli.yaml + +analyzer: + language: + strict-raw-types: false + errors: + inference_failure_on_instance_creation: ignore + inference_failure_on_function_invocation: ignore + inference_failure_on_untyped_parameter: ignore + prefer_final_parameters: ignore + +linter: + rules: + prefer_relative_imports: true diff --git a/packages/serverpod_logging/lib/serverpod_logging.dart b/packages/serverpod_logging/lib/serverpod_logging.dart new file mode 100644 index 0000000..81e55d6 --- /dev/null +++ b/packages/serverpod_logging/lib/serverpod_logging.dart @@ -0,0 +1,7 @@ +export 'src/global_log.dart'; +export 'src/isolated_log_writer.dart'; +export 'src/log.dart'; +export 'src/log_types.dart'; +export 'src/spinner_log_writer.dart'; +export 'src/test_log_writer.dart'; +export 'src/text_log_writer.dart'; diff --git a/packages/serverpod_logging/lib/src/global_log.dart b/packages/serverpod_logging/lib/src/global_log.dart new file mode 100644 index 0000000..711ecd3 --- /dev/null +++ b/packages/serverpod_logging/lib/src/global_log.dart @@ -0,0 +1,20 @@ +import 'log.dart'; +import 'log_types.dart'; + +/// Global writer chain that backs [log]. Callers configure the chain by +/// adding writers with [MultiLogWriter.add] and removing them with +/// [MultiLogWriter.remove]; identity is stable for the process lifetime, +/// so the chain is shared across any number of [Log] consumers and +/// framework bootstraps. +/// +/// ```dart +/// logWriter.add(MyLogWriter()); +/// log.info('Server started'); +/// ``` +final MultiLogWriter logWriter = MultiLogWriter([]); + +/// Global [Log] that forwards to [logWriter]. Identity is stable: the +/// instance is constructed at library init and never reassigned. Entry +/// points configure logging by mutating [logWriter], not by replacing +/// [log]. +final Log log = Log(logWriter, logLevel: LogLevel.info); diff --git a/packages/serverpod_logging/lib/src/isolated_log_writer.dart b/packages/serverpod_logging/lib/src/isolated_log_writer.dart new file mode 100644 index 0000000..4f84fca --- /dev/null +++ b/packages/serverpod_logging/lib/src/isolated_log_writer.dart @@ -0,0 +1,42 @@ +import 'package:isolated_object/isolated_object.dart'; + +import 'log_types.dart'; + +/// A [LogWriter] that runs a wrapped writer on a dedicated isolate. +/// +/// Not a high-throughput sink. Every operation is copied across the isolate +/// boundary, so payloads must be sendable. +class IsolatedLogWriter extends IsolatedObject implements LogWriter { + /// Creates an [IsolatedLogWriter] that runs the writer produced by + /// [factory] on a dedicated isolate. + IsolatedLogWriter(super.factory); + + @override + Future log(LogEntry entry) async { + try { + await evaluate((w) => w.log(entry)); + } catch (_) {} // best effort + } + + @override + Future openScope(LogScope scope) => evaluate((w) => w.openScope(scope)); + + @override + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }) async { + await evaluate( + (w) => w.closeScope( + scope, + success: success, + duration: duration, + error: error, + stackTrace: stackTrace, + ), + ); + } +} diff --git a/packages/serverpod_logging/lib/src/log.dart b/packages/serverpod_logging/lib/src/log.dart new file mode 100644 index 0000000..09740ea --- /dev/null +++ b/packages/serverpod_logging/lib/src/log.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +import 'log_types.dart'; + +/// Symbol used to store the current [LogScope] in Zone values. +const Symbol _logScopeKey = #_logScope; + +final _stopwatch = Stopwatch()..start(); +int _scopeCounter = 0; +String _newScopeId(String label) => + '${label.hashCode}_${_stopwatch.elapsedTicks}_${++_scopeCounter}'; + +/// A factory function that creates a [LogEntry]. +typedef LogEntryFactory = FutureOr Function(); + +/// A logger that delegates to a [LogWriter] and resolves the current +/// [LogScope] from the Zone. +/// +/// Each call appends onto a rolling `_latest` Future, so writes +/// serialize in invocation order. [flush] awaits that tail; [close] +/// does the same and blocks further dispatches. +/// +/// ```dart +/// log.info('Server started'); +/// await log.progress('Migration', () async { +/// log.info('Step 1'); +/// }); +/// ``` +class Log { + final LogWriter _writer; + Future _latest = Future.value(); + bool _closed = false; + + /// The minimum severity that will be forwarded to the writer. Calls below + /// this level short-circuit without invoking the [LogEntryFactory]. May + /// be changed at runtime (e.g. when a verbose flag is parsed). + LogLevel logLevel; + + /// Creates a [Log] that forwards to [_writer]. Messages below [logLevel] + /// are dropped before the [LogEntryFactory] runs. + Log(this._writer, {this.logLevel = LogLevel.info}); + + /// The current scope from the Zone, or a synthetic root if none. + LogScope get currentScope => + Zone.current[_logScopeKey] as LogScope? ?? _fallbackScope; + + static final _fallbackScope = LogScope.root('unknown'); + + /// Checks level, evaluates the factory eagerly, and appends the write + /// to the chain. Writer errors are swallowed - logging is best-effort. + void call(LogLevel level, LogEntryFactory factory) { + if (level.index < logLevel.index || _closed) return; + _latest = _latest.then((_) async { + try { + await _writer.log(await factory()); + } catch (_) {} + }); + } + + /// Awaits in-flight writes without blocking further dispatches. + Future flush() => _latest; + + /// Awaits in-flight writes and blocks further dispatches. + Future close() async { + _closed = true; + await flush(); + } +} + +/// Convenience methods for common log levels. +extension LogConvenience on Log { + /// Logs [message] at [LogLevel.debug]. + void debug(String message, {Map? metadata}) => this( + LogLevel.debug, + () => LogEntry( + time: DateTime.now(), + level: LogLevel.debug, + message: message, + scope: currentScope, + metadata: metadata, + ), + ); + + /// Logs [message] at [LogLevel.info]. + void info(String message, {Map? metadata}) => this( + LogLevel.info, + () => LogEntry( + time: DateTime.now(), + level: LogLevel.info, + message: message, + scope: currentScope, + metadata: metadata, + ), + ); + + /// Logs [message] at [LogLevel.warning]. + void warning(String message, {Map? metadata}) => this( + LogLevel.warning, + () => LogEntry( + time: DateTime.now(), + level: LogLevel.warning, + message: message, + scope: currentScope, + metadata: metadata, + ), + ); + + /// Logs [message] at [LogLevel.error], optionally attaching an [error] + /// value and [stackTrace]. + void error( + String message, { + Object? error, + StackTrace? stackTrace, + Map? metadata, + }) => + this( + LogLevel.error, + () => LogEntry( + time: DateTime.now(), + level: LogLevel.error, + message: message, + scope: currentScope, + error: error, + stackTrace: stackTrace, + metadata: metadata, + ), + ); + + /// Whether debug-level messages are currently forwarded to the writer. + /// Use to gate expensive message construction: + /// `if (log.isDebugEnabled) log.debug(formatBigObject(x));`. + bool get isDebugEnabled => logLevel.index <= LogLevel.debug.index; +} + +/// Scope management: progress operations and manual scope control. +extension LogScoping on Log { + /// Runs [runner] inside a new scope. The scope is automatically opened + /// before the runner and closed after it completes (or fails). + /// + /// Log calls inside the runner are automatically scoped via the Zone. + /// + /// Success signal: + /// - If [runner] throws, the scope closes with `success: false`. + /// - Else if [isSuccess] is provided, its return value is used. + /// - Else if [T] is `bool`, the returned value is used directly. + /// - Otherwise, the scope closes with `success: true`. + Future progress( + String label, + FutureOr Function() runner, { + Map? metadata, + bool Function(T result)? isSuccess, + }) async { + final scope = currentScope.child( + id: _newScopeId(label), + label: label, + metadata: metadata, + ); + await _writer.openScope(scope); + final stopwatch = Stopwatch()..start(); + try { + final result = await runZoned(runner, zoneValues: {_logScopeKey: scope}); + await _writer.closeScope( + scope, + success: isSuccess?.call(result) ?? (result is bool ? result : true), + duration: stopwatch.elapsed, + ); + return result; + } catch (e, st) { + await _writer.closeScope( + scope, + success: false, + duration: stopwatch.elapsed, + error: e, + stackTrace: st, + ); + rethrow; + } + } +} diff --git a/packages/serverpod_logging/lib/src/log_types.dart b/packages/serverpod_logging/lib/src/log_types.dart new file mode 100644 index 0000000..b719a6e --- /dev/null +++ b/packages/serverpod_logging/lib/src/log_types.dart @@ -0,0 +1,181 @@ +/// Log severity level. +enum LogLevel { + /// Fine-grained diagnostic information, normally disabled in production. + debug, + + /// Informational messages describing normal operation. + info, + + /// Recoverable issues worth flagging but not halting the flow. + warning, + + /// Errors the caller is expected to handle or surface. + error, + + /// Unrecoverable failures. Usually followed by process teardown. + fatal, +} + +/// A scoped operation. Scopes form a tree - every scope has a parent except the +/// root scope. +/// +/// A scope begins with [LogWriter.openScope] and ends with +/// [LogWriter.closeScope]. Log entries within the scope reference it via +/// [LogEntry.scope]. +class LogScope { + /// Stable identifier for this scope. Unique within a process lifetime; + /// referenced by [LogEntry.scope] and by scope open/close events. + final String id; + + /// Human-readable label describing the scoped operation. + final String label; + + /// Wall-clock time at which the scope was opened. + final DateTime startTime; + + /// Enclosing scope, or `null` if this is a root scope. + final LogScope? parent; + + /// Optional scope-level structured metadata (arbitrary key/value pairs). + final Map? metadata; + + /// Creates a scope. Prefer [LogScope.root] or [child] over calling this + /// directly. + const LogScope({ + required this.id, + required this.label, + required this.startTime, + this.parent, + this.metadata, + }); + + /// Creates a root scope for a process. + factory LogScope.root(String label) => + LogScope(id: 'root', label: label, startTime: DateTime.now()); + + /// Creates a child scope under this scope. + LogScope child({ + required String id, + required String label, + Map? metadata, + }) => + LogScope( + id: id, + label: label, + startTime: DateTime.now(), + parent: this, + metadata: metadata, + ); +} + +/// A single log entry. Always belongs to a [LogScope]. +class LogEntry { + /// Wall-clock time at which the entry was produced. + final DateTime time; + + /// Severity of the entry. + final LogLevel level; + + /// The log message. + final String message; + + /// Scope the entry was emitted from. + final LogScope scope; + + /// Optional error associated with this entry. + final Object? error; + + /// Optional stack trace captured alongside [error]. + final StackTrace? stackTrace; + + /// Optional entry-level structured metadata. + final Map? metadata; + + /// Creates a log entry. + const LogEntry({ + required this.time, + required this.level, + required this.message, + required this.scope, + this.error, + this.stackTrace, + this.metadata, + }); +} + +/// Transport layer for log output. Implementations decide where logs go +/// (terminal, database, VM service, TUI, etc.). +/// +/// Use [MultiLogWriter] to fan out to multiple writers. +abstract class LogWriter { + /// Writes a single log entry. + Future log(LogEntry entry); + + /// Begins a scoped operation. + Future openScope(LogScope scope); + + /// Ends a scoped operation. + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }); + + /// Releases any resources held by the writer. + Future close() async {} +} + +/// A [LogWriter] that fans out to multiple child writers. +class MultiLogWriter extends LogWriter { + List _writers; + + /// Creates a [MultiLogWriter] that fans out to [writers]. + MultiLogWriter(List writers) : _writers = List.of(writers); + + /// Appends [writer] to the chain. Useful when a writer can only be + /// constructed after the chain has already been built (e.g. a writer + /// that needs a database session that doesn't exist yet at startup). + void add(LogWriter writer) => _writers = [..._writers, writer]; + + /// Removes [writer] from the chain, if present. Counterpart to [add]; + /// used when the chain needs to be reconfigured after construction + /// (e.g. swapping a console writer once config has been loaded). + bool remove(LogWriter writer) { + final copy = List.of(_writers); + final removed = copy.remove(writer); + _writers = copy; + return removed; + } + + @override + Future log(LogEntry entry) => _writers.map((w) => w.log(entry)).wait; + + @override + Future openScope(LogScope scope) => + _writers.map((w) => w.openScope(scope)).wait; + + @override + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }) => + _writers + .map( + (w) => w.closeScope( + scope, + success: success, + duration: duration, + error: error, + stackTrace: stackTrace, + ), + ) + .wait; + + @override + Future close() => _writers.map((w) => w.close()).wait; +} diff --git a/packages/serverpod_logging/lib/src/spinner_log_writer.dart b/packages/serverpod_logging/lib/src/spinner_log_writer.dart new file mode 100644 index 0000000..1594bd9 --- /dev/null +++ b/packages/serverpod_logging/lib/src/spinner_log_writer.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:io'; + +import 'log_types.dart'; + +const _clearLine = '\u001b[2K\r'; +const _brailleFrames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'; + +/// Whether stdout is an interactive terminal. +bool isInteractiveTerminal = stdout.hasTerminal; + +/// Format a duration for display in spinner/completion lines. +String formatElapsed(Duration d) { + final ms = d.inMilliseconds; + if (ms < 100) return '${ms}ms'; + return '${(ms / 1000).toStringAsFixed(1)}s'; +} + +/// State for a single in-progress scope with a braille spinner. +class ActiveScope { + /// Tracks [scope] and starts a stopwatch for its elapsed-time display. + ActiveScope(this.scope) : stopwatch = Stopwatch()..start(); + + /// The scope being rendered. + final LogScope scope; + + /// Measures how long the scope has been open; read by the spinner + /// formatter to display elapsed time next to the label. + final Stopwatch stopwatch; +} + +/// A [LogWriter] base class that manages braille progress spinners. +/// +/// When stdout is a terminal, [openScope]/[closeScope] animate a spinner +/// on the last line. Log messages print above the spinner, which redraws +/// after each line. +/// +/// Subclasses override [writeLogLine] to format log output and +/// [formatSpinner]/[formatScopeComplete] to customize spinner appearance. +abstract class SpinnerLogWriter extends LogWriter { + /// Stack of currently-open scopes, innermost last. The innermost entry + /// drives the active spinner line. + final List scopeStack = []; + Timer? _timer; + int _frameIndex = 0; + + /// Write a single log entry to the terminal. Called after the spinner + /// line has been cleared and before it is redrawn. + void writeLogLine(LogEntry entry); + + /// Format the spinner text for the currently active scope. + /// Default uses green braille frames with gray elapsed time. + String formatSpinner(String frame, ActiveScope active) { + return '$frame ${active.scope.label}... (${formatElapsed(active.stopwatch.elapsed)})'; + } + + /// Format the scope completion line. + /// Default uses ✓/✗ with the label and elapsed time. + String formatScopeComplete(LogScope scope, bool success, Duration duration) { + final icon = success ? '\u2713' : '\u2717'; + return '$icon ${scope.label} (${formatElapsed(duration)})'; + } + + /// Format the scope label for non-interactive (pipe) output. + String formatScopeStart(LogScope scope) => '${scope.label}...'; + + /// Format the scope completion for non-interactive output. + String formatScopeEnd(LogScope scope, bool success, Duration duration) { + final status = success ? 'done' : 'failed'; + return '${scope.label} $status. (${formatElapsed(duration)})'; + } + + @override + Future log(LogEntry entry) async { + _clearSpinnerLine(); + writeLogLine(entry); + _drawSpinner(); + } + + @override + Future openScope(LogScope scope) async { + if (isInteractiveTerminal) { + _clearSpinnerLine(); + scopeStack.add(ActiveScope(scope)); + _drawSpinner(); + _startTimer(); + } else { + stdout.writeln(formatScopeStart(scope)); + } + } + + @override + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }) async { + if (isInteractiveTerminal) { + _clearSpinnerLine(); + scopeStack.removeWhere((s) => s.scope.id == scope.id); + if (scopeStack.isEmpty) _stopTimer(); + + stdout.writeln(formatScopeComplete(scope, success, duration)); + + _drawSpinner(); + } else { + stdout.writeln(formatScopeEnd(scope, success, duration)); + } + } + + void _startTimer() { + if (_timer != null) return; + _timer = Timer.periodic(const Duration(milliseconds: 80), (_) { + _drawSpinner(); + }); + } + + void _stopTimer() { + _timer?.cancel(); + _timer = null; + } + + void _drawSpinner() { + if (scopeStack.isEmpty || !isInteractiveTerminal) return; + final active = scopeStack.last; + _frameIndex++; + final frame = _brailleFrames[_frameIndex % _brailleFrames.length]; + stdout.write('$_clearLine${formatSpinner(frame, active)}'); + } + + void _clearSpinnerLine() { + if (isInteractiveTerminal && scopeStack.isNotEmpty) { + stdout.write(_clearLine); + } + } +} diff --git a/packages/serverpod_logging/lib/src/test_log_writer.dart b/packages/serverpod_logging/lib/src/test_log_writer.dart new file mode 100644 index 0000000..5e7e59d --- /dev/null +++ b/packages/serverpod_logging/lib/src/test_log_writer.dart @@ -0,0 +1,69 @@ +import 'package:meta/meta.dart'; + +import 'log_types.dart'; + +/// A completed scope record for test assertions. +class ClosedScope { + /// Captures the arguments passed to [LogWriter.closeScope]. + ClosedScope({ + required this.scope, + required this.success, + required this.duration, + this.error, + this.stackTrace, + }); + + /// The scope that was closed. + final LogScope scope; + + /// Whether the scope completed successfully. + final bool success; + + /// How long the scope was open. + final Duration duration; + + /// Optional error captured when the scope failed. + final Object? error; + + /// Optional stack trace for [error]. + final StackTrace? stackTrace; +} + +/// A [LogWriter] that collects entries and scopes for test assertions. +@visibleForTesting +class TestLogWriter extends LogWriter { + /// Log entries written via [log], in call order. + final List entries = []; + + /// Scopes opened via [openScope], in call order. + final List openedScopes = []; + + /// Scopes closed via [closeScope], in call order, with their close-time + /// success/error details. + final List closedScopes = []; + + @override + Future log(LogEntry entry) async => entries.add(entry); + + @override + Future openScope(LogScope scope) async => openedScopes.add(scope); + + @override + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }) async { + closedScopes.add( + ClosedScope( + scope: scope, + success: success, + duration: duration, + error: error, + stackTrace: stackTrace, + ), + ); + } +} diff --git a/packages/serverpod_logging/lib/src/text_log_writer.dart b/packages/serverpod_logging/lib/src/text_log_writer.dart new file mode 100644 index 0000000..fbf7225 --- /dev/null +++ b/packages/serverpod_logging/lib/src/text_log_writer.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'log_types.dart'; +import 'spinner_log_writer.dart'; + +bool get _ansiSupported => isInteractiveTerminal && stdout.supportsAnsiEscapes; + +String _green(String text) => _ansiSupported ? '\x1B[92m$text\x1B[0m' : text; +String _yellow(String text) => _ansiSupported ? '\x1B[93m$text\x1B[0m' : text; +String _red(String text) => _ansiSupported ? '\x1B[91m$text\x1B[0m' : text; +String _gray(String text) => _ansiSupported ? '\x1B[90m$text\x1B[0m' : text; +String _cyan(String text) => _ansiSupported ? '\x1B[36m$text\x1B[0m' : text; + +/// A [SpinnerLogWriter] that writes formatted text to stdout/stderr. +/// +/// Log messages are prefixed with the level (DEBUG, WARNING, ERROR). +/// Errors and stack traces are written to stderr. +class TextLogWriter extends SpinnerLogWriter { + @override + void writeLogLine(LogEntry entry) { + final prefix = switch (entry.level) { + LogLevel.debug => _gray('DEBUG: '), + LogLevel.info => '', + LogLevel.warning => _yellow('WARNING: '), + LogLevel.error || LogLevel.fatal => _red('ERROR: '), + }; + final output = '$prefix${entry.message}'; + if (entry.level.index >= LogLevel.error.index) { + stderr.writeln(output); + if (entry.error != null) stderr.writeln('${entry.error}'); + if (entry.stackTrace != null) stderr.writeln('${entry.stackTrace}'); + } else { + stdout.writeln(output); + } + } + + @override + String formatSpinner(String frame, ActiveScope active) { + final elapsed = _gray('(${formatElapsed(active.stopwatch.elapsed)})'); + return '${_cyan(frame)} ${active.scope.label}... $elapsed'; + } + + @override + String formatScopeComplete(LogScope scope, bool success, Duration duration) { + final elapsed = _gray('(${formatElapsed(duration)})'); + final icon = success ? _green('\u2713') : _red('\u2717'); + return '$icon ${scope.label} $elapsed'; + } +} diff --git a/packages/serverpod_logging/pubspec.yaml b/packages/serverpod_logging/pubspec.yaml new file mode 100644 index 0000000..0c5a294 --- /dev/null +++ b/packages/serverpod_logging/pubspec.yaml @@ -0,0 +1,19 @@ +name: serverpod_logging +description: Shared serverpod logging utilities. +version: 0.1.0 +repository: https://github.com/serverpod/serverpod +homepage: https://serverpod.dev +issue_tracker: https://github.com/serverpod/serverpod/issues + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + isolated_object: ^0.1.0 + meta: ^1.15.0 + +dev_dependencies: + serverpod_lints: '>=2.7.0' + test: ^1.25.6 diff --git a/packages/serverpod_logging/test/isolated_log_writer_test.dart b/packages/serverpod_logging/test/isolated_log_writer_test.dart new file mode 100644 index 0000000..8d6fee9 --- /dev/null +++ b/packages/serverpod_logging/test/isolated_log_writer_test.dart @@ -0,0 +1,128 @@ +import 'package:serverpod_logging/serverpod_logging.dart'; +import 'package:test/test.dart'; + +LogEntry _entry(String message) => LogEntry( + time: DateTime.now(), + level: LogLevel.info, + message: message, + scope: LogScope.root('r'), + ); + +/// A writer whose [log] is slow, so a write is reliably in-flight when the +/// isolate is asked to close. +class SlowWriter extends LogWriter { + @override + Future log(LogEntry entry) async => + Future.delayed(const Duration(milliseconds: 100)); + + @override + Future openScope(LogScope scope) async {} + + @override + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }) async {} +} + +/// A writer whose [log] fails after a delay, so a rejecting write is reliably +/// in-flight when the isolate is asked to close. +class SlowFailingWriter extends LogWriter { + @override + Future log(LogEntry entry) async { + await Future.delayed(const Duration(milliseconds: 100)); + throw StateError('write failed'); + } + + @override + Future openScope(LogScope scope) async {} + + @override + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }) async {} +} + +void main() { + group('Given an IsolatedLogWriter wrapping a TestLogWriter', () { + test( + 'when an entry is logged, ' + 'then it is forwarded to the writer running in the isolate', + () async { + final writer = IsolatedLogWriter(() => TestLogWriter()); + + await writer.log(_entry('hi')); + + // Read the isolate-local writer's state back across the boundary. + final messages = await writer.evaluate( + (w) => (w as TestLogWriter).entries.map((e) => e.message).toList(), + ); + expect(messages, ['hi']); + + await writer.close(); + }, + ); + + test( + 'when a scope is opened and closed, ' + 'then both are forwarded to the writer running in the isolate', + () async { + final writer = IsolatedLogWriter(() => TestLogWriter()); + final scope = LogScope.root('op'); + + await writer.openScope(scope); + await writer.closeScope(scope, success: true, duration: Duration.zero); + + final opened = await writer.evaluate( + (w) => (w as TestLogWriter).openedScopes.length, + ); + final closed = await writer.evaluate( + (w) => (w as TestLogWriter).closedScopes.length, + ); + expect(opened, 1); + expect(closed, 1); + + await writer.close(); + }); + }); + + group('Given an IsolatedLogWriter lifecycle', () { + test( + 'when closed with a write in flight, ' + 'then the in-flight write is swallowed and its log future completes', + () async { + final writer = IsolatedLogWriter(() => SlowWriter()); + + final pending = writer.log(_entry('slow')); // intentionally not awaited + await writer.close(); + + await expectLater(pending, completes); + }); + + test( + 'when a wrapped write fails while in flight, ' + 'then close() still completes (does not rethrow the failure)', + () async { + final writer = IsolatedLogWriter(() => SlowFailingWriter()); + + // ignore: unawaited_futures - fire-and-forget, like Log.call. + writer.log(_entry('boom')).catchError((_) {}); + await expectLater(writer.close(), completes); + }, + ); + + test('when log is called after close, then it is a no-op', () async { + final writer = IsolatedLogWriter(() => TestLogWriter()); + await writer.close(); + + await expectLater(writer.log(_entry('x')), completes); + }); + }); +} diff --git a/packages/serverpod_logging/test/log_dispatch_test.dart b/packages/serverpod_logging/test/log_dispatch_test.dart new file mode 100644 index 0000000..31596a7 --- /dev/null +++ b/packages/serverpod_logging/test/log_dispatch_test.dart @@ -0,0 +1,209 @@ +import 'package:serverpod_logging/serverpod_logging.dart'; +import 'package:test/test.dart'; + +/// A writer whose [log] always throws, to exercise the best-effort contract. +class _ThrowingWriter extends LogWriter { + @override + Future log(LogEntry entry) async => throw StateError('writer boom'); + + @override + Future openScope(LogScope scope) async {} + + @override + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }) async {} +} + +/// A writer that records the order in which entries are actually written, +/// after an artificial per-message delay. +class _DelayedOrderWriter extends LogWriter { + _DelayedOrderWriter(this.delayFor); + + final Duration Function(String message) delayFor; + final List writeOrder = []; + + @override + Future log(LogEntry entry) async { + await Future.delayed(delayFor(entry.message)); + writeOrder.add(entry.message); + } + + @override + Future openScope(LogScope scope) async {} + + @override + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }) async {} +} + +LogEntry _entry(LogLevel level, String message) => LogEntry( + time: DateTime.now(), + level: level, + message: message, + scope: LogScope.root('r'), + ); + +void main() { + group('Given a Log with a warning threshold', () { + late TestLogWriter writer; + late Log log; + + setUp(() { + writer = TestLogWriter(); + log = Log(writer, logLevel: LogLevel.warning); + }); + + test( + 'when a below-threshold entry is dispatched, ' + 'then it is dropped and the factory is never invoked', () async { + var factoryInvoked = false; + log(LogLevel.info, () { + factoryInvoked = true; + return _entry(LogLevel.info, 'x'); + }); + await log.flush(); + + expect( + factoryInvoked, + isFalse, + reason: 'gated calls must short-circuit before the factory runs', + ); + expect(writer.entries, isEmpty); + }); + + test( + 'when an at-threshold entry is dispatched, ' + 'then it is forwarded', () async { + log.warning('warn'); + await log.flush(); + + expect(writer.entries.single.message, 'warn'); + }); + + test( + 'when the threshold is lowered at runtime, ' + 'then newly-enabled levels start being forwarded', () async { + log.info('dropped'); + log.logLevel = LogLevel.info; + log.info('kept'); + await log.flush(); + + expect(writer.entries.map((e) => e.message), ['kept']); + }); + }); + + group('Given a Log whose writer throws', () { + test( + 'when an entry is dispatched, ' + 'then dispatch is best-effort and the error is not surfaced', () async { + final log = Log(_ThrowingWriter()); + + log.info('x'); + + await expectLater(log.flush(), completes); + }); + }); + + group('Given a Log with out-of-order write timing', () { + test( + 'when earlier writes are slower than later ones, ' + 'then writes still complete in invocation order', () async { + final writer = _DelayedOrderWriter( + (m) => m == '1' ? const Duration(milliseconds: 40) : Duration.zero, + ); + final log = Log(writer); + + log.info('1'); + log.info('2'); + log.info('3'); + await log.flush(); + + expect( + writer.writeOrder, + ['1', '2', '3'], + reason: 'the _latest chain serializes writes in call order', + ); + }); + }); + + group('Given a closed Log', () { + test( + 'when an entry is dispatched after close, ' + 'then it is dropped', () async { + final writer = TestLogWriter(); + final log = Log(writer); + + log.info('before'); + await log.close(); + log.info('after'); + await log.flush(); + + expect(writer.entries.map((e) => e.message), ['before']); + }); + }); + + group("Given a Log's debug gate (isDebugEnabled)", () { + test('when the level is debug, then it is enabled', () { + expect( + Log(TestLogWriter(), logLevel: LogLevel.debug).isDebugEnabled, + isTrue, + ); + }); + + test('when the level is above debug, then it is disabled', () { + expect( + Log(TestLogWriter(), logLevel: LogLevel.info).isDebugEnabled, + isFalse, + ); + }); + }); + + group('Given a Log at debug level', () { + test( + 'when each convenience method is called, ' + 'then the entry carries the matching LogLevel', () async { + final writer = TestLogWriter(); + final log = Log(writer, logLevel: LogLevel.debug); + + log.debug('d'); + log.info('i'); + log.warning('w'); + log.error('e'); + await log.flush(); + + expect(writer.entries.map((e) => e.level), [ + LogLevel.debug, + LogLevel.info, + LogLevel.warning, + LogLevel.error, + ]); + }); + }); + + group('Given the error() convenience method', () { + test( + 'when it is given an error and stack trace, ' + 'then both are attached to the entry', () async { + final writer = TestLogWriter(); + final log = Log(writer); + final stackTrace = StackTrace.current; + + log.error('boom', error: StateError('x'), stackTrace: stackTrace); + await log.flush(); + + final entry = writer.entries.single; + expect(entry.error, isA()); + expect(entry.stackTrace, same(stackTrace)); + }); + }); +} diff --git a/packages/serverpod_logging/test/log_progress_test.dart b/packages/serverpod_logging/test/log_progress_test.dart new file mode 100644 index 0000000..d7238d3 --- /dev/null +++ b/packages/serverpod_logging/test/log_progress_test.dart @@ -0,0 +1,101 @@ +import 'package:serverpod_logging/serverpod_logging.dart'; +import 'package:test/test.dart'; + +void main() { + group('Given Log.progress with a runner that returns bool', () { + late TestLogWriter writer; + late Log log; + + setUp(() { + writer = TestLogWriter(); + log = Log(writer); + }); + + test( + 'when the runner returns true, ' + 'then the scope closes with success true', () async { + await log.progress('op', () async => true); + + expect(writer.closedScopes, hasLength(1)); + expect(writer.closedScopes.first.success, isTrue); + }); + + test( + 'when the runner returns false, ' + 'then the scope closes with success false', () async { + await log.progress('op', () async => false); + + expect(writer.closedScopes, hasLength(1)); + expect(writer.closedScopes.first.success, isFalse); + }); + }); + + group('Given Log.progress with a runner that returns a non-bool value', () { + late TestLogWriter writer; + late Log log; + + setUp(() { + writer = TestLogWriter(); + log = Log(writer); + }); + + test( + 'when no isSuccess is provided, ' + 'then the scope closes with success true', () async { + await log.progress('op', () async => 'ok'); + + expect(writer.closedScopes.first.success, isTrue); + }); + + test( + 'when isSuccess is provided, ' + 'then the scope uses its verdict', () async { + await log.progress( + 'op', + () async => 'fail', + isSuccess: (r) => r == 'ok', + ); + + expect(writer.closedScopes.first.success, isFalse); + }); + }); + + group('Given Log.progress with a runner that throws', () { + late TestLogWriter writer; + late Log log; + + setUp(() { + writer = TestLogWriter(); + log = Log(writer); + }); + + test( + 'when the runner throws, ' + 'then the scope closes with success false and rethrows', () async { + await expectLater( + log.progress('op', () async => throw StateError('boom')), + throwsA(isA()), + ); + + expect(writer.closedScopes.first.success, isFalse); + expect(writer.closedScopes.first.error, isA()); + }); + + test( + 'when isSuccess would return true but runner throws, ' + 'then throw takes precedence and scope closes with success false', + () async { + await expectLater( + log.progress( + 'op', + () async => throw StateError('boom'), + isSuccess: (_) => true, + ), + throwsA(isA()), + ); + + expect(writer.closedScopes.first.success, isFalse); + }, + ); + }); +} diff --git a/packages/serverpod_logging/test/log_scope_test.dart b/packages/serverpod_logging/test/log_scope_test.dart new file mode 100644 index 0000000..e698bd0 --- /dev/null +++ b/packages/serverpod_logging/test/log_scope_test.dart @@ -0,0 +1,53 @@ +import 'package:serverpod_logging/serverpod_logging.dart'; +import 'package:test/test.dart'; + +void main() { + group('Given a Log with no active progress scope', () { + late TestLogWriter writer; + late Log log; + + setUp(() { + writer = TestLogWriter(); + log = Log(writer); + }); + + test( + 'when a log is emitted, ' + 'then it attaches to the synthetic root scope', () async { + log.info('orphan'); + await log.flush(); + + expect(writer.entries.single.scope.label, 'unknown'); + }); + + test( + 'when progress is given metadata, ' + 'then the opened scope carries it', () async { + await log.progress('op', () async => true, metadata: {'k': 'v'}); + + expect(writer.openedScopes.single.metadata, {'k': 'v'}); + }); + }); + + group('Given a root LogScope', () { + test('when it is created, then it has no parent', () { + expect(LogScope.root('root').parent, isNull); + }); + }); + + group('Given a child LogScope', () { + test( + 'when it is created, ' + 'then it references its parent and keeps its own id, label and metadata', + () { + final root = LogScope.root('root'); + final child = root.child(id: 'c1', label: 'child', metadata: {'a': 1}); + + expect(child.parent, same(root)); + expect(child.id, 'c1'); + expect(child.label, 'child'); + expect(child.metadata, {'a': 1}); + }, + ); + }); +} diff --git a/packages/serverpod_logging/test/multi_log_writer_test.dart b/packages/serverpod_logging/test/multi_log_writer_test.dart new file mode 100644 index 0000000..75c918c --- /dev/null +++ b/packages/serverpod_logging/test/multi_log_writer_test.dart @@ -0,0 +1,97 @@ +import 'package:serverpod_logging/serverpod_logging.dart'; +import 'package:test/test.dart'; + +LogEntry _entry(String message) => LogEntry( + time: DateTime.now(), + level: LogLevel.info, + message: message, + scope: LogScope.root('r'), + ); + +/// Minimal writer that only records whether [close] was called, so the +/// close fan-out can be observed (the base [LogWriter.close] is a no-op). +class _CloseTrackingWriter extends LogWriter { + bool closed = false; + + @override + Future log(LogEntry entry) async {} + + @override + Future openScope(LogScope scope) async {} + + @override + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }) async {} + + @override + Future close() async => closed = true; +} + +void main() { + group('Given a MultiLogWriter', () { + test( + 'when constructed with writers, ' + 'then log/openScope/closeScope fan out to all of them', () async { + final a = TestLogWriter(); + final b = TestLogWriter(); + final multi = MultiLogWriter([a, b]); + final scope = LogScope.root('s'); + + await multi.openScope(scope); + await multi.log(_entry('hi')); + await multi.closeScope(scope, success: true, duration: Duration.zero); + + for (final w in [a, b]) { + expect(w.openedScopes, hasLength(1)); + expect(w.entries.single.message, 'hi'); + expect(w.closedScopes.single.success, isTrue); + } + }); + + test( + 'when a writer is added after construction, ' + 'then it receives subsequent entries', () async { + final added = TestLogWriter(); + final multi = MultiLogWriter([]); + + multi.add(added); + await multi.log(_entry('after-add')); + + expect( + added.entries.single.message, + 'after-add', + reason: 'add() must actually wire the writer into the chain', + ); + }); + + test( + 'when a writer is removed, ' + 'then it stops receiving entries and remove reports its presence', + () async { + final w = TestLogWriter(); + final multi = MultiLogWriter([w]); + + expect(multi.remove(w), isTrue); + await multi.log(_entry('after-remove')); + expect(w.entries, isEmpty); + + expect(multi.remove(w), isFalse, reason: 'already removed'); + }, + ); + + test('when closed, then close fans out to all writers', () async { + final a = _CloseTrackingWriter(); + final b = _CloseTrackingWriter(); + + await MultiLogWriter([a, b]).close(); + + expect(a.closed, isTrue); + expect(b.closed, isTrue); + }); + }); +} diff --git a/packages/serverpod_logging/test/progress_nesting_test.dart b/packages/serverpod_logging/test/progress_nesting_test.dart new file mode 100644 index 0000000..1d0a1fd --- /dev/null +++ b/packages/serverpod_logging/test/progress_nesting_test.dart @@ -0,0 +1,62 @@ +import 'package:serverpod_logging/serverpod_logging.dart'; +import 'package:test/test.dart'; + +/// Regression guard for the Zone-based scoping contract of [Log.progress]. +/// +/// The whole point of [Log.progress] is documented as: +/// "Log calls inside the runner are automatically scoped via the Zone." +/// +/// A `log.info(...)` emitted from within the runner must be attributed +/// to the progress scope, not to the enclosing/root scope. This is a pure +/// framework property and has nothing to do with how (or whether) the scope +/// is rendered. +void main() { + group('Given Log.progress with a runner that logs', () { + late TestLogWriter writer; + late Log log; + + setUp(() { + writer = TestLogWriter(); + log = Log(writer); + }); + + test( + 'when the runner emits a log entry, ' + 'then that entry is scoped to the progress scope', () async { + await log.progress('outer', () async { + log.info('inside'); + }); + await log.flush(); + + final opened = writer.openedScopes.single; + expect(opened.label, 'outer'); + + final inside = writer.entries.singleWhere((e) => e.message == 'inside'); + expect( + inside.scope.id, + opened.id, + reason: 'a log emitted inside the runner must reference the ' + 'progress scope, via the Zone', + ); + expect(inside.scope.label, 'outer'); + }); + + test( + 'when the runner opens a nested progress scope, ' + 'then the inner scope is a child of the outer scope', () async { + await log.progress('outer', () async { + await log.progress('inner', () async => true); + }); + await log.flush(); + + final outer = writer.openedScopes.firstWhere((s) => s.label == 'outer'); + final inner = writer.openedScopes.firstWhere((s) => s.label == 'inner'); + expect( + inner.parent?.id, + outer.id, + reason: 'the inner progress scope must nest under the outer one, ' + 'which only works if the runner executes inside the outer Zone', + ); + }); + }); +} diff --git a/packages/serverpod_logging/test/progress_relabel_test.dart b/packages/serverpod_logging/test/progress_relabel_test.dart new file mode 100644 index 0000000..b1e1aa6 --- /dev/null +++ b/packages/serverpod_logging/test/progress_relabel_test.dart @@ -0,0 +1,74 @@ +import 'package:serverpod_logging/serverpod_logging.dart'; +import 'package:test/test.dart'; + +/// A writer that records every scope label it is ever asked to render, +/// regardless of which lifecycle hook delivered it. +/// +/// This is deliberately a *generic* sink (think: a JSON line writer, a DB +/// writer, an OTEL exporter) - it does not animate spinners or care about +/// terminals. It exists to pin a framework-level contract: +/// +/// `Log.progress(label, runner)` describes ONE operation called [label]. +/// The runner's *return value* is data, not a new name for the operation. +/// +class _LabelRecordingWriter extends LogWriter { + final List observedLabels = []; + + @override + Future log(LogEntry entry) async {} + + @override + Future openScope(LogScope scope) async => + observedLabels.add(scope.label); + + @override + Future closeScope( + LogScope scope, { + required bool success, + required Duration duration, + Object? error, + StackTrace? stackTrace, + }) async => + observedLabels.add(scope.label); +} + +void main() { + group('Given Log.progress with a plain (non-stream) runner', () { + late _LabelRecordingWriter writer; + late Log log; + + setUp(() { + writer = _LabelRecordingWriter(); + log = Log(writer); + }); + + test( + 'when the runner returns a value, ' + 'then no scope event is labelled with that value', () async { + await log.progress('op', () async => 'sentinel-result'); + await log.flush(); + + expect( + writer.observedLabels, + everyElement('op'), + reason: 'progress must not relabel the scope with the runner result; ' + 'the operation is called "op" from start to finish', + ); + expect( + writer.observedLabels, + isNot(contains('sentinel-result')), + reason: 'the runner return value must never surface as a scope label', + ); + }); + + test( + 'when the runner returns true, ' + 'then the scope label stays "op" (not "true")', () async { + await log.progress('op', () async => true); + await log.flush(); + + expect(writer.observedLabels, everyElement('op')); + expect(writer.observedLabels, isNot(contains('true'))); + }); + }); +} diff --git a/packages/serverpod_logging/test/progress_sync_runner_test.dart b/packages/serverpod_logging/test/progress_sync_runner_test.dart new file mode 100644 index 0000000..e55a601 --- /dev/null +++ b/packages/serverpod_logging/test/progress_sync_runner_test.dart @@ -0,0 +1,72 @@ +import 'package:serverpod_logging/serverpod_logging.dart'; +import 'package:test/test.dart'; + +/// [Log.progress] accepts a `FutureOr Function()` runner, so a plain +/// synchronous callback (`() => value`, not `() async => value`) is a +/// supported call shape. These tests pin that contract. +void main() { + group('Given Log.progress with a synchronous runner', () { + late TestLogWriter writer; + late Log log; + + setUp(() { + writer = TestLogWriter(); + log = Log(writer); + }); + + test( + 'when the runner returns true synchronously, ' + 'then the scope closes with success true', () async { + final result = await log.progress('op', () => true); + + expect(result, isTrue); + expect(writer.closedScopes.single.success, isTrue); + }); + + test( + 'when the runner returns false synchronously, ' + 'then the scope closes with success false', () async { + final result = await log.progress('op', () => false); + + expect(result, isFalse); + expect(writer.closedScopes.single.success, isFalse); + }); + + test( + 'when a synchronous runner returns a non-bool value, ' + 'then the value is returned and the scope closes successfully', + () async { + final result = await log.progress('op', () => 'ok'); + + expect(result, 'ok'); + expect(writer.closedScopes.single.success, isTrue); + }, + ); + + test( + 'when a synchronous runner emits a log entry, ' + 'then that entry is scoped to the progress scope', () async { + await log.progress('op', () { + log.info('inside'); + return true; + }); + await log.flush(); + + final opened = writer.openedScopes.single; + final inside = writer.entries.singleWhere((e) => e.message == 'inside'); + expect(inside.scope.id, opened.id); + }); + + test( + 'when a synchronous runner throws, ' + 'then the scope closes with success false and rethrows', () async { + await expectLater( + log.progress('op', () => throw StateError('boom')), + throwsA(isA()), + ); + + expect(writer.closedScopes.single.success, isFalse); + expect(writer.closedScopes.single.error, isA()); + }); + }); +} diff --git a/packages/serverpod_logging_cli/CHANGELOG.md b/packages/serverpod_logging_cli/CHANGELOG.md new file mode 100644 index 0000000..a0712a7 --- /dev/null +++ b/packages/serverpod_logging_cli/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial version. diff --git a/packages/serverpod_logging_cli/LICENSE b/packages/serverpod_logging_cli/LICENSE new file mode 100644 index 0000000..dcdffe5 --- /dev/null +++ b/packages/serverpod_logging_cli/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, Serverpod + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/serverpod_logging_cli/README.md b/packages/serverpod_logging_cli/README.md new file mode 100644 index 0000000..29e8897 --- /dev/null +++ b/packages/serverpod_logging_cli/README.md @@ -0,0 +1,25 @@ +# serverpod_logging_cli + +A [`cli_tools`](https://pub.dev/packages/cli_tools) `Logger` implementation +backed by the [`serverpod_logging`](https://pub.dev/packages/serverpod_logging) +`LogWriter` architecture. It renders scoped logs and progress to the terminal +while letting output fan out to any number of backends (terminal, file, +database, …) via `LogWriter` / `MultiLogWriter`. + +## Usage + +```dart +import 'package:serverpod_logging_cli/serverpod_logging_cli.dart'; + +final logger = ServerpodCliLogger(StdOutLogWriter()); + +logger.info('Server starting'); +await logger.progress('Migrating', () async => true); + +await logger.flush(); +``` + +`progressStream` is modelled as a parent operation with one nested +sub-operation per stream event - each a real, independently-timed scope - so +the structure stays meaningful for non-terminal writers (a database writer +records the parent span plus a child span per event). diff --git a/packages/serverpod_logging_cli/analysis_options.yaml b/packages/serverpod_logging_cli/analysis_options.yaml new file mode 100644 index 0000000..c249a90 --- /dev/null +++ b/packages/serverpod_logging_cli/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:serverpod_lints/cli.yaml + +analyzer: + language: + strict-raw-types: false + errors: + inference_failure_on_instance_creation: ignore + inference_failure_on_function_invocation: ignore + inference_failure_on_untyped_parameter: ignore + prefer_final_parameters: ignore + +linter: + rules: + prefer_relative_imports: true diff --git a/packages/serverpod_logging_cli/lib/serverpod_logging_cli.dart b/packages/serverpod_logging_cli/lib/serverpod_logging_cli.dart new file mode 100644 index 0000000..f75cac7 --- /dev/null +++ b/packages/serverpod_logging_cli/lib/serverpod_logging_cli.dart @@ -0,0 +1,10 @@ +/// A cli_tools `Logger` bridge for the serverpod_logging architecture. +/// +/// This package sits on top of `serverpod_logging` (the dependency-free core) +/// and `cli_tools` (the CLI toolkit), wiring the two together. Keeping it in +/// its own package means the logging core never takes on the cli_tools +/// dependency, and the cli_tools toolkit never learns about serverpod. +library; + +export 'src/serverpod_cli_logger.dart'; +export 'src/std_out_log_writer.dart'; diff --git a/packages/serverpod_logging_cli/lib/src/serverpod_cli_logger.dart b/packages/serverpod_logging_cli/lib/src/serverpod_cli_logger.dart new file mode 100644 index 0000000..267eb01 --- /dev/null +++ b/packages/serverpod_logging_cli/lib/src/serverpod_cli_logger.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:cli_tools/cli_tools.dart' as cli; +import 'package:serverpod_logging/serverpod_logging.dart'; + +import 'std_out_log_writer.dart'; + +/// A [cli.Logger] that delegates to a [Log]. +/// +/// This bridges the [cli.Logger] interface with the [LogWriter] architecture, +/// allowing multi-backend logging (terminal, TUI, file, database, etc.) via +/// [LogWriter] and [MultiLogWriter]. +/// +/// [cli.LogType] is preserved by stashing it in [LogEntry.metadata] under +/// [logTypeKey], so writers like [StdOutLogWriter] can format accordingly. +class ServerpodCliLogger extends cli.Logger { + final Log _log; + final LogWriter _writer; + + ServerpodCliLogger(LogWriter writer, {LogLevel logLevel = LogLevel.info}) + : _writer = writer, + _log = Log(writer, logLevel: logLevel), + super(toCliLogLevel(logLevel)); + + /// Releases any resources held by the underlying writer. + Future close() async { + await _log.close(); + await _writer.close(); + } + + @override + set logLevel(cli.LogLevel level) { + super.logLevel = level; + if (level == cli.LogLevel.nothing) return; + _log.logLevel = _mapLogLevel(level); + } + + @override + int? get wrapTextColumn => stdout.hasTerminal ? stdout.terminalColumns : null; + + @override + void debug( + String message, { + bool newParagraph = false, + cli.LogType type = cli.TextLogType.normal, + }) => + _call(LogLevel.debug, message, type: type); + + @override + void info( + String message, { + bool newParagraph = false, + cli.LogType type = cli.TextLogType.normal, + }) => + _call(LogLevel.info, message, type: type); + + @override + void warning( + String message, { + bool newParagraph = false, + cli.LogType type = cli.TextLogType.normal, + }) => + _call(LogLevel.warning, message, type: type); + + @override + void error( + String message, { + bool newParagraph = false, + StackTrace? stackTrace, + cli.LogType type = cli.TextLogType.normal, + }) => + _call(LogLevel.error, message, stackTrace: stackTrace, type: type); + + @override + void log( + String message, + cli.LogLevel level, { + bool newParagraph = false, + cli.LogType type = cli.TextLogType.normal, + }) { + // `nothing` is the "off" sentinel, not a message level - suppress rather + // than map it (mapping would throw on this otherwise-valid enum value). + if (level == cli.LogLevel.nothing) return; + _call(_mapLogLevel(level), message, type: type); + } + + @override + void write( + String message, + cli.LogLevel logLevel, { + bool newParagraph = false, + bool newLine = true, + }) { + if (logLevel == cli.LogLevel.nothing) return; + _call(_mapLogLevel(logLevel), message); + } + + @override + Future progress( + String message, + Future Function() runner, { + bool newParagraph = false, + String? successMessage, + }) { + if (_silent) return runner(); + return _log.progress(message, runner); + } + + /// Renders [stream] as a parent operation with one nested sub-operation per + /// event. Each sub-operation stays open until the next event arrives (or the + /// stream ends), so its elapsed time is the gap between events. The parent + /// closes when the stream completes. + /// + /// Returns the last event. Rethrows a stream error (failing the open + /// sub-operation and the parent). Throws [StateError] if the stream is empty. + @override + Future progressStream( + String initialMessage, + Stream stream, { + String Function(T)? toMessage, + bool Function(T)? isSuccess, + bool newParagraph = false, + }) async { + if (_silent) return _drain(stream); + + T? last; + return _log.progress( + initialMessage, + () async { + final events = StreamIterator(stream); + try { + if (!await events.moveNext()) { + throw StateError(_noEventsMessage); + } + while (true) { + final event = events.current; + last = event; + final label = toMessage?.call(event) ?? event.toString(); + // The sub-operation's lifetime is the wait for the next event. + var hasMore = false; + await _log.progress(label, () async { + hasMore = await events.moveNext(); + return true; + }); + if (!hasMore) break; + } + } finally { + await events.cancel(); + } + return last as T; + }, + // Let Log own the success verdict + isSuccess: isSuccess ?? (_) => true, + ); + } + + @override + Future flush() => _log.flush(); + + /// Consumes [stream] without rendering, honoring the same return/throw + /// contract as [progressStream] (used when output is silenced). + static Future _drain(Stream stream) async { + T? last; + var hasEvent = false; + await for (final event in stream) { + hasEvent = true; + last = event; + } + if (!hasEvent) throw StateError(_noEventsMessage); + return last as T; + } + + static const _noEventsMessage = 'No events in stream'; + + void _call( + LogLevel level, + String message, { + StackTrace? stackTrace, + cli.LogType? type, + }) { + if (_silent) return; + _log( + level, + () => LogEntry( + time: DateTime.now(), + level: level, + message: message, + scope: _log.currentScope, + stackTrace: stackTrace, + metadata: type != null ? {logTypeKey: type} : null, + ), + ); + } + + // serverpod_logging LogLevel has no "nothing" sentinel - its lowest-passing + // level is fatal, and the filter check is strict-`<` so fatal still leaks + // through. Progress / scope events bypass logLevel entirely. So when the + // caller sets cli.LogLevel.nothing we gate at the bridge instead of mapping + // through to _log.logLevel. + bool get _silent => super.logLevel == cli.LogLevel.nothing; + + static LogLevel _mapLogLevel(cli.LogLevel level) => switch (level) { + cli.LogLevel.debug => LogLevel.debug, + cli.LogLevel.info => LogLevel.info, + cli.LogLevel.warning => LogLevel.warning, + cli.LogLevel.error => LogLevel.error, + // The setter early-returns before reaching here, so this branch only + // fires from log() / write() being called with nothing as a message + // level - which is a programmer error worth surfacing. + cli.LogLevel.nothing => throw ArgumentError.value( + level, + 'level', + 'cli.LogLevel.nothing is a filter sentinel; it cannot be used as a ' + 'message level', + ), + }; +} diff --git a/packages/serverpod_logging_cli/lib/src/std_out_log_writer.dart b/packages/serverpod_logging_cli/lib/src/std_out_log_writer.dart new file mode 100644 index 0000000..4d2e8b0 --- /dev/null +++ b/packages/serverpod_logging_cli/lib/src/std_out_log_writer.dart @@ -0,0 +1,40 @@ +import 'package:cli_tools/cli_tools.dart' as cli; +import 'package:serverpod_logging/serverpod_logging.dart'; + +/// Metadata key used to pass [cli.LogType] through [LogEntry.metadata]. +const logTypeKey = 'serverpod:logType'; + +/// Maps a [LogLevel] to the [cli.LogLevel]. +cli.LogLevel toCliLogLevel(LogLevel level) => switch (level) { + LogLevel.debug => cli.LogLevel.debug, + LogLevel.info => cli.LogLevel.info, + LogLevel.warning => cli.LogLevel.warning, + LogLevel.error || LogLevel.fatal => cli.LogLevel.error, + }; + +/// A [TextLogWriter] that renders log lines through a [cli.StdOutLogger], +/// adding [cli.LogType]-aware formatting, ie. bullets, headers, boxes, Windows +/// emoji replacements, etc. +class StdOutLogWriter extends TextLogWriter { + final cli.StdOutLogger _logger; + + StdOutLogWriter({Map? replacements}) + : _logger = cli.StdOutLogger( + // Accept all levels - filtering is done by Log, not the writer. + cli.LogLevel.debug, + replacements: replacements, + // Match TextLogWriter: errors and above go to stderr. + logToStderrLevelThreshold: cli.LogLevel.error, + ); + + @override + void writeLogLine(LogEntry entry) { + final type = + entry.metadata?[logTypeKey] as cli.LogType? ?? cli.TextLogType.normal; + if (entry.level == LogLevel.error || entry.level == LogLevel.fatal) { + _logger.error(entry.message, stackTrace: entry.stackTrace, type: type); + } else { + _logger.log(entry.message, toCliLogLevel(entry.level), type: type); + } + } +} diff --git a/packages/serverpod_logging_cli/pubspec.yaml b/packages/serverpod_logging_cli/pubspec.yaml new file mode 100644 index 0000000..247a735 --- /dev/null +++ b/packages/serverpod_logging_cli/pubspec.yaml @@ -0,0 +1,21 @@ +name: serverpod_logging_cli +description: >- + A cli_tools.Logger bridge for the serverpod_logging architecture, rendering + scoped logs and progress to the terminal. +version: 0.1.0 +repository: https://github.com/serverpod/serverpod +homepage: https://serverpod.dev +issue_tracker: https://github.com/serverpod/serverpod/issues + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + cli_tools: ^0.13.1 + serverpod_logging: ^0.1.0 + +dev_dependencies: + serverpod_lints: '>=2.7.0' + test: ^1.25.6 diff --git a/packages/serverpod_logging_cli/test/serverpod_cli_logger_test.dart b/packages/serverpod_logging_cli/test/serverpod_cli_logger_test.dart new file mode 100644 index 0000000..a69f8ef --- /dev/null +++ b/packages/serverpod_logging_cli/test/serverpod_cli_logger_test.dart @@ -0,0 +1,209 @@ +import 'package:cli_tools/cli_tools.dart' as cli; +import 'package:serverpod_logging/serverpod_logging.dart'; +import 'package:serverpod_logging_cli/serverpod_logging_cli.dart'; +import 'package:test/test.dart'; + +Stream _failingStream(List before, Object error) async* { + for (final e in before) { + yield e; + } + throw error; +} + +void main() { + group('Given a ServerpodCliLogger over a TestLogWriter', () { + late TestLogWriter writer; + late ServerpodCliLogger logger; + + setUp(() { + writer = TestLogWriter(); + logger = ServerpodCliLogger(writer); + }); + + test( + 'when progress runs a runner that returns true, ' + 'then it opens and closes exactly one scope marked successful', + () async { + final result = await logger.progress('op', () async => true); + + expect(result, isTrue); + expect(writer.openedScopes.map((s) => s.label), ['op']); + expect(writer.closedScopes.single.success, isTrue); + }); + + test( + 'when progress runs a runner that returns false, ' + 'then the scope closes unsuccessfully', () async { + final result = await logger.progress('op', () async => false); + + expect(result, isFalse); + expect(writer.closedScopes.single.success, isFalse); + }); + + test( + 'when progressStream consumes a multi-event stream, ' + 'then each event is a sub-operation nested under the parent ' + '(the "db shape": one parent span, one child span per event)', + () async { + final result = await logger.progressStream( + 'Deploying', + Stream.fromIterable(['build', 'deploy']), + toMessage: (s) => s, + ); + + expect(result, 'deploy'); + + // Parent opens first, then a child per event. + expect(writer.openedScopes.map((s) => s.label), [ + 'Deploying', + 'build', + 'deploy', + ]); + + final parent = writer.openedScopes.first; + final children = writer.openedScopes.skip(1); + expect( + children.map((s) => s.parent?.id), + everyElement(parent.id), + reason: 'every event scope must nest under the parent operation', + ); + + // Children close before the parent; nothing is relabeled. + expect(writer.closedScopes.map((c) => c.scope.label), [ + 'build', + 'deploy', + 'Deploying', + ]); + expect(writer.closedScopes.map((c) => c.success), everyElement(isTrue)); + }); + + test( + 'when progressStream consumes a single event, ' + 'then it behaves like one sub-operation and returns the event', + () async { + final result = await logger.progressStream('P', Stream.value(42)); + + expect(result, 42); + expect(writer.openedScopes.map((s) => s.label), ['P', '42']); + expect(writer.closedScopes.map((c) => c.scope.label), ['42', 'P']); + }); + + test( + 'when progressStream is given an isSuccess, ' + 'then it decides the parent verdict while sub-operations stay successful', + () async { + await logger.progressStream( + 'P', + Stream.fromIterable(['a', 'b']), + toMessage: (s) => s, + isSuccess: (s) => s == 'never', + ); + + final byLabel = { + for (final c in writer.closedScopes) c.scope.label: c.success, + }; + expect(byLabel['a'], isTrue); + expect(byLabel['b'], isTrue); + expect(byLabel['P'], isFalse, reason: 'isSuccess(last) == false'); + }); + + test( + 'when progressStream consumes a bool stream ending in false without ' + 'isSuccess, then it still succeeds (the last event is not coerced)', + () async { + final result = await logger.progressStream( + 'P', + Stream.fromIterable([true, false]), + ); + + expect(result, isFalse, reason: 'returns the last event'); + final parent = writer.closedScopes.firstWhere( + (c) => c.scope.label == 'P', + ); + expect( + parent.success, + isTrue, + reason: 'stream completed without error -> success', + ); + }); + + test( + 'when the stream errors, ' + 'then the open sub-operation and the parent fail and it is rethrown', + () async { + await expectLater( + logger.progressStream( + 'P', + _failingStream(['a'], Exception('boom')), + toMessage: (s) => s, + ), + throwsA(isA()), + ); + + final byLabel = {for (final c in writer.closedScopes) c.scope.label: c}; + expect(byLabel['a']!.success, isFalse); + expect(byLabel['a']!.error, isA()); + expect(byLabel['P']!.success, isFalse); + }); + + test( + 'when progressStream consumes an empty stream, ' + 'then it throws StateError and fails the parent only', () async { + await expectLater( + logger.progressStream('P', const Stream.empty()), + throwsA(isA()), + ); + + expect(writer.openedScopes.map((s) => s.label), ['P']); + expect(writer.closedScopes.single.success, isFalse); + }); + + test( + 'when info() is given a cli LogType, ' + 'then the entry carries it in metadata', () async { + logger.info('hello', type: cli.TextLogType.bullet); + await logger.flush(); + + final entry = writer.entries.single; + expect(entry.message, 'hello'); + expect(entry.metadata?[logTypeKey], cli.TextLogType.bullet); + }); + + test( + 'when log/write is called with cli.LogLevel.nothing, ' + 'then it is suppressed, not an error', () async { + logger.log('x', cli.LogLevel.nothing); + logger.write('y', cli.LogLevel.nothing); + await logger.flush(); + + expect(writer.entries, isEmpty); + }); + + test( + 'when error() is given a stack trace, ' + 'then it stays structured on the entry', () async { + final st = StackTrace.current; + logger.error('bad', stackTrace: st); + await logger.flush(); + + final entry = writer.entries.single; + expect(entry.level, LogLevel.error); + expect(entry.message, 'bad', reason: 'message is not flattened'); + expect(entry.stackTrace, same(st)); + }); + + test( + 'when the logger is silenced (cli.LogLevel.nothing), ' + 'then nothing is logged or scoped (the runner still runs)', () async { + logger.logLevel = cli.LogLevel.nothing; + + logger.info('dropped'); + final result = await logger.progress('op', () async => true); + await logger.flush(); + + expect(result, isTrue, reason: 'runner still runs while silent'); + expect(writer.entries, isEmpty); + expect(writer.openedScopes, isEmpty); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 125a892..b874168 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,6 +5,13 @@ repository: https://github.com/serverpod/cli_tools homepage: https://serverpod.dev issue_tracker: https://github.com/serverpod/cli_tools/issues +workspace: + - packages/config + - packages/cli_tools + - packages/serverpod_logging + - packages/isolated_object + - packages/serverpod_logging_cli + topics: - cli - tools @@ -13,8 +20,28 @@ topics: - yaml environment: - sdk: ">=3.3.0 <4.0.0" + sdk: ^3.6.0 dev_dependencies: - melos: '>=6.3.3 <7.1.0' + melos: '>=6.3.3 <8.0.0' serverpod_lints: '>=2.7.0' + +melos: + ide: + intellij: + enabled: false + + command: + version: + workspaceChangelog: false + + scripts: + analyze: + description: "Run dart analyze" + run: dart analyze --fatal-infos + + test: + description: "Run all tests" + run: dart test + exec: + concurrency: 1