Allow retry load suites (#1160)

Partial resolution for https://github.com/dart-lang/test/issues/1159.

Retries loading test suites (in loader.dart). Retry configuration is currently based on the suite metadata - and is counted separately from the test retries (does not count as a retry for tests).
diff --git a/pkgs/test/test/runner/loader_test.dart b/pkgs/test/test/runner/loader_test.dart
index 1311ece..9b2e7ac 100644
--- a/pkgs/test/test/runner/loader_test.dart
+++ b/pkgs/test/test/runner/loader_test.dart
@@ -3,7 +3,9 @@
 // BSD-style license that can be found in the LICENSE file.
 
 @TestOn('vm')
+import 'dart:async';
 
+import 'package:pedantic/pedantic.dart';
 import 'package:path/path.dart' as p;
 import 'package:test_descriptor/test_descriptor.dart' as d;
 
@@ -152,6 +154,48 @@
     expectTestPassed(liveTest);
   });
 
+  group('LoadException', () {
+    test('suites can be retried', () async {
+      var numRetries = 5;
+
+      await d.file('a_test.dart', '''
+      import 'hello.dart';
+
+      void main() {}
+    ''').create();
+
+      var firstFailureCompleter = Completer<void>();
+
+      // After the first load failure we create the missing dependency.
+      unawaited(firstFailureCompleter.future.then((_) async {
+        await d.file('hello.dart', '''
+      String get message => 'hello';
+    ''').create();
+      }));
+
+      await runZoned(() async {
+        var suites = await _loader
+            .loadFile(p.join(d.sandbox, 'a_test.dart'),
+                SuiteConfiguration(retry: numRetries))
+            .toList();
+        expect(suites, hasLength(1));
+        var loadSuite = suites.first;
+        var suite = await loadSuite.getSuite();
+        expect(suite.path, equals(p.join(d.sandbox, 'a_test.dart')));
+        expect(suite.platform.runtime, equals(Runtime.vm));
+      }, zoneSpecification:
+          ZoneSpecification(print: (_, parent, zone, message) {
+        if (message.contains('Retrying load of') &&
+            !firstFailureCompleter.isCompleted) {
+          firstFailureCompleter.complete(null);
+        }
+        parent.print(zone, message);
+      }));
+
+      expect(firstFailureCompleter.isCompleted, true);
+    });
+  });
+
   // TODO: Test load suites. Don't forget to test that prints in loaded files
   // are piped through the suite. Also for browser tests!
 }
diff --git a/pkgs/test_core/CHANGELOG.md b/pkgs/test_core/CHANGELOG.md
index f16d824..b36455a 100644
--- a/pkgs/test_core/CHANGELOG.md
+++ b/pkgs/test_core/CHANGELOG.md
@@ -6,6 +6,9 @@
 * Differentiate between test-randomize-ordering-seed not set and 0 being chosen
   as the random seed.
 * `deserializeSuite` now takes an optional `gatherCoverage` callback.
+* Support retrying of entire test suites when they fail to load.
+* Fix the `compiling` message in precompiled mode so it says `loading` instead,
+  which is more accurate.
 
 ## 0.2.18
 
diff --git a/pkgs/test_core/lib/src/runner/loader.dart b/pkgs/test_core/lib/src/runner/loader.dart
index cdc64c8..b8c779f 100644
--- a/pkgs/test_core/lib/src/runner/loader.dart
+++ b/pkgs/test_core/lib/src/runner/loader.dart
@@ -215,21 +215,37 @@
         continue;
       }
 
-      var name = (platform.runtime.isJS ? 'compiling ' : 'loading ') + path;
+      var name =
+          (platform.runtime.isJS && platformConfig.precompiledPath == null
+                  ? 'compiling '
+                  : 'loading ') +
+              path;
       yield LoadSuite(name, platformConfig, platform, () async {
         var memo = _platformPlugins[platform.runtime];
 
-        try {
-          var plugin = await memo.runOnce(_platformCallbacks[platform.runtime]);
-          _customizePlatform(plugin, platform.runtime);
-          var suite = await plugin.load(path, platform, platformConfig,
-              {'platformVariables': _runtimeVariables.toList()});
-          if (suite != null) _suites.add(suite);
-          return suite;
-        } catch (error, stackTrace) {
-          if (error is LoadException) rethrow;
-          await Future.error(LoadException(path, error), stackTrace);
-          return null;
+        var retriesLeft = suiteConfig.metadata.retry;
+        while (true) {
+          try {
+            var plugin =
+                await memo.runOnce(_platformCallbacks[platform.runtime]);
+            _customizePlatform(plugin, platform.runtime);
+            var suite = await plugin.load(path, platform, platformConfig,
+                {'platformVariables': _runtimeVariables.toList()});
+            if (suite != null) _suites.add(suite);
+            return suite;
+          } catch (error, stackTrace) {
+            if (retriesLeft > 0) {
+              retriesLeft--;
+              print('Retrying load of $path in 1s ($retriesLeft remaining)');
+              await Future.delayed(Duration(seconds: 1));
+              continue;
+            }
+            if (error is LoadException) {
+              rethrow;
+            }
+            await Future.error(LoadException(path, error), stackTrace);
+            return null;
+          }
         }
       }, path: path);
     }