Merge branch 'master' into raw-test-api
diff --git a/pkgs/checks/lib/src/checks.dart b/pkgs/checks/lib/src/checks.dart
index c01a6a3..bca3071 100644
--- a/pkgs/checks/lib/src/checks.dart
+++ b/pkgs/checks/lib/src/checks.dart
@@ -74,7 +74,7 @@
         ].join('\n'));
       },
       allowAsync: true,
-      allowLateFailure: true,
+      allowUnawaited: true,
     ));
 
 /// Checks whether [value] satisfies all expectations invoked in [condition].
@@ -92,7 +92,7 @@
       failure = f;
     },
     allowAsync: false,
-    allowLateFailure: false,
+    allowUnawaited: false,
   ));
   condition.apply(check);
   return failure;
@@ -114,7 +114,7 @@
       failure = f;
     },
     allowAsync: true,
-    allowLateFailure: false,
+    allowUnawaited: false,
   ));
   await condition.applyAsync(check);
   return failure;
@@ -135,7 +135,7 @@
       throw UnimplementedError();
     },
     allowAsync: false,
-    allowLateFailure: true,
+    allowUnawaited: true,
   );
   condition.apply(Check._(context));
   return context.detail(context).expected.skip(1);
@@ -323,19 +323,19 @@
   final void Function(CheckFailure) _fail;
 
   final bool _allowAsync;
-  final bool _allowLateFailure;
+  final bool _allowUnawaited;
 
   _TestContext._root({
     required _Optional<T> value,
     required void Function(CheckFailure) fail,
     required bool allowAsync,
-    required bool allowLateFailure,
+    required bool allowUnawaited,
     String? label,
   })  : _value = value,
         _label = label ?? '',
         _fail = fail,
         _allowAsync = allowAsync,
-        _allowLateFailure = allowLateFailure,
+        _allowUnawaited = allowUnawaited,
         _parent = null,
         _clauses = [],
         _aliases = [];
@@ -346,7 +346,7 @@
         _aliases = original._aliases,
         _fail = original._fail,
         _allowAsync = original._allowAsync,
-        _allowLateFailure = original._allowLateFailure,
+        _allowUnawaited = original._allowUnawaited,
         // Never read from an aliased context because they are never present in
         // `_clauses`.
         _label = '';
@@ -355,7 +355,7 @@
       : _parent = parent,
         _fail = parent._fail,
         _allowAsync = parent._allowAsync,
-        _allowLateFailure = parent._allowLateFailure,
+        _allowUnawaited = parent._allowUnawaited,
         _clauses = [],
         _aliases = [];
 
