Repository: VeryGoodOpenSource/very_good_cli (dart)

Issue:
fix: `very_good test` failed (with exception)
```
eseidel@erics-mbp shorebird % very_good test -r    
...
Running "flutter test" in /Users/eseidel/Documents/GitHub/_shorebird/shorebird/bin/cache/flutter/examples...
✓ Optimizing tests (58ms)
Failed to load "/Users/eseidel/Documents/GitHub/_shorebird/shorebird/bin/cache/flutter/examples/api/test/.test_optimizer.dart": type 'FlutterLocalFileComparator' is not a subtype of type 'LocalFileComparator' in type cast
test/.test_optimizer.dart 371:47  _TestOptimizationAwareGoldenFileComparator._goldenFilePaths
test/.test_optimizer.dart 361:27  new _TestOptimizationAwareGoldenFileComparator
test/.test_optimizer.dart 182:26  main

Unhandled exception:
Null check operator used on a null value
#0      _actualTestPath (package:very_good_cli/src/cli/flutter_cli.dart:502:62)
#1      _flutterTest.<anonymous closure> (package:very_good_cli/src/cli/flutter_cli.dart:398:13)
#2      _RootZone.runUnaryGuarded (dart:async/zone.dart:1593:10)
#3      _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:339:11)
#4      _DelayedData.perform (dart:async/stream_impl.dart:515:14)
#5      _PendingEvents.handleNext (dart:async/stream_impl.dart:620:11)
#6      _PendingEvents.schedule.<anonymous closure> (dart:async/stream_impl.dart:591:7)
#7      _microtaskLoop (dart:async/schedule_microtask.dart:40:21)
#8      _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#9      _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:123:13)
#10     _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:193:5)
```

I'm not sure what "optimizationApplied" means, but it seems to use a value which is not null-checked:
https://github.com/VeryGoodOpenSource/very_good_cli/blob/main/lib/src/cli/flutter_cli.dart#L502

Pull request:
<!--
  Thanks for contributing!

  Provide a description of your changes below and a general summary in the title

  Please look at the following checklist to ensure that your PR can be accepted quickly:
-->

## Description

fix: dont crash on test compilation error 

fixes #727

<!--- Describe your changes in detail -->

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ] ✨ New feature (non-breaking change which adds functionality)
- [x] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 🧹 Code refactor
- [ ] ✅ Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore

Patch:
diff --git a/.github/workflows/very_good_cli.yaml b/.github/workflows/very_good_cli.yaml
index 175dd3aae..8cb184e2b 100644
--- a/.github/workflows/very_good_cli.yaml
+++ b/.github/workflows/very_good_cli.yaml
@@ -46,6 +46,7 @@ jobs:
 
       - name: Check Code Coverage
         uses: VeryGoodOpenSource/very_good_coverage@v2.1.0
+
   pana:
     runs-on: ubuntu-latest
 
