Reapply "[test_runner] Improve timeout deflaking"

The fix is in https://dart-review.googlesource.com/c/sdk/+/441460/1..2

The issue was that the TestConfiguration.selectors field was set
incorrectly, because _expandSelectors was still reading
configuration['test-list-contents'] without accounting for the format
changes. It just needs the test name list, so the fix is to pass in
the already parsed testList variable.

Example build: https://ci.chromium.org/ui/p/dart/builders/try/vm-aot-linux-release-x64-try/7213/overview
compare_results output: https://logs.chromium.org/logs/dart/buildbucket/cr-buildbucket/8708774547991696017/+/u/deflaking/list_tests_to_deflake__vm_tests_/raw_io.output_text
Deflaking run flags: https://logs.chromium.org/logs/dart/buildbucket/cr-buildbucket/8708774547991696017/+/u/deflaking/vm_tests/l_execution_details
Deflaking run output: https://logs.chromium.org/logs/dart/buildbucket/cr-buildbucket/8708774547991696017/+/u/deflaking/vm_tests/stdout

This reverts commit cc5f351c3499bf06b0468f481e2a76d3a236f28e.

TEST=CI
Bug: https://github.com/dart-lang/sdk/issues/55044
Change-Id: Ie9ff1ba71f44ade7b4cd1c9b2f75b9efc1dee24a
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/441460
Commit-Queue: Liam Appelbe <liama@google.com>
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/pkg/test_runner/bin/compare_results.dart b/pkg/test_runner/bin/compare_results.dart
index 10ecbf2..77f1c6c 100755
--- a/pkg/test_runner/bin/compare_results.dart
+++ b/pkg/test_runner/bin/compare_results.dart
@@ -8,10 +8,12 @@
 // readable mode that explains the results and how they changed.
 
 import 'dart:collection';
+import 'dart:convert';
 import 'dart:io';
 
 import 'package:args/args.dart';
 import 'package:test_runner/bot_results.dart';
+import 'package:test_runner/src/deflake_info.dart';
 
 class Event {
   final Result? before;
@@ -50,6 +52,18 @@
       throw Exception("Unreachable");
     }
   }
+
+  String deflakeInfo(String name) {
+    final isTimeout = after.outcome == 'Timeout';
+    final lastTimeMs = before?.timeMs;
+    return jsonEncode(DeflakeInfo(
+      name: name,
+      repeat: isTimeout ? 2 : 5,
+      timeout: isTimeout && lastTimeMs != null
+          ? ((2 * lastTimeMs) / 1000).ceil()
+          : -1,
+    ).toJson());
+  }
 }
 
 class Options {
@@ -70,6 +84,7 @@
   Iterable<String> get statusFilter => ["passing", "flaky", "failing"]
       .where((option) => _options[option] as bool);
   bool get unchanged => _options["unchanged"] as bool;
+  bool get nameOnly => _options["name-only"] as bool;
   bool get verbose => _options["verbose"] as bool;
   List<String> get rest => _options.rest;
 }
@@ -148,8 +163,10 @@
             "${before?.matches} ${after.matches} "
             "${before?.flaked} ${after.flaked}";
       }
-    } else {
+    } else if (options.nameOnly) {
       output = name;
+    } else {
+      output = event.deflakeInfo(name);
     }
     final log = logs[event.after.key];
     final bar = '=' * (output.length + 2);
@@ -191,6 +208,8 @@
       abbr: 'u',
       negatable: false,
       help: "Show only tests with unchanged results.");