@@ -387,7 +387,7 @@
   @override
   void expectUnawaited(Iterable<String> Function() clause,
       void Function(T actual, void Function(Rejection) reject) predicate) {
-    if (!_allowLateFailure) {
+    if (!_allowUnawaited) {
       throw StateError('Late expectations cannot be used for soft checks');
     }
     _clauses.add(_StringClause(clause));
@@ -658,6 +658,10 @@
 
   @override
   final _ReplayContext<T> _context = _ReplayContext();
+
+  String toString() {
+    return ['A value that:', ...describe(_context)].join('\n');
+  }
 }
 
 class _ReplayContext<T> implements Context<T>, Condition<T> {
diff --git a/pkgs/checks/lib/src/collection_equality.dart b/pkgs/checks/lib/src/collection_equality.dart
new file mode 100644
index 0000000..fff7b04
--- /dev/null
+++ b/pkgs/checks/lib/src/collection_equality.dart
@@ -0,0 +1,320 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:collection';
+
+import 'package:checks/context.dart';
+
+/// Returns a rejection if the elements of [actual] are unequal to the elements
+/// of [expected].
+///
+/// {@template deep_collection_equals}
+/// Elements, keys, or values, which are a collections are deeply compared for
+/// equality, and do not use the native identity based equality or custom
+/// equality operator overrides.
+/// Elements, keys, or values, which are a [Condition] instances are checked
+/// against actual values.
+/// All other value or key types use `operator ==`.
+///
+/// Comparing sets or maps will have a runtime which is polynomial on the the
+/// size of those collections. Does not use [Set.contains] or [Map.containsKey],
+/// there will not be runtime benefits from hashing. Custom collection behavior
+/// is ignored. For example, it is not possible to distinguish between a `Set`
+/// and a `Set.identity`.
+///
+/// Collections may be nested to a maximum depth of 1000. Recursive collections
+/// are not allowed.
+/// {@endtemplate}
+Rejection? deepCollectionEquals(Object actual, Object expected) {
+  try {
+    return _deepCollectionEquals(actual, expected, 0);
+  } on _ExceededDepthError {
+    return Rejection(
+        actual: literal(actual),
+        which: ['exceeds the depth limit of $_maxDepth']);
+  }
+}
+
+const _maxDepth = 1000;
+
+class _ExceededDepthError extends Error {}
+
+Rejection? _deepCollectionEquals(Object actual, Object expected, int depth) {
+  assert(actual is Iterable || actual is Map);
+  assert(expected is Iterable || expected is Map);
+
+  final queue = Queue.of([_Search(_Path.root(), actual, expected, depth)]);
+  while (queue.isNotEmpty) {
+    final toCheck = queue.removeFirst();
+    final currentActual = toCheck.actual;
+    final currentExpected = toCheck.expected;
+    final path = toCheck.path;
+    final currentDepth = toCheck.depth;
+    Iterable<String>? rejectionWhich;
+    if (currentExpected is Set) {
+      rejectionWhich = _findSetDifference(
+          currentActual, currentExpected, path, currentDepth);
+    } else if (currentExpected is Iterable) {
+      rejectionWhich = _findIterableDifference(
+          currentActual, currentExpected, path, queue, currentDepth);
+    } else {
+      currentExpected as Map;
+      rejectionWhich = _findMapDifference(
+          currentActual, currentExpected, path, currentDepth);
+    }
+    if (rejectionWhich != null) {
+      return Rejection(actual: literal(actual), which: rejectionWhich);
+    }
+  }
+  return null;
+}
+
+List<String>? _findIterableDifference(Object? actual,
+    Iterable<Object?> expected, _Path path, Queue<_Search> queue, int depth) {
+  if (actual is! Iterable) {
+    return ['${path}is not an Iterable'];
+  }
+  var actualIterator = actual.iterator;
+  var expectedIterator = expected.iterator;
+  for (var index = 0;; index++) {
+    var actualNext = actualIterator.moveNext();
+    var expectedNext = expectedIterator.moveNext();
+    if (!expectedNext && !actualNext) break;
+    if (!expectedNext) {
+      return [
+        '${path}has more elements than expected',
+        'expected an iterable with $index element(s)'
+      ];
+    }
+    if (!actualNext) {
+      return [
+        '${path}has too few elements',
+        'expected an iterable with at least ${index + 1} element(s)'
+      ];
+    }
+    var actualValue = actualIterator.current;
+    var expectedValue = expectedIterator.current;
+    if (expectedValue is Iterable || expectedValue is Map) {
+      if (depth + 1 > _maxDepth) throw _ExceededDepthError();
+      queue.addLast(
+          _Search(path.append(index), actualValue, expectedValue, depth + 1));
+    } else if (expectedValue is Condition) {
+      final failure = softCheck(actualValue, expectedValue);
+      if (failure != null) {
+        final which = failure.rejection.which;
+        return [
+          'has an element ${path.append(index)}that:',
+          ...indent(failure.detail.actual.skip(1)),
+          ...indent(prefixFirst('Actual: ', failure.rejection.actual),
+              failure.detail.depth + 1),
+          if (which != null)
+            ...indent(prefixFirst('which ', which), failure.detail.depth + 1)
+        ];
+      }
+    } else {
+      if (actualValue != expectedValue) {
+        return [
+          ...prefixFirst('${path.append(index)}is ', literal(actualValue)),
+          ...prefixFirst('which does not equal ', literal(expectedValue))
+        ];
+      }
+    }
+  }
+  return null;
+}
+
+bool _elementMatches(Object? actual, Object? expected, int depth) {
+  if (expected == null) return actual == null;
+  if (expected is Iterable || expected is Map) {
+    if (++depth > _maxDepth) throw _ExceededDepthError();
+    return actual != null &&
+        _deepCollectionEquals(actual, expected, depth) == null;
+  }
+  if (expected is Condition) {
+    return softCheck(actual, expected) == null;
+  }
+  return expected == actual;
+}
+
+Iterable<String>? _findSetDifference(
+    Object? actual, Set<Object?> expected, _Path path, int depth) {
+  if (actual is! Set) {
+    return ['${path}is not a Set'];
+  }
+  final indexedExpected = expected.toList();
+  final indexedActual = actual.toList();
+  final adjacency = <List<int>>[];
+
+  for (final expectedElement in indexedExpected) {
+    final pairs = [
+      for (var j = 0; j < indexedActual.length; j++)
+        if (_elementMatches(indexedActual[j], expectedElement, depth)) j,
+    ];
+    if (pairs.isEmpty) {
+      return prefixFirst(
+          '${path}has no element to match ', literal(expectedElement));
+    }
+    adjacency.add(pairs);
+  }
+  if (indexedActual.length != indexedExpected.length) {
+    return [
+      '${path}has ${indexedActual.length} element(s),',
+      'expected a set with ${indexedExpected.length} element(s)'
+    ];
+  }
+  if (!_hasPerfectMatching(adjacency)) {
+    return prefixFirst(
+        '${path}cannot be matched with the elements of ', literal(expected));
+  }
+  return null;
+}
+
+Iterable<String>? _findMapDifference(
+    Object? actual, Map<Object?, Object?> expected, _Path path, int depth) {
+  if (actual is! Map) {
+    return ['${path}is not a Map'];
+  }
+  final expectedEntries = expected.entries.toList();
+  final actualEntries = actual.entries.toList();
+  final adjacency = <List<int>>[];
+  for (final expectedEntry in expectedEntries) {
+    final potentialPairs = [
+      for (var i = 0; i < actualEntries.length; i++)
+        if (_elementMatches(actualEntries[i].key, expectedEntry.key, depth)) i
+    ];
+    if (potentialPairs.isEmpty) {
+      return prefixFirst(
+          '${path}has no key to match ', literal(expectedEntry.key));
+    }
+    final matchingPairs = [
+      for (var i in potentialPairs)
+        if (_elementMatches(actualEntries[i].value, expectedEntry.value, depth))
+          i
+    ];
+    if (matchingPairs.isEmpty) {
+      return prefixFirst(
+          '${path.append(expectedEntry.key)}has no value to match ',
+          literal(expectedEntry.value));
+    }
+    adjacency.add(matchingPairs);
+  }
+  if (expectedEntries.length != actualEntries.length) {
+    return [
+      '${path}has ${actualEntries.length} entries,',
+      'expected a Map with ${expectedEntries.length} entries'
+    ];
+  }
+  if (!_hasPerfectMatching(adjacency)) {
+    return prefixFirst(
+        '${path}cannot be matched with the entries of ', literal(expected));
+  }
+  return null;
+}
+
+class _Path {
+  final _Path? parent;
+  final Object? index;
+  _Path._(this.parent, this.index);
+  _Path.root()
+      : parent = null,
+        index = '';
+  _Path append(Object? index) => _Path._(this, index);
+  String toString() {
+    if (parent == null && index == '') return '';
+    final stack = Queue.of([this]);
+    var current = this.parent;
+    while (current?.parent != null) {
+      stack.addLast(current!);
+      current = current.parent;
+    }
+    final result = StringBuffer('at ');
+    while (stack.isNotEmpty) {
+      result.write('[');
+      result.write(literal(stack.removeLast().index).join(r'\n'));
+      result.write(']');
+    }
+    result.write(' ');
+    return result.toString();
+  }
+}
+
+class _Search {
+  final _Path path;
+  final Object? actual;
+  final Object? expected;
+  final int depth;
+  _Search(this.path, this.actual, this.expected, this.depth);
+}
+
+/// Returns true if [adjacency] represents a bipartite graph that has a perfect
+/// pairing without unpaired elements in either set.
+///
+/// Vertices are represented as integers - a vertice in `u` is an index in
+/// [adjacency], and a vertice in `v` is a value in list at that index. An edge
+/// from `U[n]` to `V[m]` is represented by the value `m` being present in the
+/// list at index `n`.
+/// Assumes that there are an equal number of values in both sets, equal to the
+/// length of [adjacency].
+///
+/// Uses the Hopcroft–Karp algorithm based on pseudocode from
+/// https://en.wikipedia.org/wiki/Hopcroft%E2%80%93Karp_algorithm
+bool _hasPerfectMatching(List<List<int>> adjacency) {
+  final length = adjacency.length;
+  // The index [length] represents a "dummy vertex"
+  final distances = List<num>.filled(length + 1, double.infinity);
+  // Initially, everything is paired with the "dummy vertex"
+  final leftPairs = List.filled(length, length);
+  final rightPairs = List.filled(length, length);
+  bool bfs() {
+    final queue = Queue<int>();
+    for (int leftIndex = 0; leftIndex < length; leftIndex++) {
+      if (leftPairs[leftIndex] == length) {
+        distances[leftIndex] = 0;
+        queue.add(leftIndex);
+      } else {
+        distances[leftIndex] = double.infinity;
+      }
+    }
+    distances.last = double.infinity;
+    while (queue.isNotEmpty) {
+      final current = queue.removeFirst();
+      if (distances[current] < distances[length]) {
+        for (final rightIndex in adjacency[current]) {
+          if (distances[rightPairs[rightIndex]].isInfinite) {
+            distances[rightPairs[rightIndex]] = distances[current] + 1;
+            queue.addLast(rightPairs[rightIndex]);
+          }
+        }
+      }
+    }
+    return !distances.last.isInfinite;
+  }
+
+  bool dfs(int leftIndex) {
+    if (leftIndex == length) return true;
+    for (final rightIndex in adjacency[leftIndex]) {
+      if (distances[rightPairs[rightIndex]] == distances[leftIndex] + 1) {
+        if (dfs(rightPairs[rightIndex])) {
+          leftPairs[leftIndex] = rightIndex;
+          rightPairs[rightIndex] = leftIndex;
+          return true;
+        }
+      }
+    }
+    distances[leftIndex] = double.infinity;
+    return false;
+  }
+
+  var matching = 0;
+  while (bfs()) {
+    for (int leftIndex = 0; leftIndex < length; leftIndex++) {
+      if (leftPairs[leftIndex] == length) {
+        if (dfs(leftIndex)) {
+          matching++;
+        }
+      }
+    }
+  }
+  return matching == length;
+}
diff --git a/pkgs/checks/lib/src/extensions/iterable.dart b/pkgs/checks/lib/src/extensions/iterable.dart
index 5448088..ca49a3b 100644
--- a/pkgs/checks/lib/src/extensions/iterable.dart
+++ b/pkgs/checks/lib/src/extensions/iterable.dart
@@ -4,6 +4,7 @@
 
 import 'package:checks/context.dart';
 
+import '../collection_equality.dart';
 import 'core.dart';
 
 extension IterableChecks<T> on Check<Iterable<T>> {
@@ -92,6 +93,14 @@
     });
   }
 