diff --git a/lib/src/cli/flutter_cli.dart b/lib/src/cli/flutter_cli.dart
index 736f7ec7a..2cece1afb 100644
--- a/lib/src/cli/flutter_cli.dart
+++ b/lib/src/cli/flutter_cli.dart
@@ -388,16 +388,29 @@ Future<int> _flutterTest({
         if (event.stackTrace.trim().isNotEmpty) {
           stderr('$clearLine${event.stackTrace}');
         }
+
         final test = tests[event.testID]!;
         final suite = suites[test.suiteID]!;
         final prefix = event.isFailure ? '[FAILED]' : '[ERROR]';
 
         final optimizationApplied = _isOptimizationApplied(suite);
-        final topGroupName = _topGroupName(test, groups);
-        final testPath =
-            _actualTestPath(optimizationApplied, suite.path!, topGroupName);
-        final testName =
-            _actualTestName(optimizationApplied, test.name, topGroupName);
+
+        var testPath = suite.path!;
+        var testName = test.name;
+
+        // When there is a test error before any group is computed, it means
+        // that there is an error when compiling the test optimizer file.
+        if (optimizationApplied && groups.isNotEmpty) {
+          final topGroupName = _topGroupName(test, groups)!;
+
+          testPath = testPath.replaceFirst(
+            _testOptimizerFileName,
+            topGroupName,
+          );
+
+          testName = testName.replaceFirst(topGroupName, '').trim();
+        }
+
         final relativeTestPath = p.relative(testPath, from: cwd);
         failedTestErrorMessages[relativeTestPath] = [
           ...failedTestErrorMessages[relativeTestPath] ?? [],
@@ -411,11 +424,18 @@ Future<int> _flutterTest({
         final test = tests[event.testID]!;
         final suite = suites[test.suiteID]!;
         final optimizationApplied = _isOptimizationApplied(suite);
-        final firstGroupName = _topGroupName(test, groups);
-        final testPath =
-            _actualTestPath(optimizationApplied, suite.path!, firstGroupName);
-        final testName =
-            _actualTestName(optimizationApplied, test.name, firstGroupName);
+
+        var testPath = suite.path!;
+        var testName = test.name;
+
+        if (optimizationApplied) {
+          final firstGroupName = _topGroupName(test, groups) ?? '';
+          testPath = testPath.replaceFirst(
+            _testOptimizerFileName,
+            firstGroupName,
+          );
+          testName = testName.replaceFirst(firstGroupName, '').trim();
+        }
 
         if (event.skipped) {
           stdout(
@@ -493,22 +513,6 @@ String? _topGroupName(Test test, Map<int, TestGroup> groups) => test.groupIDs
     .map((groupID) => groups[groupID]?.name)
     .firstWhereOrNull((groupName) => groupName?.isNotEmpty ?? false);
 
-String _actualTestPath(
-  bool optimizationApplied,
-  String path,
-  String? groupName,
-) =>
-    optimizationApplied
-        ? path.replaceFirst(_testOptimizerFileName, groupName!)
-        : path;
-
-String _actualTestName(
-  bool optimizationApplied,
-  String name,
-  String? topGroupName,
-) =>
-    optimizationApplied ? name.replaceFirst(topGroupName!, '').trim() : name;
-
 final int _lineLength = () {
   try {
     return stdout.terminalColumns;
@@ -562,6 +566,7 @@ extension on String {
     return '...$truncated';
   }
 
-  String toSingleLine() =>
-      replaceAll('\n', '').replaceAll(RegExp(r'\s\s+'), ' ');
+  String toSingleLine() {
+    return replaceAll('\n', '').replaceAll(RegExp(r'\s\s+'), ' ');
+  }
 }


Test patch:
diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml
index 7f4233ea2..b1089e0d8 100644
--- a/.github/workflows/e2e.yaml
+++ b/.github/workflows/e2e.yaml
@@ -31,6 +31,7 @@ jobs:
         test:
           # E2E tests for the test command
           - test/commands/test/async_main/async_main_test.dart
+          - test/commands/test/compilation_error/compilation_error_test.dart
           - test/commands/test/no_project/no_project_test.dart
           - test/commands/test/spaced_golden_file_name/spaced_golden_file_name_test.dart
 
diff --git a/e2e/test/commands/test/compilation_error/compilation_error_test.dart b/e2e/test/commands/test/compilation_error/compilation_error_test.dart
new file mode 100644
index 000000000..fd3561236
--- /dev/null
+++ b/e2e/test/commands/test/compilation_error/compilation_error_test.dart
@@ -0,0 +1,42 @@
+import 'package:mason/mason.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:test/test.dart';
+import 'package:universal_io/io.dart';
+
+import '../../../../helpers/helpers.dart';
+
+void main() {
+  test(
+    'fails when there is a compilation error, but does not crash',
+    timeout: const Timeout(Duration(minutes: 2)),
+    withRunner((commandRunner, logger, updater, logs) async {
+      final tempDirectory =
+          Directory.systemTemp.createTempSync('compilation_error');
+      addTearDown(() => tempDirectory.deleteSync(recursive: true));
+
+      await copyDirectory(
+        Directory('test/commands/test/compilation_error/fixture'),
+        tempDirectory,
+      );
+
+      await expectSuccessfulProcessResult(
+        'flutter',
+        ['pub', 'get'],
+        workingDirectory: tempDirectory.path,
+      );
+
+      final cwd = Directory.current;
+      Directory.current = tempDirectory;
+      addTearDown(() {
+        Directory.current = cwd;
+      });
+
+      final result = await commandRunner.run(['test']);
+
+      expect(result, equals(ExitCode.unavailable.code));
+      verify(
+        () => logger.err(any(that: contains('- test/.test_optimizer.dart'))),
+      ).called(1);
+    }),
+  );
+}
diff --git a/e2e/test/commands/test/compilation_error/fixture/analysis_options.yaml b/e2e/test/commands/test/compilation_error/fixture/analysis_options.yaml
new file mode 100644
index 000000000..4d55121de
--- /dev/null
+++ b/e2e/test/commands/test/compilation_error/fixture/analysis_options.yaml
@@ -0,0 +1,3 @@
+analyzer:
+  exclude:
+    - test
diff --git a/e2e/test/commands/test/compilation_error/fixture/lib/compilation_error.dart b/e2e/test/commands/test/compilation_error/fixture/lib/compilation_error.dart
new file mode 100644
index 000000000..c944c0b36
--- /dev/null
+++ b/e2e/test/commands/test/compilation_error/fixture/lib/compilation_error.dart
@@ -0,0 +1,3 @@
+class Thing {
+  const Thing();
+}
diff --git a/e2e/test/commands/test/compilation_error/fixture/pubspec.yaml b/e2e/test/commands/test/compilation_error/fixture/pubspec.yaml
new file mode 100644
index 000000000..ebbdb0080
--- /dev/null
+++ b/e2e/test/commands/test/compilation_error/fixture/pubspec.yaml
@@ -0,0 +1,9 @@
+name: compilation_error
+version: 0.1.0+1
+publish_to: none
+
+environment:
+  sdk: ">=2.19.0 <3.0.0"
+
+dev_dependencies:
+  test: ^1.19.2
diff --git a/e2e/test/commands/test/compilation_error/fixture/test/src/my_package_test.dart b/e2e/test/commands/test/compilation_error/fixture/test/src/my_package_test.dart
new file mode 100644
index 000000000..f7c11e40d
--- /dev/null
+++ b/e2e/test/commands/test/compilation_error/fixture/test/src/my_package_test.dart
@@ -0,0 +1,10 @@
+// ignore_for_file: prefer_const_constructors
+
+import 'package:compilation_error/compilation_error.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('can be instantiated', () {
+    expect(Thing(thing: true), isNull);
+  });
+}
diff --git a/test/fixtures/test_runner_fixtures.dart b/test/fixtures/test_runner_fixtures.dart
index 50ca0bb79..7e8afd3b0 100644
--- a/test/fixtures/test_runner_fixtures.dart
+++ b/test/fixtures/test_runner_fixtures.dart
@@ -1441,3 +1441,54 @@ List<Map<String, Object>> skipExceptionMessageJsonOutput(String cwd) => [
       },
       {'success': false, 'type': 'done', 'time': 5466},
     ];
+
+List<Map<String, Object>> compilationErrorJsonOutput(String cwd) {
+  return [
+    {
+      'protocolVersion': '0.1.1',
+      'runnerVersion': '1.24.3',
+      'pid': 65730,
+      'type': 'start',
+      'time': 0
+    },
+    {
+      'suite': {'id': 0, 'platform': 'vm', 'path': 'test/.test_optimizer.dart'},
+      'type': 'suite',
+      'time': 0
+    },
+    {
+      'test': {
+        'id': 1,
+        'name': 'loading test/.test_optimizer.dart',
+        'suiteID': 0,
+        'groupIDs': <int>[],
+        'metadata': {'skip': false, 'skipReason': null},
+        'line': null,
+        'column': null,
+        'url': null
+      },
+      'type': 'testStart',
+      'time': 0
+    },
+    {'count': 1, 'time': 4, 'type': 'allSuites'},
+    {
+      'testID': 1,
+      'error':
+          "Failed to load \"test/.test_optimizer.dart\":\ntest/src/my_package_test.dart:8:18: Error: No named parameter with the name 'thing'.\n    expect(Thing(thing: true), isNull);\n                 ^^^^^\nlib/compilation_error.dart:2:9: Context: Found this candidate, but the arguments don't match.\n  const Thing();\n        ^^^^^",
+      'stackTrace':
+          'package:test_core/src/runner/vm/platform.dart 255:7   VMPlatform._compileToKernel\n===== asynchronous gap ===========================\npackage:test_core/src/runner/vm/platform.dart 232:15  VMPlatform._spawnIsolate\n===== asynchronous gap ===========================\npackage:test_core/src/runner/vm/platform.dart 76:19   VMPlatform.load\n===== asynchronous gap ===========================\npackage:test_core/src/runner/loader.dart 232:27       Loader.loadFile.<fn>\n===== asynchronous gap ===========================\npackage:test_core/src/runner/load_suite.dart 98:19    new LoadSuite.<fn>.<fn>\n',
+      'isFailure': false,
+      'type': 'error',
+      'time': 532
+    },
+    {
+      'testID': 1,
+      'result': 'error',
+      'skipped': false,
+      'hidden': false,
+      'type': 'testDone',
+      'time': 535
+    },
+    {'success': false, 'type': 'done', 'time': 536},
+  ];
+}
diff --git a/test/src/cli/flutter_cli_test.dart b/test/src/cli/flutter_cli_test.dart
index 4749b9da5..7f80670e5 100644
--- a/test/src/cli/flutter_cli_test.dart
+++ b/test/src/cli/flutter_cli_test.dart
@@ -734,6 +734,50 @@ void main() {
         );
       });
 
+      test('runs tests (compilation error)', () async {
+        final tempDirectory = Directory.systemTemp.createTempSync();
+        addTearDown(() => tempDirectory.deleteSync(recursive: true));
+        Directory(p.join(tempDirectory.path, 'test')).createSync();
+        File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync();
+
+        final testEventStream = Stream.fromIterable([
+          ...compilationErrorJsonOutput(tempDirectory.path)
+              .map(TestEvent.fromJson),
+          const ExitTestEvent(exitCode: 1, time: 0),
+        ]);
+
+        await expectLater(
+          Flutter.test(
+            cwd: tempDirectory.path,
+            stdout: stdoutLogs.add,
+            stderr: stderrLogs.add,
+            testRunner: testRunner(testEventStream),
+            logger: logger,
+          ),
+          completion(equals([ExitCode.unavailable.code])),
+        );
+
+        expect(
+          stdoutLogs,
+          containsAllInOrder([
+            '\x1B[2K\r00:00 -1: loading test/.test_optimizer.dart',
+            '\x1B[2K\r00:00 -1: Some tests failed.\n'
+          ]),
+        );
+        expect(
+          stderrLogs,
+          containsAll([
+            '\x1B[2K\rFailed to load "test/.test_optimizer.dart":\n'
+                "test/src/my_package_test.dart:8:18: Error: No named parameter with the name 'thing'.\n"
+                '    expect(Thing(thing: true), isNull);\n'
+                '                 ^^^^^\n'
+                "lib/compilation_error.dart:2:9: Context: Found this candidate, but the arguments don't match.\n"
+                '  const Thing();\n'
+                '        ^^^^^',
+          ]),
+        );
+      });
+
       test('runs tests w/out logs', () async {
         final tempDirectory = Directory.systemTemp.createTempSync();
         addTearDown(() => tempDirectory.deleteSync(recursive: true));