[test_reflective_loader] Pass test locations to `pkg:test` to improve IDE navigation (#2090)

diff --git a/.github/workflows/test_reflective_loader.yaml b/.github/workflows/test_reflective_loader.yaml
index 7550fff..8e70b85 100644
--- a/.github/workflows/test_reflective_loader.yaml
+++ b/.github/workflows/test_reflective_loader.yaml
@@ -26,7 +26,7 @@
     strategy:
       fail-fast: false
       matrix:
-        sdk: [dev, 3.1]
+        sdk: [dev, 3.5]
 
     steps:
       - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
diff --git a/pkgs/test_reflective_loader/CHANGELOG.md b/pkgs/test_reflective_loader/CHANGELOG.md
index 803eb0e..61ba81b 100644
--- a/pkgs/test_reflective_loader/CHANGELOG.md
+++ b/pkgs/test_reflective_loader/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 0.3.0
+
+- Require Dart `^3.5.0`.
+- Update to `package:test` 1.26.1.
+- Pass locations of groups/tests to `package:test` to improve locations reported
+  in the JSON reporter that may be used for navigation in IDEs.
+
 ## 0.2.3
 
 - Require Dart `^3.1.0`.
diff --git a/pkgs/test_reflective_loader/lib/test_reflective_loader.dart b/pkgs/test_reflective_loader/lib/test_reflective_loader.dart
index cb69bf3..9c3a103 100644
--- a/pkgs/test_reflective_loader/lib/test_reflective_loader.dart
+++ b/pkgs/test_reflective_loader/lib/test_reflective_loader.dart
@@ -87,7 +87,8 @@
   {
     var isSolo = _hasAnnotationInstance(classMirror, soloTest);
     var className = MirrorSystem.getName(classMirror.simpleName);
-    group = _Group(isSolo, _combineNames(_currentSuiteName, className));
+    group = _Group(isSolo, _combineNames(_currentSuiteName, className),
+        classMirror.testLocation);
     _currentGroups.add(group);
   }
 
@@ -104,7 +105,7 @@
     // test_
     if (memberName.startsWith('test_')) {
       if (_hasSkippedTestAnnotation(memberMirror)) {
-        group.addSkippedTest(memberName);
+        group.addSkippedTest(memberName, memberMirror.testLocation);
       } else {
         group.addTest(isSolo, memberName, memberMirror, () {
           if (_hasFailingTestAnnotation(memberMirror) ||
@@ -137,7 +138,7 @@
     }
     // skip_test_
     if (memberName.startsWith('skip_test_')) {
-      group.addSkippedTest(memberName);
+      group.addSkippedTest(memberName, memberMirror.testLocation);
     }
   });
 
@@ -154,7 +155,9 @@
           for (var test in group.tests) {
             if (allTests || test.isSolo) {
               test_package.test(test.name, test.function,
-                  timeout: test.timeout, skip: test.isSkipped);
+                  timeout: test.timeout,
+                  skip: test.isSkipped,
+                  location: test.location);
             }
           }
         }
@@ -304,15 +307,16 @@
 class _Group {
   final bool isSolo;
   final String name;
+  final test_package.TestLocation? location;
   final List<_Test> tests = <_Test>[];
 
-  _Group(this.isSolo, this.name);
+  _Group(this.isSolo, this.name, this.location);
 
   bool get hasSoloTest => tests.any((test) => test.isSolo);
 
-  void addSkippedTest(String name) {
+  void addSkippedTest(String name, test_package.TestLocation? location) {
     var fullName = _combineNames(this.name, name);
-    tests.add(_Test.skipped(isSolo, fullName));
+    tests.add(_Test.skipped(isSolo, fullName, location));
   }
 
   void addTest(bool isSolo, String name, MethodMirror memberMirror,
@@ -320,7 +324,8 @@
     var fullName = _combineNames(this.name, name);
     var timeout =
         _getAnnotationInstance(memberMirror, TestTimeout) as TestTimeout?;
-    tests.add(_Test(isSolo, fullName, function, timeout?._timeout));
+    tests.add(_Test(isSolo, fullName, function, timeout?._timeout,
+        memberMirror.testLocation));
   }
 }
 
@@ -341,14 +346,26 @@
   final String name;
   final _TestFunction function;
   final test_package.Timeout? timeout;
+  final test_package.TestLocation? location;
 
   final bool isSkipped;
 
-  _Test(this.isSolo, this.name, this.function, this.timeout)
+  _Test(this.isSolo, this.name, this.function, this.timeout, this.location)
       : isSkipped = false;
 
-  _Test.skipped(this.isSolo, this.name)
+  _Test.skipped(this.isSolo, this.name, this.location)
       : isSkipped = true,
         function = (() {}),
         timeout = null;
 }
+
+extension on DeclarationMirror {
+  test_package.TestLocation? get testLocation {
+    if (location case var location?) {
+      return test_package.TestLocation(
+          location.sourceUri, location.line, location.column);
+    } else {
+      return null;
+    }
+  }
+}
diff --git a/pkgs/test_reflective_loader/pubspec.yaml b/pkgs/test_reflective_loader/pubspec.yaml
index f63ab01..262a349 100644
--- a/pkgs/test_reflective_loader/pubspec.yaml
+++ b/pkgs/test_reflective_loader/pubspec.yaml
@@ -1,14 +1,15 @@
 name: test_reflective_loader
-version: 0.2.3
+version: 0.3.0
 description: Support for discovering tests and test suites using reflection.
 repository: https://github.com/dart-lang/tools/tree/main/pkgs/test_reflective_loader
 issue_tracker: https://github.com/dart-lang/tools/labels/package%3Atest_reflective_loader
 
 environment:
-  sdk: ^3.1.0
+  sdk: ^3.5.0
 
 dependencies:
-  test: ^1.16.0
+  test: ^1.26.1
 
 dev_dependencies:
   dart_flutter_team_lints: ^3.0.0
+  path: ^1.8.0
diff --git a/pkgs/test_reflective_loader/test/location_test.dart b/pkgs/test_reflective_loader/test/location_test.dart
new file mode 100644
index 0000000..14984bb
--- /dev/null
+++ b/pkgs/test_reflective_loader/test/location_test.dart
@@ -0,0 +1,69 @@
+// Copyright (c) 2025, 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:convert';
+import 'dart:io';
+import 'dart:isolate';
+
+import 'package:path/path.dart' as path;
+import 'package:test/test.dart';
+
+void main() {
+  test("reports correct locations in the JSON output from 'dart test'",
+      () async {
+    var testPackagePath = (await Isolate.resolvePackageUri(
+            Uri.parse('package:test_reflective_loader/')))!
+        .toFilePath();
+    var testFilePath = path.normalize(path.join(
+        testPackagePath, '..', 'test', 'test_reflective_loader_test.dart'));
+    var testFileContent = File(testFilePath).readAsLinesSync();
+    var result = await Process.run(
+        Platform.resolvedExecutable, ['test', '-r', 'json', testFilePath]);
+
+    var error = result.stderr.toString().trim();
+    var output = result.stdout.toString().trim();
+
+    expect(error, isEmpty);
+    expect(output, isNotEmpty);
+
+    for (var event in LineSplitter.split(output).map(jsonDecode)) {
+      if (event case {'type': 'testStart', 'test': Map<String, Object?> test}) {
+        var name = test['name'] as String;
+
+        // Skip the "loading" test, it never has a location.
+        if (name.startsWith('loading')) {
+          continue;
+        }
+
+        // Split just the method name from the combined test so we can search
+        // the source code to ensure the locations match up.
+        name = name.split('|').last.trim();
+
+        // Expect locations for all remaining fields.
+        var url = test['url'] as String;
+        var line = test['line'] as int;
+        var column = test['column'] as int;
+
+        expect(path.equals(Uri.parse(url).toFilePath(), testFilePath), isTrue);
+
+        // Verify the location provided matches where this test appears in the
+        // file.
+        var lineContent = testFileContent[line - 1];
+        // If the line is an annotation, skip to the next line
+        if (lineContent.trim().startsWith('@')) {
+          lineContent = testFileContent[line];
+        }
+        expect(lineContent, contains(name),
+            reason: 'JSON reports test $name on line $line, '
+                'but line content is "$lineContent"');
+
+        // Verify the column too.
+        var columnContent = lineContent.substring(column - 1);
+        expect(columnContent, contains(name),
+            reason: 'JSON reports test $name at column $column, '
+                'but text at column is "$columnContent"');
+      }
+    }
+  });
+}