+  /// Expects that the iterable contains elements that are deeply equal to the
+  /// elements of [expected].
+  ///
+  /// {@macro deep_collection_equals}
+  void deepEquals(Iterable<Object?> expected) => context.expect(
+      () => prefixFirst('is deeply equal to ', literal(expected)),
+      (actual) => deepCollectionEquals(actual, expected));
+
   /// Expects that the iterable contains elements that correspond by the
   /// [elementCondition] exactly to each element in [expected].
   ///
diff --git a/pkgs/checks/lib/src/extensions/map.dart b/pkgs/checks/lib/src/extensions/map.dart
index e87ecae..2f2ef72 100644
--- a/pkgs/checks/lib/src/extensions/map.dart
+++ b/pkgs/checks/lib/src/extensions/map.dart
@@ -4,6 +4,7 @@
 
 import 'package:checks/context.dart';
 
+import '../collection_equality.dart';
 import 'core.dart';
 
 extension MapChecks<K, V> on Check<Map<K, V>> {
@@ -98,4 +99,12 @@
           actual: literal(actual), which: ['Contains no matching value']);
     });
   }
+
+  /// Expects that the map contains entries that are deeply equal to the entries
+  /// of [expected].
+  ///
+  /// {@macro deep_collection_equals}
+  void deepEquals(Map<Object?, Object?> expected) => context.expect(
+      () => prefixFirst('is deeply equal to ', literal(expected)),
+      (actual) => deepCollectionEquals(actual, expected));
 }
