[reload_test] Adding support for negative tests in reload suite.

* Extends the frontend server controller to validate/reject compile errors in compiles/recompiles.
* Extends the config to permit an 'expectedError' entry.
* Adds a handful of tests to the suite (adapted from the VM's hot reload tests).

Change-Id: I47d814e375c4c72d0406ebf5bdfee3f1975c64f0
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/363800
Reviewed-by: Nicholas Shahan <nshahan@google.com>
Commit-Queue: Mark Zhou <markzipan@google.com>
diff --git a/pkg/dev_compiler/test/hot_reload_suite.dart b/pkg/dev_compiler/test/hot_reload_suite.dart
index 1055356..9846d40 100644
--- a/pkg/dev_compiler/test/hot_reload_suite.dart
+++ b/pkg/dev_compiler/test/hot_reload_suite.dart
@@ -155,8 +155,8 @@
         // Ignore Dart source files, which may be imported as helpers
         continue;
       }
-      throw Exception(
-          'Non-directory or file entity found in ${allTestsUri.toFilePath()}: $testDir');
+      throw Exception('Non-directory or file entity found in '
+          '${allTestsUri.toFilePath()}: $testDir');
     }
     final testDirParts = testDir.uri.pathSegments;
     final testName = testDirParts[testDirParts.length - 2];
@@ -167,6 +167,20 @@
     );
     var stopwatch = Stopwatch()..start();
 
+    // Report results for this test.
+    Future<void> reportTestOutcome(String testOutput, bool testPassed) async {
+      stopwatch.stop();
+      outcome.elapsedTime = stopwatch.elapsed;
+      outcome.testOutput = testOutput;
+      outcome.matchedExpectations = testPassed;
+      testOutcomes.add(outcome);
+      if (testPassed) {
+        _print('PASSED with:\n$testOutput', label: testName);
+      } else {
+        _print('FAILED with:\n$testOutput', label: testName);
+      }
+    }
+
     final tempUri = generatedCodeUri.resolve('$testName/');
     Directory.fromUri(tempUri).createSync();
 
@@ -221,6 +235,7 @@
     _print('Generating code over ${maxGenerations + 1} generations.',
         label: testName);
 
+    var hasCompileError = false;
     // Generate hot reload/restart generations as subdirectories in a loop.
     var currentGeneration = 0;
     while (currentGeneration <= maxGenerations) {
@@ -231,7 +246,8 @@
       // names restored (e.g., path/to/main' from 'path/to/main.0.dart).
       // TODO(markzipan): support subdirectories.
       _debugPrint(
-          'Copying Dart files to snapshot directory: ${snapshotUri.toFilePath()}',
+          'Copying Dart files to snapshot directory: '
+          '${snapshotUri.toFilePath()}',
           label: testName);
       for (var file in testDir.listSync()) {
         // Convert a name like `/path/foo.bar.25.dart` to `/path/foo.bar.dart`.
@@ -267,21 +283,49 @@
       _print(
           'Compiling generation $currentGeneration with the Frontend Server.',
           label: testName);
+      CompilerOutput compilerOutput;
       if (currentGeneration == 0) {
         _debugPrint(
             'Compiling snapshot entrypoint: $snapshotEntrypointWithScheme',
             label: testName);
         outputDirectoryPath = outputDillUri.toFilePath();
-        await controller.sendCompileAndAccept(snapshotEntrypointWithScheme);
+        compilerOutput =
+            await controller.sendCompile(snapshotEntrypointWithScheme);
       } else {
         _debugPrint(
             'Recompiling snapshot entrypoint: $snapshotEntrypointWithScheme',
             label: testName);
         outputDirectoryPath = outputIncrementalDillUri.toFilePath();
         // TODO(markzipan): Add logic to reject bad compiles.
-        await controller.sendRecompileAndAccept(snapshotEntrypointWithScheme,
+        compilerOutput = await controller.sendRecompile(
+            snapshotEntrypointWithScheme,
             invalidatedFiles: updatedFilesInCurrentGeneration);
       }
+      // Frontend Server reported compile errors. Fail if they weren't
+      // expected, and do not run tests.
+      if (compilerOutput.errorCount > 0) {
+        hasCompileError = true;
+        await controller.sendReject();
+        // TODO(markzipan): Determine if 'contains' is good enough to determine
+        // compilation error correctness.
+        if (testConfig.expectedError != null &&
+            compilerOutput.outputText.contains(testConfig.expectedError!)) {
+          await reportTestOutcome(
+              'Expected error found during compilation: '
+              '${testConfig.expectedError}',
+              true);
+        } else {
+          await reportTestOutcome(
+              'Test failed with compile error: ${compilerOutput.outputText}',
+              false);
+        }
+      } else {
+        controller.sendAccept();
+      }
+
+      // Stop processing further generations if compilation failed.
+      if (hasCompileError) break;
+
       _debugPrint(
           'Frontend Server successfully compiled outputs to: '
           '$outputDirectoryPath',
@@ -324,6 +368,14 @@
       currentGeneration++;
     }
 
+    // Skip to the next test and avoid execution if we encountered a
+    // compilation error.
+    if (hasCompileError) {
+      _print('Did not emit all assets due to compilation error.',
+          label: testName);
+      continue;
+    }
+
     _print('Finished emitting assets.', label: testName);
 
     final testOutputStreamController = StreamController<List<int>>();
@@ -369,7 +421,8 @@
         ];
         final vm = await Process.start(Platform.executable, vmArgs);
         _debugPrint(
-            'Starting VM with command: ${Platform.executable} ${vmArgs.join(" ")}',
+            'Starting VM with command: '
+            '${Platform.executable} ${vmArgs.join(" ")}',
             label: testName);
         vm.stdout
             .transform(utf8.decoder)
@@ -398,19 +451,7 @@
         testPassed = vmExitCode == 0;
     }
 
-    stopwatch.stop();
-    final testOutput = testOutputBuffer.toString();
-    outcome.elapsedTime = stopwatch.elapsed;
-    outcome.matchedExpectations = testPassed;
-    outcome.testOutput = testOutput;
-    testOutcomes.add(outcome);
-    if (testPassed) {
-      _print('PASSED with:\n$testOutput', label: testName);
-    } else {
-      _print('FAILED with:\n$testOutput', label: testName);
-      await shutdown();
-      exit(1);
-    }
+    await reportTestOutcome(testOutputBuffer.toString(), testPassed);
   }
 
   await shutdown();