+  parser.addFlag("name-only",
+      help: "Only show the test names.", negatable: false);
   parser.addFlag("verbose",
       abbr: "v",
       help: "Show the old and new result for each test",
diff --git a/pkg/test_runner/lib/bot_results.dart b/pkg/test_runner/lib/bot_results.dart
index b313e0a..ac7cbb4 100644
--- a/pkg/test_runner/lib/bot_results.dart
+++ b/pkg/test_runner/lib/bot_results.dart
@@ -30,6 +30,7 @@
   final bool flaked; // From optional flakiness_data argument to constructor.
   final bool? isFlaky; // From results.json after it is extended.
   final String? previousOutcome;
+  final int? timeMs;
 
   Result(
     this.configuration,
@@ -40,7 +41,8 @@
     this.changed,
     this.commitHash,
     this.isFlaky,
-    this.previousOutcome, {
+    this.previousOutcome,
+    this.timeMs, {
     this.flaked = false,
   });
 
@@ -56,6 +58,7 @@
         commitHash = map["commit_hash"] as String?,
         isFlaky = map["flaky"] as bool?,
         previousOutcome = map["previous_result"] as String?,
+        timeMs = map["timeMs"] as int?,
         flaked = flakinessData != null &&
             (flakinessData["active"] ?? true) == true &&
             (flakinessData["outcomes"] as List).contains(map["result"]);
diff --git a/pkg/test_runner/lib/src/configuration.dart b/pkg/test_runner/lib/src/configuration.dart
index b9311e0..5183c38 100644
--- a/pkg/test_runner/lib/src/configuration.dart
+++ b/pkg/test_runner/lib/src/configuration.dart
@@ -11,6 +11,7 @@
 import 'package:smith/smith.dart';
 
 import 'compiler_configuration.dart';
+import 'deflake_info.dart';
 import 'feature.dart';
 import 'path.dart';
 import 'repository.dart';
@@ -31,7 +32,8 @@
       this.selectors = const {},
       this.build = false,
       this.testList = const [],
-      this.repeat = 1,
+      this.deflakeInfoMap = const {},
+      int repeat = 1,
       this.batch = false,
       this.copyCoreDumps = false,
       this.rr = false,
@@ -74,7 +76,8 @@
       : packages = packages ??
             Repository.uri
                 .resolve('.dart_tool/package_config.json')
-                .toFilePath();
+                .toFilePath(),
+        _repeat = repeat;
 
   final Map<String, RegExp?> selectors;
   final Progress progress;
@@ -136,11 +139,12 @@
   final String? dartPrecompiledPath;
   final String? genSnapshotPath;
   final List<String>? testList;
+  final Map<String, DeflakeInfo> deflakeInfoMap;
 
   final int taskCount;
   final int shardCount;
   final int shard;
-  final int repeat;
+  final int _repeat;
 
   final int testServerPort;
   final int testServerCrossOriginPort;
@@ -221,15 +225,15 @@
   ///     build/ReleaseX64
   String get buildDirectory => system.outputDirectory + configurationDirectory;
 
-  int? _timeout;
+  int? _defaultTimeout;
 
   // TODO(whesse): Put non-default timeouts explicitly in configs, not this.
   /// Calculates a default timeout based on the compiler and runtime used,
   /// and the mode, architecture, etc.
-  int get timeout {
-    if (_timeout == null) {
+  int get defaultTimeout {
+    if (_defaultTimeout == null) {
       if (configuration.timeout > 0) {
-        _timeout = configuration.timeout;
+        _defaultTimeout = configuration.timeout;
       } else {
         var isReload = hotReload || hotReloadRollback;
 
@@ -241,13 +245,22 @@
             arch: architecture,
             system: system);
 
-        _timeout = 30 * compilerMultiplier * runtimeMultiplier;
+        _defaultTimeout = 30 * compilerMultiplier * runtimeMultiplier;
       }
     }
 
-    return _timeout!;
+    return _defaultTimeout!;
   }
 
+  /// Returns the timeout for the given test name.
+  int timeout(String name) {
+    final t = deflakeInfoMap[name]?.timeout ?? -1;
+    return t >= 0 ? t : defaultTimeout;
+  }
+
+  /// Returns the repeat count for the given test name.
+  int repeat(String name) => deflakeInfoMap[name]?.repeat ?? _repeat;
+
   List<String> get standardOptions {
     if (compiler != Compiler.dart2js) {
       return const ["--ignore-unrecognized-flags"];
diff --git a/pkg/test_runner/lib/src/deflake_info.dart b/pkg/test_runner/lib/src/deflake_info.dart
new file mode 100644
index 0000000..807e30b
--- /dev/null
+++ b/pkg/test_runner/lib/src/deflake_info.dart
@@ -0,0 +1,25 @@
+// 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.
+
+/// During deflaking, each test can be run with a custom timeout and repetition
+/// count.
+class DeflakeInfo {
+  String name;
+  int repeat;
+  int timeout;
+
+  DeflakeInfo(
+      {required this.name, required this.repeat, required this.timeout});
+
+  Map<dynamic, dynamic> toJson() => {
+        'name': name,
+        'repeat': repeat,
+        'timeout': timeout,
+      };
+
+  static DeflakeInfo fromJson(Map<dynamic, dynamic> json) => DeflakeInfo(
+      name: json['name'] as String,
+      repeat: json['repeat'] as int? ?? 5,
+      timeout: json['timeout'] as int? ?? -1);
+}
diff --git a/pkg/test_runner/lib/src/options.dart b/pkg/test_runner/lib/src/options.dart
index 300a4ae..2d578ca 100644
--- a/pkg/test_runner/lib/src/options.dart
+++ b/pkg/test_runner/lib/src/options.dart
@@ -11,6 +11,7 @@
 import 'package:path/path.dart' as path;
 
 import 'configuration.dart';
+import 'deflake_info.dart';
 import 'path.dart';
 import 'repository.dart';
 import 'test_configurations.dart';
@@ -626,12 +627,16 @@
 
     void addConfiguration(Configuration innerConfiguration,
         [String? namedConfiguration]) {
+      final (testList, deflakeInfoMap) =
+          parseTestList(data["test-list-contents"] as List<String>?);
+
       var configuration = TestConfiguration(
           configuration: innerConfiguration,
           progress: progress,
-          selectors: _expandSelectors(data),
+          selectors: _expandSelectors(data, testList),
           build: data["build"] as bool,
-          testList: data["test-list-contents"] as List<String>?,
+          testList: testList,
+          deflakeInfoMap: deflakeInfoMap ?? const {},
           repeat: int.parse(data["repeat"] as String),
           batch: !(data["no-batch"] as bool),
           copyCoreDumps: data["copy-coredumps"] as bool,
@@ -812,18 +817,15 @@
   ///
   /// If no selectors are explicitly given, uses the default suite patterns.
   Map<String, RegExp> _expandSelectors(
-      Map<String, dynamic> configuration) {
+      Map<String, dynamic> configuration, List<String>? testList) {
     var selectors = configuration['selectors'] as List<String>? ?? [];
 
     if (selectors.isEmpty || configuration['default-suites'] as bool) {
       if (configuration['suite-dir'] != null) {
         var suitePath = Path(configuration['suite-dir'] as String);
         selectors.add(suitePath.filename);
-      } else if (configuration['test-list-contents'] != null) {
-        selectors = (configuration['test-list-contents'] as List<String>)
-            .map((t) => t.split('/').first)
-            .toSet()
-            .toList();
+      } else if (testList != null) {
+        selectors = testList.map((t) => t.split('/').first).toSet().toList();
       } else {
         selectors.addAll(_defaultTestSelectors);
       }
@@ -1009,3 +1011,16 @@
 
   return environment;
 })();
+
+(List<String>?, Map<String, DeflakeInfo>?) parseTestList(List<String>? raw) {
+  final isJson = raw != null && raw.isNotEmpty && raw[0].startsWith('{');
+  if (!isJson) return (raw, null);
+  final deflakes = <DeflakeInfo>[
+    for (var line in raw)
+      DeflakeInfo.fromJson(jsonDecode(line) as Map<dynamic, dynamic>),
+  ];
+  return (
+    [for (var i in deflakes) i.name],
+    {for (var i in deflakes) i.name: i},
+  );
+}
diff --git a/pkg/test_runner/lib/src/process_queue.dart b/pkg/test_runner/lib/src/process_queue.dart
index a426b75..775de22 100644
--- a/pkg/test_runner/lib/src/process_queue.dart
+++ b/pkg/test_runner/lib/src/process_queue.dart
@@ -253,7 +253,7 @@
   /// test completing successfully, just on it completing.
   void _add(TestCase testCase) {
     Node<Command>? lastNode;
-    for (var i = 0; i < testCase.configuration.repeat; ++i) {
+    for (var i = 0; i < testCase.repeat; ++i) {
       if (i > 0) {
         testCase = testCase.indexedCopy(i);
       }
diff --git a/pkg/test_runner/lib/src/test_case.dart b/pkg/test_runner/lib/src/test_case.dart
index 1633e1a..5164ae9 100644
--- a/pkg/test_runner/lib/src/test_case.dart
+++ b/pkg/test_runner/lib/src/test_case.dart
@@ -133,7 +133,7 @@
   }
 
   int get timeout {
-    var result = configuration.timeout;
+    var result = configuration.timeout(displayName);
     if (expectedOutcomes.contains(Expectation.slow)) {
       result *= _slowTimeoutMultiplier;
     } else if (expectedOutcomes.contains(Expectation.extraSlow)) {
@@ -142,6 +142,8 @@
     return result;
   }
 
+  int get repeat => configuration.repeat(displayName);
+
   String get configurationString {
     var compiler = configuration.compiler.name;
     var runtime = configuration.runtime.name;
diff --git a/pkg/test_runner/lib/test_runner.dart b/pkg/test_runner/lib/test_runner.dart
index b1f25c3..860028c 100644
--- a/pkg/test_runner/lib/test_runner.dart
+++ b/pkg/test_runner/lib/test_runner.dart
@@ -577,6 +577,7 @@
   // Find the list of tests to deflake.
   var deflakeListOutput = await runProcess(Platform.resolvedExecutable, [
     "tools/bots/compare_results.dart",
+    "--name-only",
     "--changed",
     "--failing",
     "--passing",
diff --git a/pkg/test_runner/test/compare_results/compare_results_test.dart b/pkg/test_runner/test/compare_results/compare_results_test.dart
index c655ddf..5318cf9 100644
--- a/pkg/test_runner/test/compare_results/compare_results_test.dart
+++ b/pkg/test_runner/test/compare_results/compare_results_test.dart
@@ -197,7 +197,8 @@
     String commitHash = 'abcdabcd',
     bool flaked = false,
     bool isFlaky = false,
-    String previousOutcome = 'Pass'}) {
+    String previousOutcome = 'Pass',
+    int timeMs = -1}) {
   return Result(
     configuration,
     name,
@@ -208,6 +209,7 @@
     commitHash,
     isFlaky,
     previousOutcome,
+    timeMs,
     flaked: flaked,
   );
 }