diff --git a/pkgs/checks/test/extensions/collection_equality_test.dart b/pkgs/checks/test/extensions/collection_equality_test.dart
new file mode 100644
index 0000000..4bf66c8
--- /dev/null
+++ b/pkgs/checks/test/extensions/collection_equality_test.dart
@@ -0,0 +1,166 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:checks/checks.dart';
+import 'package:checks/src/collection_equality.dart';
+import 'package:test/scaffolding.dart';
+
+import '../test_shared.dart';
+
+void main() {
+  group('deepCollectionEquals', () {
+    test('allows nested collections with equal elements', () {
+      checkThat(deepCollectionEquals([
+        'a',
+        {'b': 1},
+        {'c', 'd'},
+        [
+          ['e']
+        ],
+      ], [
+        'a',
+        {'b': 1},
+        {'c', 'd'},
+        [
+          ['e']
+        ],
+      ])).isNull();
+    });
+
+    test('allows collections inside sets', () {
+      checkThat(deepCollectionEquals({
+        {'a': 1}
+      }, {
+        {'a': 1}
+      })).isNull();
+    });
+
+    test('allows collections as Map keys', () {
+      checkThat(deepCollectionEquals([
+        {
+          {'a': 1}: {'b': 2}
+        }
+      ], [
+        {
+          {'a': 1}: {'b': 2}
+        }
+      ])).isNull();
+    });
+
+    test('allows conditions in place of elements in lists', () {
+      checkThat(deepCollectionEquals([
+        'a',
+        'b'
+      ], [
+        it()
+          ..isA<String>().that(it()
+            ..startsWith('a')
+            ..length.isLessThan(2)),
+        it()..isA<String>().startsWith('b')
+      ])).isNull();
+    });
+
+    test('allows conditions in place of values in maps', () {
+      checkThat(deepCollectionEquals([
+        {'a': 'b'}
+      ], [
+        {'a': it()..isA<String>().startsWith('b')}
+      ])).isNull();
+    });
+
+    test('allows conditions in place of elements in sets', () {
+      checkThat(deepCollectionEquals(
+          {'b', 'a'}, {'a', it()..isA<String>().startsWith('b')})).isNull();
+    });
+
+    test('allows conditions in place of keys in maps', () {
+      checkThat(deepCollectionEquals(
+          {'a': 'b'}, {it()..isA<String>().startsWith('a'): 'b'})).isNull();
+    });
+
+    test('reports non-Set elements', () {
+      checkThat(deepCollectionEquals([
+        ['a']
+      ], [
+        {'a'}
+      ])).isARejection(which: ['at [<0>] is not a Set']);
+    });
+
+    test('reports long iterables', () {
+      checkThat(deepCollectionEquals([0], [])).isARejection(which: [
+        'has more elements than expected',
+        'expected an iterable with 0 element(s)'
+      ]);
+    });
+
+    test('reports short iterables', () {
+      checkThat(deepCollectionEquals([], [0])).isARejection(which: [
+        'has too few elements',
+        'expected an iterable with at least 1 element(s)'
+      ]);
+    });
+
+    test('reports unequal elements in iterables', () {
+      checkThat(deepCollectionEquals([0], [1]))
+          .isARejection(which: ['at [<0>] is <0>', 'which does not equal <1>']);
+    });
+
+    test('reports unmet conditions in iterables', () {
+      checkThat(deepCollectionEquals([0], [it()..isA<int>().isGreaterThan(0)]))
+          .isARejection(which: [
+        'has an element at [<0>] that:',
+        '  Actual: <0>',
+        '  which is not greater than <0>'
+      ]);
+    });
+
+    test('reports unmet conditions in map values', () {
+      checkThat(deepCollectionEquals(
+              {'a': 'b'}, {'a': it()..isA<String>().startsWith('a')}))
+          .isARejection(which: [
+        "at ['a'] has no value to match <A value that:",
+        '  is a String',
+        "  starts with 'a'>",
+      ]);
+    });
+
+    test('reports unmet conditions in map keys', () {
+      checkThat(deepCollectionEquals(
+              {'b': 'a'}, {it()..isA<String>().startsWith('a'): 'a'}))
+          .isARejection(which: [
+        'has no key to match <A value that:',
+        '  is a String',
+        "  starts with 'a'>",
+      ]);
+    });
+
+    test('reports recursive lists', () {
+      var l = [];
+      l.add(l);
+      checkThat(deepCollectionEquals(l, l))
+          .isARejection(which: ['exceeds the depth limit of 1000']);
+    });
+
+    test('reports recursive sets', () {
+      var s = <Object>{};
+      s.add(s);
+      checkThat(deepCollectionEquals(s, s))
+          .isARejection(which: ['exceeds the depth limit of 1000']);
+    });
+
+    test('reports maps with recursive keys', () {
+      var m = <Object, Object>{};
+      m[m] = 0;
+      checkThat(deepCollectionEquals(m, m))
+          .isARejection(which: ['exceeds the depth limit of 1000']);
+    });
+
+    test('reports maps with recursive values', () {
+      var m = <Object, Object>{};
+      m[0] = m;
+      checkThat(deepCollectionEquals(m, m))
+          .isARejection(which: ['exceeds the depth limit of 1000']);
+    });
+  });
+}
diff --git a/pkgs/checks/test/test_shared.dart b/pkgs/checks/test/test_shared.dart
index 10b49ce..7d17164 100644
--- a/pkgs/checks/test/test_shared.dart
+++ b/pkgs/checks/test/test_shared.dart
@@ -5,33 +5,30 @@
 import 'package:checks/checks.dart';
 import 'package:checks/context.dart';
 