@@ -444,6 +485,17 @@
     _print('Emitted logs to ${testResultsUri.toFilePath()} '
         'and ${testLogsUri.toFilePath()}.');
   }
+
+  // Report failed tests.
+  var failedTests =
+      testOutcomes.where((outcome) => !outcome.matchedExpectations);
+  if (failedTests.isNotEmpty) {
+    print('Some tests failed:');
+    failedTests.forEach((outcome) {
+      print('${outcome.testName} failed with:\n${outcome.testOutput}');
+    });
+    exit(1);
+  }
 }
 
 /// Runs the [command] with [args] in [environment].
diff --git a/pkg/reload_test/lib/frontend_server_controller.dart b/pkg/reload_test/lib/frontend_server_controller.dart
index 49981aa6..bf4e19e 100644
--- a/pkg/reload_test/lib/frontend_server_controller.dart
+++ b/pkg/reload_test/lib/frontend_server_controller.dart
@@ -13,6 +13,38 @@
 
 final debug = false;
 
+/// Represents the output of a FrontendServer's 'compile' or 'recompile'.
+class CompilerOutput {
+  CompilerOutput({
+    required this.outputDillPath,
+    required this.errorCount,
+    this.sources = const [],
+    this.outputText = '',
+  });
+
+  /// Output for a 'reject' response.
+  factory CompilerOutput.rejectOutput() {
+    return CompilerOutput(
+      outputDillPath: '',
+      errorCount: 0,
+    );
+  }
+
+  final String outputDillPath;
+  final int errorCount;
+  final List<Uri> sources;
+  final String outputText;
+}
+
+enum FrontendServerState {
+  awaitingResult,
+  awaitingKey,
+  collectingResultSources,
+  awaitingReject,
+  awaitingRejectKey,
+  finished,
+}
+
 /// Controls and synchronizes the Frontend Server during hot reloaad tests.
 ///
 /// The Frontend Server accepts the following instructions:
@@ -27,11 +59,12 @@
 /// > accept
 ///
 /// > quit