-extension TestIterableCheck on Check<Iterable<String>?> {
-  // TODO: remove this once we have a deepEquals or equivalent
-  void toStringEquals(List<String>? other) {
-    final otherToString = other.toString();
-    context.expect(
-      () => prefixFirst('toString equals ', literal(otherToString)),
-      (actual) {
-        final actualToString = actual.toString();
-        return actualToString == otherToString
-            ? null
-            : Rejection(
-                actual: literal(actualToString),
-                which: ['does not have a matching toString'],
-              );
-      },
-    );
+extension FailureCheck on Check<CheckFailure?> {
+  void isARejection({List<String>? which, List<String>? actual}) {
+    isNotNull()
+        .has((f) => f.rejection, 'rejection')
+        ._hasActualWhich(actual: actual, which: which);
   }
 }
 
-extension RejectionCheck on Check<CheckFailure?> {
+extension RejectionCheck on Check<Rejection?> {
   void isARejection({List<String>? which, List<String>? actual}) {
-    final rejection = this.isNotNull().has((f) => f.rejection, 'rejection');
+    isNotNull()._hasActualWhich(actual: actual, which: which);
+  }
+}
+
+extension _RejectionCheck on Check<Rejection> {
+  void _hasActualWhich({List<String>? which, List<String>? actual}) {
     if (actual != null) {
-      rejection
-          .has((p0) => p0.actual.toList(), 'actual')
-          .toStringEquals(actual);
+      has((r) => r.actual.toList(), 'actual').deepEquals(actual);
     }
-    rejection.has((p0) => p0.which?.toList(), 'which').toStringEquals(which);
+    final whichCheck = has((r) => r.which?.toList(), 'which');
+    if (which == null) {
+      whichCheck.isNull();
+    } else {
+      whichCheck.isNotNull().deepEquals(which);
+    }
   }
 }
diff --git a/pkgs/test/CHANGELOG.md b/pkgs/test/CHANGELOG.md
index ac91092..5e7c905 100644
--- a/pkgs/test/CHANGELOG.md
+++ b/pkgs/test/CHANGELOG.md
@@ -1,5 +1,8 @@
 ## 1.22.3-dev
 
+* Avoid empty expandable groups for tests without extra output in Github
+  reporter.
+
 ## 1.22.2
 
 * Don't run `tearDown` until the test body and outstanding work is complete,
diff --git a/pkgs/test/pubspec.yaml b/pkgs/test/pubspec.yaml
index 68439f1..3113be8 100644
--- a/pkgs/test/pubspec.yaml
+++ b/pkgs/test/pubspec.yaml
@@ -33,7 +33,7 @@
   yaml: ^3.0.0
   # Use an exact version until the test_api and test_core package are stable.
   test_api: 0.4.18
-  test_core: 0.4.22
+  test_core: 0.4.23
 
 dev_dependencies:
   fake_async: ^1.0.0
diff --git a/pkgs/test/test/runner/configuration/top_level_error_test.dart b/pkgs/test/test/runner/configuration/top_level_error_test.dart
index 7f15f7e..ea2ffbe 100644
--- a/pkgs/test/test/runner/configuration/top_level_error_test.dart
+++ b/pkgs/test/test/runner/configuration/top_level_error_test.dart
@@ -390,15 +390,15 @@
           .file(
               'dart_test.yaml',
               jsonEncode({
-                'paths': ['[invalid]']
+                'paths': [':invalid']
               }))
           .create();
 
       var test = await runTest(['test.dart']);
       expect(test.stderr,
-          containsInOrder(['Invalid path: Invalid character', '^^^^^^^^^']));
+          containsInOrder(['Invalid path: Invalid empty scheme', '^^^^^^^^']));
       await test.shouldExit(exit_codes.data);
-    }, skip: 'Broken by sdk#34988');
+    });
   });
 
   group('filename', () {
diff --git a/pkgs/test/test/runner/github_reporter_test.dart b/pkgs/test/test/runner/github_reporter_test.dart
index de3a7d8..ba094fb 100644
--- a/pkgs/test/test/runner/github_reporter_test.dart
+++ b/pkgs/test/test/runner/github_reporter_test.dart
@@ -27,22 +27,17 @@
         test('success 1', () {});
         test('success 2', () {});
         test('success 3', () {});''', '''
-        ::group::✅ success 1
-        ::endgroup::
-        ::group::✅ success 2
-        ::endgroup::
-        ::group::✅ success 3
-        ::endgroup::
+        ✅ success 1
+        ✅ success 2
+        ✅ success 3
         🎉 3 tests passed.''');
   });
 
   test('includes the platform name when multiple platforms are run', () {
     return _expectReportLines('''
         test('success 1', () {});''', [
-      '::group::✅ [VM] success 1',
-      '::endgroup::',
-      '::group::✅ [Chrome] success 1',
-      '::endgroup::',
+      '✅ [VM] success 1',
+      '✅ [Chrome] success 1',
       '🎉 2 tests passed.',
     ], args: [
       '-p',
@@ -97,14 +92,12 @@
         oh no
         test.dart 6:33  main.<fn>
         ::endgroup::
-        ::group::✅ success 1
-        ::endgroup::
+        ✅ success 1
         ::group::❌ failure 2 (failed)
         oh no
         test.dart 8:33  main.<fn>
         ::endgroup::
-        ::group::✅ success 2
-        ::endgroup::
+        ✅ success 2
         ::error::2 tests passed, 2 failed.''');
   });
 
@@ -115,9 +108,7 @@
            'really gosh dang long test name. Even longer than that. No, yet '
                'longer. A little more... okay, that should do it.',
            () {});''',
-      '''
-        ::group::✅ really gosh dang long test name. Even longer than that. No, yet longer. A little more... okay, that should do it.
-        ::endgroup::''',
+      '✅ really gosh dang long test name. Even longer than that. No, yet longer. A little more... okay, that should do it.',
       useContains: true,
     );
   });
@@ -142,8 +133,7 @@
         third error
         test.dart 12:34  main.<fn>.<fn>
         ::endgroup::
-        ::group::✅ wait
-        ::endgroup::
+        ✅ wait
         ::error::1 test passed, 1 failed.''');
   });
 
@@ -158,8 +148,7 @@
 
       test('second test so that the first failure is reported', () {});''',
       '''
-        ::group::✅ fail after completion
-        ::endgroup::
+        ✅ fail after completion
         ::group::❌ fail after completion (failed after test completion)
         foo
         test.dart 8:62  main.<fn>.<fn>
@@ -169,8 +158,7 @@
         or the [completes] matcher when testing async code.
         test.dart 8:62  main.<fn>.<fn>
         ::endgroup::
-        ::group::✅ second test so that the first failure is reported
-        ::endgroup::
+        ✅ second test so that the first failure is reported
         ::error::1 test passed, 1 failed.''',
     );
   });
@@ -216,14 +204,12 @@
           waitStarted.complete();
           return testDone.future;
         });''', '''
-        ::group::✅ test
-        ::endgroup::
+        ✅ test
         one
         two
         three
         four
-        ::group::✅ wait
-        ::endgroup::
+        ✅ wait
         🎉 2 tests passed.''');
     });
   });