-
-// 'compile' and 'recompile' instructions output the following on completion:
-//   result <boundary-key>
-//   <compiler output>
-//   <boundary-key> [<output.dill>]
+///
+/// 'compile' and 'recompile' instructions output the following on completion:
+///   result <boundary-key>
+///   <boundary-key>
+///   [<error text or modified files prefixed by '-' or '+'>]
+///   <boundary-key> [<output.dill>] <error-count>
 class HotReloadFrontendServerController {
   final List<String> frontendServerArgs;
 
@@ -43,23 +76,36 @@
 
   /// Contains one event per completed Frontend Server 'compile' or 'recompile'
   /// command.
-  final StreamController<String> compileCommandOutputChannel;
+  final StreamController<CompilerOutput> compileCommandOutputChannel;
 
   /// An iterator over `compileCommandOutputChannel`.
   /// Should be awaited after every 'compile' or 'recompile' command.
-  final StreamIterator<String> synchronizer;
+  final StreamIterator<CompilerOutput> synchronizer;
 
+  /// Whether or not this controller has already been started.
   bool started = false;
-  String? _boundaryKey;
+
+  /// Initialize to an invalid string prior to the first result.
+  String _boundaryKey = 'INVALID';
+
   late Future<int> frontendServerExitCode;
 
+  /// Source file URIs reported by the Frontend Server.
+  List<Uri> sources = [];
+
+  List<String> accumulatedOutput = [];
+
+  int totalErrors = 0;
+
+  FrontendServerState _state = FrontendServerState.awaitingResult;
+
   HotReloadFrontendServerController._(this.frontendServerArgs, this.input,
       this.output, this.compileCommandOutputChannel, this.synchronizer);
 
   factory HotReloadFrontendServerController(List<String> frontendServerArgs) {
     var input = StreamController<List<int>>();
     var output = StreamController<List<int>>();
-    var compileCommandOutputChannel = StreamController<String>();
+    var compileCommandOutputChannel = StreamController<CompilerOutput>();
     var synchronizer = StreamIterator(compileCommandOutputChannel.stream);
     return HotReloadFrontendServerController._(frontendServerArgs, input,
         output, compileCommandOutputChannel, synchronizer);
@@ -78,15 +124,67 @@
         .transform(const LineSplitter())
         .listen((String s) {
       if (debug) print('Frontend Server Response: $s');
-      if (_boundaryKey == null) {
-        if (s.startsWith(frontEndResponsePrefix)) {
+      switch (_state) {
+        case FrontendServerState.awaitingReject:
+          if (!s.startsWith(frontEndResponsePrefix)) {
+            throw Exception('Unexpected Frontend Server response: $s');
+          }
           _boundaryKey = s.substring(frontEndResponsePrefix.length);
-        }
-      } else {
-        if (s.startsWith(_boundaryKey!)) {
-          compileCommandOutputChannel.add(_boundaryKey!);
-          _boundaryKey = null;
-        }
+          _state = FrontendServerState.awaitingRejectKey;
+          break;
+        case FrontendServerState.awaitingRejectKey:
+          if (s != _boundaryKey) {
+            throw Exception('Unexpected Frontend Server response for reject '
+                '(expected just a key): $s');
+          }
+          _state = FrontendServerState.finished;
+          compileCommandOutputChannel.add(CompilerOutput.rejectOutput());
+          _clearState();
+          break;
+        case FrontendServerState.awaitingResult:
+          if (!s.startsWith(frontEndResponsePrefix)) {
+            throw Exception('Unexpected Frontend Server response: $s');
+          }
+          _boundaryKey = s.substring(frontEndResponsePrefix.length);
+          _state = FrontendServerState.awaitingKey;
+          break;
+        case FrontendServerState.awaitingKey:
+          // Advance to the next state when we encounter a lone boundary key.
+          if (s == _boundaryKey) {
+            _state = FrontendServerState.collectingResultSources;
+          } else {
+            accumulatedOutput.add(s);
+          }
+        case FrontendServerState.collectingResultSources:
+          // Stop and record the result when we encounter a boundary key.
+          if (s.startsWith(_boundaryKey)) {
+            final compilationReportOutput = s.split(' ');
+            final outputDillPath = compilationReportOutput[1];
+            final errorCount = int.parse(compilationReportOutput[2]);
+            // The FrontendServer accumulates all errors seen so far, so we
+            // need to correct for errors from previous compilations.
+            final actualErrorCount = errorCount - totalErrors;
+            final compilerOutput = CompilerOutput(
+              outputDillPath: outputDillPath,
+              errorCount: actualErrorCount,
+              sources: sources,
+              outputText: accumulatedOutput.join('\n'),
+            );
+            totalErrors = errorCount;
+            _state = FrontendServerState.finished;
+            compileCommandOutputChannel.add(compilerOutput);
+            _clearState();
+          } else if (s.startsWith('+')) {
+            sources.add(Uri.parse(s.substring(1)));
+          } else if (s.startsWith('-')) {
+            sources.remove(Uri.parse(s.substring(1)));
+          } else {
+            throw Exception("Unexpected Frontend Server response "
+                "(expected '+' or '-')'): $s");
+          }
+          break;
+        case FrontendServerState.finished:
+          throw StateError('Frontend Server reached an unexpected state: $s');
       }
     });
 
@@ -99,12 +197,23 @@
     started = true;
   }
 
-  Future<void> sendCompile(String dartSourcePath) async {
+  /// Clears the controller's state between commands.
+  ///
+  /// Note: this does not reset the Frontend Server's state.
+  void _clearState() {
+    sources.clear();
+    accumulatedOutput.clear();
+    _boundaryKey = 'INVALID';
+  }
+
+  Future<CompilerOutput> sendCompile(String dartSourcePath) async {
     if (!started) throw Exception('Frontend Server has not been started yet.');
+    _state = FrontendServerState.awaitingResult;
     final command = 'compile $dartSourcePath\n';
     if (debug) print('Sending instruction to Frontend Server:\n$command');
     input.add(command.codeUnits);
     await synchronizer.moveNext();
+    return synchronizer.current;
   }
 
   Future<void> sendCompileAndAccept(String dartSourcePath) async {
@@ -112,15 +221,17 @@
     sendAccept();
   }
 
-  Future<void> sendRecompile(String entrypointPath,
+  Future<CompilerOutput> sendRecompile(String entrypointPath,
       {List<String> invalidatedFiles = const [],
       String boundaryKey = fakeBoundaryKey}) async {
     if (!started) throw Exception('Frontend Server has not been started yet.');
+    _state = FrontendServerState.awaitingResult;
     final command = 'recompile $entrypointPath $boundaryKey\n'
         '${invalidatedFiles.join('\n')}\n$boundaryKey\n';
     if (debug) print('Sending instruction to Frontend Server:\n$command');
     input.add(command.codeUnits);
     await synchronizer.moveNext();
+    return synchronizer.current;
   }
 
   Future<void> sendRecompileAndAccept(String entrypointPath,
@@ -134,12 +245,19 @@
   void sendAccept() {
     if (!started) throw Exception('Frontend Server has not been started yet.');
     final command = 'accept\n';
-    // TODO(markzipan): We should reject certain invalid compiles (e.g., those
-    // with unimplemented or invalid nodes).
     if (debug) print('Sending instruction to Frontend Server:\n$command');
     input.add(command.codeUnits);
   }
 
+  Future<void> sendReject() async {
+    if (!started) throw Exception('Frontend Server has not been started yet.');
+    _state = FrontendServerState.awaitingReject;
+    final command = 'reject\n';
+    if (debug) print('Sending instruction to Frontend Server:\n$command');
+    input.add(command.codeUnits);
+    await synchronizer.moveNext();
+  }
+
   void _sendQuit() {
     if (!started) throw Exception('Frontend Server has not been started yet.');
     final command = 'quit\n';
diff --git a/pkg/reload_test/lib/test_helpers.dart b/pkg/reload_test/lib/test_helpers.dart
index 3db6bca..7b984a0 100644
--- a/pkg/reload_test/lib/test_helpers.dart
+++ b/pkg/reload_test/lib/test_helpers.dart
@@ -69,11 +69,13 @@
 class ReloadTestConfiguration {
   final Map<String, dynamic> _values;
   final Set<RuntimePlatforms> excludedPlaforms;
+  final String? expectedError;
 
-  ReloadTestConfiguration._(this._values, this.excludedPlaforms);
+  ReloadTestConfiguration._(
+      this._values, this.excludedPlaforms, this.expectedError);
 
   factory ReloadTestConfiguration() => ReloadTestConfiguration._(
-      const <String, dynamic>{}, <RuntimePlatforms>{});
+      const <String, dynamic>{}, <RuntimePlatforms>{}, null);
 
   factory ReloadTestConfiguration.fromJsonFile(Uri file) {
     final Map<String, dynamic> jsonData =
@@ -86,7 +88,8 @@
         excludedPlaforms.add(runtimePlatform);
       }
     }
-    return ReloadTestConfiguration._(jsonData, excludedPlaforms);
+    return ReloadTestConfiguration._(
+        jsonData, excludedPlaforms, jsonData['expectedError']);
   }
 
   String toJson() {
diff --git a/tests/hot_reload/bad_class/config.json b/tests/hot_reload/bad_class/config.json
new file mode 100644
index 0000000..736ed4d
--- /dev/null
+++ b/tests/hot_reload/bad_class/config.json
@@ -0,0 +1,7 @@
+{
+  "exclude": [
+    "d8",
+    "chrome"
+  ],
+  "expectedError": "Error: Expected ';' after this"
+}
\ No newline at end of file
diff --git a/tests/hot_reload/bad_class/main.0.dart b/tests/hot_reload/bad_class/main.0.dart
new file mode 100644
index 0000000..f464c78
--- /dev/null
+++ b/tests/hot_reload/bad_class/main.0.dart
@@ -0,0 +1,21 @@
+// Copyright (c) 2024, 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:expect/expect.dart';
+import 'package:reload_test/reload_test_utils.dart';
+
+// Adapted from:
+// https://github.com/dart-lang/sdk/blob/36c0788137d55c6c77f4b9a8be12e557bc764b1c/runtime/vm/isolate_reload_test.cc#L364
+
+class Foo {
+  final a;
+  Foo(this.a);
+}
+
+Future<void> main() async {
+  var foo = Foo(5);
+  Expect.equals(5, foo.a);
+  await hotReload();
+  throw Exception('This should never run.');
+}
diff --git a/tests/hot_reload/bad_class/main.1.dart b/tests/hot_reload/bad_class/main.1.dart
new file mode 100644
index 0000000..653efa7
--- /dev/null
+++ b/tests/hot_reload/bad_class/main.1.dart
@@ -0,0 +1,21 @@
+// Copyright (c) 2024, 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:expect/expect.dart';
+import 'package:reload_test/reload_test_utils.dart';
+
+// Adapted from:
+// https://github.com/dart-lang/sdk/blob/36c0788137d55c6c77f4b9a8be12e557bc764b1c/runtime/vm/isolate_reload_test.cc#L364
+
+class Foo {
+  final a kjsdf ksjdf;
+  Foo(this.a);
+}
+
+Future<void> main() async {
+  var foo = Foo(5);
+  Expect.equals(5, foo.a);
+  await hotReload();
+  throw Exception('This should never run.');
+}
diff --git a/tests/hot_reload/compile_base_class/config.json b/tests/hot_reload/compile_base_class/config.json
new file mode 100644
index 0000000..908c53e
--- /dev/null
+++ b/tests/hot_reload/compile_base_class/config.json
@@ -0,0 +1,3 @@
+{
+  "exclude": ["d8", "chrome"]
+}
diff --git a/tests/hot_reload/compile_base_class/lib.0.dart b/tests/hot_reload/compile_base_class/lib.0.dart
new file mode 100644
index 0000000..05c5039
--- /dev/null
+++ b/tests/hot_reload/compile_base_class/lib.0.dart
@@ -0,0 +1,12 @@
+// Copyright (c) 2024, 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.
+
+class State<T, U> {
+  T? t;
+  U? u;
+  State(List l) {
+    t = l[0] is T ? l[0] : null;
+    u = l[1] is U ? l[1] : null;
+  }
+}
diff --git a/tests/hot_reload/compile_base_class/lib.1.dart b/tests/hot_reload/compile_base_class/lib.1.dart
new file mode 100644
index 0000000..eec8eeb
--- /dev/null
+++ b/tests/hot_reload/compile_base_class/lib.1.dart
@@ -0,0 +1,12 @@
+// Copyright (c) 2024, 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.
+
+class State<U, T> {
+  T? t;
+  U? u;
+  State(List l) {
+    t = l[0] is T ? l[0] : null;
+    u = l[1] is U ? l[1] : null;
+  }
+}
diff --git a/tests/hot_reload/compile_base_class/main.0.dart b/tests/hot_reload/compile_base_class/main.0.dart
new file mode 100644
index 0000000..767d5f1
--- /dev/null
+++ b/tests/hot_reload/compile_base_class/main.0.dart
@@ -0,0 +1,20 @@
+// Copyright (c) 2024, 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:expect/expect.dart';
+import 'package:reload_test/reload_test_utils.dart';
+
+import 'util.dart';
+
+// Adapted from:
+// https://github.com/dart-lang/sdk/blob/36c0788137d55c6c77f4b9a8be12e557bc764b1c/runtime/vm/isolate_reload_test.cc#L278
+
+Future<void> main() async {
+  var v = doWork();
+  Expect.equals(42, v);
+  await hotReload();
+
+  v = doWork();
+  Expect.equals(null, v);
+}
diff --git a/tests/hot_reload/compile_base_class/util.0.dart b/tests/hot_reload/compile_base_class/util.0.dart
new file mode 100644
index 0000000..1a3ce7f
--- /dev/null
+++ b/tests/hot_reload/compile_base_class/util.0.dart
@@ -0,0 +1,12 @@
+// Copyright (c) 2024, 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 'lib.dart';
+
+class MyAccountState extends State<int, String> {
+  MyAccountState(List l) : super(l) {}
+  first() => t;
+}
+
+doWork() => new MyAccountState(<dynamic>[42, 'abc']).first();
diff --git a/tests/hot_reload/compile_generics/config.json b/tests/hot_reload/compile_generics/config.json
new file mode 100644
index 0000000..908c53e
--- /dev/null
+++ b/tests/hot_reload/compile_generics/config.json
@@ -0,0 +1,3 @@
+{
+  "exclude": ["d8", "chrome"]
+}
diff --git a/tests/hot_reload/compile_generics/lib.0.dart b/tests/hot_reload/compile_generics/lib.0.dart
new file mode 100644
index 0000000..cf1ba7ced
--- /dev/null
+++ b/tests/hot_reload/compile_generics/lib.0.dart
@@ -0,0 +1,9 @@
+// Copyright (c) 2024, 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.
+
+class State<T> {
+  T t;
+  State(this.t);
+  T howAreTheThings() => t;
+}
diff --git a/tests/hot_reload/compile_generics/main.0.dart b/tests/hot_reload/compile_generics/main.0.dart
new file mode 100644
index 0000000..030ef99
--- /dev/null
+++ b/tests/hot_reload/compile_generics/main.0.dart
@@ -0,0 +1,28 @@
+// Copyright (c) 2024, 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:expect/expect.dart';
+import 'package:reload_test/reload_test_utils.dart';
+
+import 'lib.dart';
+
+// Adapted from:
+// https://github.com/dart-lang/sdk/blob/36c0788137d55c6c77f4b9a8be12e557bc764b1c/runtime/vm/isolate_reload_test.cc#L204
+
+class Account {
+  int balance() => 42;
+}
+
+class MyAccountState extends State<Account> {
+  MyAccountState(Account a) : super(a) {}
+}
+
+Future<void> main() async {
+  var balance = (MyAccountState(Account())).howAreTheThings().balance();
+  Expect.equals(42, balance);
+  await hotReload();
+
+  balance = (MyAccountState(Account())).howAreTheThings().balance();
+  Expect.equals(24, balance);
+}
diff --git a/tests/hot_reload/compile_generics/main.1.dart b/tests/hot_reload/compile_generics/main.1.dart
new file mode 100644
index 0000000..706f9d5
--- /dev/null
+++ b/tests/hot_reload/compile_generics/main.1.dart
@@ -0,0 +1,28 @@
+// Copyright (c) 2024, 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:expect/expect.dart';
+import 'package:reload_test/reload_test_utils.dart';
+
+import 'lib.dart';
+
+// Adapted from:
+// https://github.com/dart-lang/sdk/blob/36c0788137d55c6c77f4b9a8be12e557bc764b1c/runtime/vm/isolate_reload_test.cc#L204
+
+class Account {
+  int balance() => 24;
+}
+
+class MyAccountState extends State<Account> {
+  MyAccountState(Account a) : super(a) {}
+}
+
+Future<void> main() async {
+  var balance = (MyAccountState(Account())).howAreTheThings().balance();
+  Expect.equals(42, balance);
+  await hotReload();
+
+  balance = (MyAccountState(Account())).howAreTheThings().balance();
+  Expect.equals(24, balance);
+}