@@ -234,12 +220,9 @@
           test('skip 1', () {}, skip: true);
           test('skip 2', () {}, skip: true);
           test('skip 3', () {}, skip: true);''', '''
-          ::group::❎ skip 1 (skipped)
-          ::endgroup::
-          ::group::❎ skip 2 (skipped)
-          ::endgroup::
-          ::group::❎ skip 3 (skipped)
-          ::endgroup::
+          ❎ skip 1 (skipped)
+          ❎ skip 2 (skipped)
+          ❎ skip 3 (skipped)
           🎉 0 tests passed, 3 skipped.''');
     });
 
@@ -250,12 +233,9 @@
             test('test 2', () {});
             test('test 3', () {});
           }, skip: true);''', '''
-          ::group::❎ skip test 1 (skipped)
-          ::endgroup::
-          ::group::❎ skip test 2 (skipped)
-          ::endgroup::
-          ::group::❎ skip test 3 (skipped)
-          ::endgroup::
+          ❎ skip test 1 (skipped)
+          ❎ skip test 2 (skipped)
+          ❎ skip test 3 (skipped)
           🎉 0 tests passed, 3 skipped.''');
     });
 
@@ -265,14 +245,10 @@
           test('success 1', () {});
           test('skip 2', () {}, skip: true);
           test('success 2', () {});''', '''
-          ::group::❎ skip 1 (skipped)
-          ::endgroup::
-          ::group::✅ success 1
-          ::endgroup::
-          ::group::❎ skip 2 (skipped)
-          ::endgroup::
-          ::group::✅ success 2
-          ::endgroup::
+          ❎ skip 1 (skipped)
+          ✅ success 1
+          ❎ skip 2 (skipped)
+          ✅ success 2
           🎉 2 tests passed, 2 skipped.''');
     });
 
@@ -288,18 +264,14 @@
           oh no
           test.dart 6:35  main.<fn>
           ::endgroup::
-          ::group::❎ skip 1 (skipped)
-          ::endgroup::
-          ::group::✅ success 1
-          ::endgroup::
+          ❎ skip 1 (skipped)
+          ✅ success 1
           ::group::❌ failure 2 (failed)
           oh no
           test.dart 9:35  main.<fn>
           ::endgroup::
-          ::group::❎ skip 2 (skipped)
-          ::endgroup::
-          ::group::✅ success 2
-          ::endgroup::
+          ❎ skip 2 (skipped)
+          ✅ success 2
           ::error::2 tests passed, 2 failed, 2 skipped.''');
     });
 
@@ -324,8 +296,7 @@
             tearDownAll(() {/* nothing to do here */});
             test('test 1', () {});
           });''', '''
-          ::group::✅ one test 1
-          ::endgroup::
+          ✅ one test 1
           🎉 1 test passed.''');
   });
 
@@ -339,8 +310,7 @@
           ::group::✅ one (setUpAll)
           one
           ::endgroup::
-          ::group::✅ one test 1
-          ::endgroup::
+          ✅ one test 1
           ::group::✅ one (tearDownAll)
           two
           ::endgroup::
diff --git a/pkgs/test_core/CHANGELOG.md b/pkgs/test_core/CHANGELOG.md
index b40d72e..3cfc241 100644
--- a/pkgs/test_core/CHANGELOG.md
+++ b/pkgs/test_core/CHANGELOG.md
@@ -1,5 +1,8 @@
 # 0.4.23-dev
 
+* Avoid empty expandable groups for tests without extra output in Github
+  reporter.
+
 # 0.4.22
 
 * Don't run `tearDown` until the test body and outstanding work is complete,
diff --git a/pkgs/test_core/lib/src/runner/reporter/github.dart b/pkgs/test_core/lib/src/runner/reporter/github.dart
index 5adf17e..2c291d6 100644
--- a/pkgs/test_core/lib/src/runner/reporter/github.dart
+++ b/pkgs/test_core/lib/src/runner/reporter/github.dart
@@ -154,15 +154,19 @@
     if (_printPlatform) {
       name = '[${test.suite.platform.runtime.name}] $name';
     }
-    _sink.writeln(_GithubMarkup.startGroup('$prefix $name$statusSuffix'));
-    for (var message in messages) {
-      _sink.writeln(message.text);
+    if (messages.isEmpty && errors.isEmpty) {
+      _sink.writeln('$prefix $name$statusSuffix');
+    } else {
+      _sink.writeln(_GithubMarkup.startGroup('$prefix $name$statusSuffix'));
+      for (var message in messages) {
+        _sink.writeln(message.text);
+      }
+      for (var error in errors) {
+        _sink.writeln('${error.error}');
+        _sink.writeln(error.stackTrace.toString().trimRight());
+      }
+      _sink.writeln(_GithubMarkup.endGroup);
     }
-    for (var error in errors) {
-      _sink.writeln('${error.error}');
-      _sink.writeln(error.stackTrace.toString().trimRight());
-    }
-    _sink.writeln(_GithubMarkup.endGroup);
   }
 
   /// A callback called when [test] throws [error].