[ddc] Adding tests for pkg:reload_test and enabling reload tests in test bots.

* Adds tests for the memory filesystem
* Enables hot restart/reload tests
* Adds flags to the hot reload suite
* Makes path resolution logic windows-friendly
* Adds test-reporting logic for trybots to hot reload tests

Change-Id: Ic51a0b8a3c6f8b6de20b58b2ac185dacf444cf47
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/353788
Reviewed-by: Nicholas Shahan <nshahan@google.com>
Reviewed-by: Nate Bosch <nbosch@google.com>
Commit-Queue: Mark Zhou <markzipan@google.com>
diff --git a/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js b/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js
index 58354a2..c2f37e7 100644
--- a/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js
+++ b/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js
@@ -732,6 +732,8 @@
         // DDCLoader, which is unique per-app.
         this.root = trimmedDirectory;
 
+        this.isWindows = false;
+
         // Optional event handlers.
         // Called when modules begin loading.
         this.onLoadStart = () => { };
@@ -837,21 +839,24 @@
     // Joins path segments from the root directory to [script]'s path to get a
     // complete URL.
     getScriptUrl(script) {
+      let pathSlash = this.loadConfig.isWindows ? "\\" : "/";
       // Get path segments for src
-      let splitSrc = script.src.toString().toString().split("/");
+      let splitSrc = script.src.toString().split(pathSlash);
       let j = 0;
       // Count number of relative path segments
       while (splitSrc[j] == "..") {
         j++;
       }
       // Get path segments for root directory
-      let splitDir = this.loadConfig.root.split("/");
+      let splitDir = !this.loadConfig.root
+        || this.loadConfig.root == pathSlash ? []
+        : this.loadConfig.root.split(pathSlash);
       // Account for relative path from the root directory
       let splitPath = splitDir
         .slice(0, splitDir.length - j)
         .concat(splitSrc.slice(j));
       // Join path segments to get a complete path
-      return splitPath.join("/");
+      return splitPath.join(pathSlash);
     };
 
     // Adds [script] to the dartLoader's internals as if it had been loaded and
diff --git a/pkg/dev_compiler/test/hot_reload_suite.dart b/pkg/dev_compiler/test/hot_reload_suite.dart
index 74e984d..968d66a 100644
--- a/pkg/dev_compiler/test/hot_reload_suite.dart
+++ b/pkg/dev_compiler/test/hot_reload_suite.dart
@@ -3,40 +3,65 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:convert';
 import 'dart:io';
 import 'dart:math';
 
 import 'package:_fe_analyzer_shared/src/util/relativize.dart' as fe_shared;
+import 'package:args/args.dart';
 import 'package:dev_compiler/dev_compiler.dart' as ddc_names
     show libraryUriToJsIdentifier;
 import 'package:front_end/src/compute_platform_binaries_location.dart' as fe;
 import 'package:reload_test/ddc_helpers.dart' as ddc_helpers;
 import 'package:reload_test/frontend_server_controller.dart';
 import 'package:reload_test/hot_reload_memory_filesystem.dart';
+import 'package:reload_test/test_helpers.dart';
 
-final verbose = true;
-final debug = true;
+final argParser = ArgParser()
+  ..addOption('named-configuration',
+      abbr: 'n',
+      defaultsTo: 'no-configuration',
+      help: 'configuration name to use for emitting test result files.')
+  ..addOption('output-directory', help: 'Directory to emit test results files.')
+  ..addFlag('debug',
+      abbr: 'd',
+      defaultsTo: true,
+      negatable: true,
+      help: 'enables additional debug behavior and logging.')
+  ..addFlag('verbose',
+      abbr: 'v',
+      defaultsTo: true,
+      negatable: true,
+      help: 'enables verbose logging.');
+
+late final bool verbose;
+late final bool debug;
 
 /// TODO(markzipan): Add arg parsing for additional execution modes
 /// (chrome, VM) and diffs across generations.
 Future<void> main(List<String> args) async {
+  final argResults = argParser.parse(args);
+  verbose = argResults['verbose'] as bool;
+  debug = argResults['debug'] as bool;
+
+  // Used to communicate individual test failures to our test bots.
+  final emitTestResultsJson = argResults['output-directory'] != null;
   final buildRootUri = fe.computePlatformBinariesLocation(forceBuildDir: true);
   // We can use the outline instead of the full SDK dill here.
   final ddcPlatformDillUri = buildRootUri.resolve('ddc_outline.dill');
 
   final sdkRoot = Platform.script.resolve('../../../');
   final packageConfigUri = sdkRoot.resolve('.dart_tool/package_config.json');
-  final hotReloadTestUri = sdkRoot.resolve('tests/hot_reload/');
-  final soundStableDartSdkJsPath =
-      buildRootUri.resolve('gen/utils/ddc/stable/sdk/ddc/dart_sdk.js').path;
-  final ddcModuleLoaderJsPath =
-      sdkRoot.resolve('pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js').path;
+  final allTestsUri = sdkRoot.resolve('tests/hot_reload/');
+  final soundStableDartSdkJsUri =
+      buildRootUri.resolve('gen/utils/ddc/stable/sdk/ddc/dart_sdk.js');
+  final ddcModuleLoaderJsUri =
+      sdkRoot.resolve('pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js');
   final d8PreamblesUri = sdkRoot
       .resolve('sdk/lib/_internal/js_dev_runtime/private/preambles/d8.js');
   final sealNativeObjectJsUri = sdkRoot.resolve(
       'sdk/lib/_internal/js_runtime/lib/preambles/seal_native_object.js');
   final d8BinaryUri = sdkRoot.resolveUri(ddc_helpers.d8executableUri);
-  final allTestsDir = Directory(hotReloadTestUri.path);
 
   // Contains generated code for all tests.
   final generatedCodeDir = Directory.systemTemp.createTempSync();
@@ -57,22 +82,24 @@
 
   // TODO(markzipan): Support custom entrypoints.
   final snapshotEntrypointUri = snapshotUri.resolve('main.dart');
-  final filesystemRootUri = snapshotDir.uri;
+  final filesystemRootUri = snapshotUri;
   final filesystemScheme = 'hot-reload-test';
   final snapshotEntrypointLibraryName = fe_shared.relativizeUri(
       filesystemRootUri, snapshotEntrypointUri, fe_shared.isWindows);
   final snapshotEntrypointWithScheme =
       '$filesystemScheme:///$snapshotEntrypointLibraryName';
+  final ddcPlatformDillFromSdkRoot =
+      fe_shared.relativizeUri(sdkRoot, ddcPlatformDillUri, fe_shared.isWindows);
   final ddcArgs = [
     '--dartdevc-module-format=ddc',
     '--incremental',
-    '--filesystem-root=${snapshotDir.path}',
+    '--filesystem-root=${snapshotUri.toFilePath()}',
     '--filesystem-scheme=$filesystemScheme',
-    '--output-dill=${outputDillUri.path}',
-    '--output-incremental-dill=${outputIncrementalDillUri.path}',
-    '--packages=${packageConfigUri.path}',
-    '--platform=${ddcPlatformDillUri.path}',
-    '--sdk-root=${sdkRoot.path}',
+    '--output-dill=${outputDillUri.toFilePath()}',
+    '--output-incremental-dill=${outputIncrementalDillUri.toFilePath()}',
+    '--packages=${packageConfigUri.toFilePath()}',
+    '--platform=$ddcPlatformDillFromSdkRoot',
+    '--sdk-root=${sdkRoot.toFilePath()}',
     '--target=dartdevc',
     '--verbosity=${verbose ? 'all' : 'info'}',
   ];
@@ -90,22 +117,31 @@
     }
   }
 
-  for (var testDir in allTestsDir.listSync()) {
+  final testOutcomes = <TestResultOutcome>[];
+  for (var testDir in Directory.fromUri(allTestsUri).listSync()) {
     if (testDir is! Directory) {
       if (testDir is File) {
         // Ignore Dart source files, which may be imported as helpers
         continue;
       }
       throw Exception(
-          'Non-directory or file entity found in ${allTestsDir.path}: $testDir');
+          'Non-directory or file entity found in ${allTestsUri.toFilePath()}: $testDir');
     }
     final testDirParts = testDir.uri.pathSegments;
     final testName = testDirParts[testDirParts.length - 2];
+
+    var outcome = TestResultOutcome(
+      configuration: argResults['named-configuration'] as String,
+      testName: testName,
+    );
+    var stopwatch = Stopwatch()..start();
+
     final tempUri = generatedCodeUri.resolve('$testName/');
     Directory.fromUri(tempUri).createSync();
 
     _print('Generating test assets.', label: testName);
-    _debugPrint('Emitting JS code to ${tempUri.path}.', label: testName);
+    _debugPrint('Emitting JS code to ${tempUri.toFilePath()}.',
+        label: testName);
 
     var filesystem = HotReloadMemoryFilesystem(tempUri);
 
@@ -129,7 +165,7 @@
     }
 
     // TODO(markzipan): replace this with a test-configurable main entrypoint.
-    final mainDartFilePath = testDir.uri.resolve('main.dart').path;
+    final mainDartFilePath = testDir.uri.resolve('main.dart').toFilePath();
     _debugPrint('Test entrypoint: $mainDartFilePath', label: testName);
     _print('Generating code over ${maxGenerations + 1} generations.',
         label: testName);
@@ -144,7 +180,7 @@
       // names restored (e.g., path/to/main' from 'path/to/main.0.dart).
       // TODO(markzipan): support subdirectories.
       _debugPrint(
-          'Copying Dart files to snapshot directory: ${snapshotDir.path}',
+          '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`.
@@ -164,7 +200,7 @@
             final snapshotPathWithScheme =
                 '$filesystemScheme:///$relativeSnapshotPath';
             updatedFilesInCurrentGeneration.add(snapshotPathWithScheme);
-            file.copySync(fileSnapshotUri.path);
+            file.copySync(fileSnapshotUri.toFilePath());
           }
         }
       }
@@ -184,13 +220,13 @@
         _debugPrint(
             'Compiling snapshot entrypoint: $snapshotEntrypointWithScheme',
             label: testName);
-        outputDirectoryPath = outputDillUri.path;
+        outputDirectoryPath = outputDillUri.toFilePath();
         await controller.sendCompileAndAccept(snapshotEntrypointWithScheme);
       } else {
         _debugPrint(
             'Recompiling snapshot entrypoint: $snapshotEntrypointWithScheme',
             label: testName);
-        outputDirectoryPath = outputIncrementalDillUri.path;
+        outputDirectoryPath = outputIncrementalDillUri.toFilePath();
         // TODO(markzipan): Add logic to reject bad compiles.
         await controller.sendRecompileAndAccept(snapshotEntrypointWithScheme,
             invalidatedFiles: updatedFilesInCurrentGeneration);
@@ -217,7 +253,8 @@
 
       // Write JS files and sourcemaps to their respective generation.
       _print('Writing generation $currentGeneration assets.', label: testName);
-      _debugPrint('Writing JS assets to ${tempUri.path}', label: testName);
+      _debugPrint('Writing JS assets to ${tempUri.toFilePath()}',
+          label: testName);
       filesystem.writeToDisk(tempUri, generation: '$currentGeneration');
       currentGeneration++;
     }
@@ -233,10 +270,10 @@
     final d8BootstrapJsUri = tempUri.resolve('generation0/bootstrap.js');
 
     final d8BootstrapJS = ddc_helpers.generateD8Bootstrapper(
-      ddcModuleLoaderJsPath: ddcModuleLoaderJsPath,
-      dartSdkJsPath: soundStableDartSdkJsPath,
-      entrypointModuleName: entrypointModuleName,
-      entrypointLibraryExportName: entrypointLibraryExportName,
+      ddcModuleLoaderJsPath: escapedString(ddcModuleLoaderJsUri.toFilePath()),
+      dartSdkJsPath: escapedString(soundStableDartSdkJsUri.toFilePath()),
+      entrypointModuleName: escapedString(entrypointModuleName),
+      entrypointLibraryExportName: escapedString(entrypointLibraryExportName),
       scriptDescriptors: filesystem.scriptDescriptorForBootstrap,
       modifiedFilesPerGeneration: filesystem.generationsToModifiedFilePaths,
     );
@@ -244,22 +281,72 @@
     File.fromUri(d8BootstrapJsUri).writeAsStringSync(d8BootstrapJS);
     _debugPrint('Writing D8 bootstrapper: $d8BootstrapJsUri', label: testName);
 
-    var process = await startProcess('D8', d8BinaryUri.path, [
-      sealNativeObjectJsUri.path,
-      d8PreamblesUri.path,
-      d8BootstrapJsUri.path
+    final d8OutputStreamController = StreamController<List<int>>();
+    final process = await startProcess('D8', d8BinaryUri.toFilePath(), [
+      sealNativeObjectJsUri.toFilePath(),
+      d8PreamblesUri.toFilePath(),
+      d8BootstrapJsUri.toFilePath()
     ]);
 
+    // Send D8's output to a local buffer so we can write or process it later.
+    final d8OutputBuffer = StringBuffer();
+    unawaited(process.stdout.pipe(d8OutputStreamController.sink));
+    d8OutputStreamController.stream
+        .transform(utf8.decoder)
+        .listen(d8OutputBuffer.write);
+
     final d8ExitCode = await process.exitCode;
-    if (d8ExitCode != 0) {
+    final testPassed = d8ExitCode == 0;
+
+    stopwatch.stop();
+    outcome.elapsedTime = stopwatch.elapsed;
+    outcome.matchedExpectations = testPassed;
+    outcome.testOutput = d8OutputBuffer.toString();
+    testOutcomes.add(outcome);
+    if (testPassed) {
+      _print('D8 passed with:\n$d8OutputBuffer', label: testName);
+    } else {
+      _print('TEST FAILURE: D8 exited with:\n$d8OutputBuffer', label: testName);
+    }
+    _print(outcome.testOutput, label: testName);
+
+    if (!testPassed) {
       await shutdown();
       exit(d8ExitCode);
     }
-    _print('Test passed in D8.', label: testName);
   }
 
   await shutdown();
   _print('Testing complete.');
+
+  if (emitTestResultsJson) {
+    final testOutcomeResults = testOutcomes.map((o) => o.toRecordJson());
+    final testOutcomeLogs = testOutcomes.map((o) => o.toLogJson());
+    final testResultsOutputDir =
+        Uri.directory(argResults['output-directory'] as String);
+    _print('Saving test results to ${testResultsOutputDir.toFilePath()}.');
+
+    // Test outputs must have one JSON blob per line and be newline-terminated.
+    final testResultsUri = testResultsOutputDir.resolve('results.json');
+    final testResultsSink = File.fromUri(testResultsUri).openWrite();
+    testOutcomeResults.forEach(testResultsSink.writeln);
+    await testResultsSink.flush();
+    await testResultsSink.close();
+
+    final testLogsUri = testResultsOutputDir.resolve('logs.json');
+    if (Platform.isWindows) {
+      // TODO(55297): Logs are disabled on windows until this but is fixed.
+      _print('Logs are not written on Windows. '
+          'See: https://github.com/dart-lang/sdk/issues/55297');
+    } else {
+      final testLogsSink = File.fromUri(testLogsUri).openWrite();
+      testOutcomeLogs.forEach(testLogsSink.writeln);
+      await testLogsSink.flush();
+      await testLogsSink.close();
+    }
+    _print('Emitted logs to ${testResultsUri.toFilePath()} '
+        'and ${testLogsUri.toFilePath()}.');
+  }
 }
 
 /// Runs the [command] with [args] in [environment].
@@ -267,7 +354,8 @@
 /// Will echo the commands to the console before running them when running in
 /// `verbose` mode.
 Future<Process> startProcess(String name, String command, List<String> args,
-    [Map<String, String> environment = const {}]) {
+    {Map<String, String> environment = const {},
+    ProcessStartMode mode = ProcessStartMode.normal}) {
   if (verbose) {
     print('Running $name:\n$command ${args.join(' ')}\n');
     if (environment.isNotEmpty) {
@@ -276,8 +364,7 @@
       print('With environment:\n$environmentVariables\n');
     }
   }
-  return Process.start(command, args,
-      mode: ProcessStartMode.inheritStdio, environment: environment);
+  return Process.start(command, args, mode: mode, environment: environment);
 }
 
 /// Prints messages if 'verbose' mode is enabled.
diff --git a/pkg/reload_test/lib/ddc_helpers.dart b/pkg/reload_test/lib/ddc_helpers.dart
index 6750d5a..c7865d4 100644
--- a/pkg/reload_test/lib/ddc_helpers.dart
+++ b/pkg/reload_test/lib/ddc_helpers.dart
@@ -44,14 +44,13 @@
   required String ddcModuleLoaderJsPath,
   required String dartSdkJsPath,
   required String entrypointModuleName,
-  String jsFileRoot = '/',
+  String jsFileRoot = '',
   String uuid = '00000000-0000-0000-0000-000000000000',
   required String entrypointLibraryExportName,
   required List<Map<String, String?>> scriptDescriptors,
   required Map<String, List<String>> modifiedFilesPerGeneration,
 }) {
   final d8BootstrapJS = '''
-
 load("$ddcModuleLoaderJsPath");
 load("$dartSdkJsPath");
 
@@ -70,6 +69,7 @@
 let scripts = ${_encoder.convert(scriptDescriptors)};
 
 let loadConfig = new self.\$dartLoader.LoadConfiguration();
+loadConfig.isWindows = ${Platform.isWindows};
 loadConfig.root = '$jsFileRoot';
 // Loading the entrypoint late is only necessary in Chrome.
 loadConfig.bootstrapScript = '';
diff --git a/pkg/reload_test/lib/frontend_server_controller.dart b/pkg/reload_test/lib/frontend_server_controller.dart
index 60a7316..49981aa6 100644
--- a/pkg/reload_test/lib/frontend_server_controller.dart
+++ b/pkg/reload_test/lib/frontend_server_controller.dart
@@ -77,6 +77,7 @@
         .transform(utf8.decoder)
         .transform(const LineSplitter())
         .listen((String s) {
+      if (debug) print('Frontend Server Response: $s');
       if (_boundaryKey == null) {
         if (s.startsWith(frontEndResponsePrefix)) {
           _boundaryKey = s.substring(frontEndResponsePrefix.length);
@@ -99,13 +100,9 @@
   }
 
   Future<void> sendCompile(String dartSourcePath) async {
-    if (!started) {
-      throw Exception('Frontend Server has not been started yet.');
-    }
+    if (!started) throw Exception('Frontend Server has not been started yet.');
     final command = 'compile $dartSourcePath\n';
-    if (debug) {
-      print('Sending instruction to Frontend Server:\n$command');
-    }
+    if (debug) print('Sending instruction to Frontend Server:\n$command');
     input.add(command.codeUnits);
     await synchronizer.moveNext();
   }
@@ -118,14 +115,10 @@
   Future<void> sendRecompile(String entrypointPath,
       {List<String> invalidatedFiles = const [],
       String boundaryKey = fakeBoundaryKey}) async {
-    if (!started) {
-      throw Exception('Frontend Server has not been started yet.');
-    }
+    if (!started) throw Exception('Frontend Server has not been started yet.');
     final command = 'recompile $entrypointPath $boundaryKey\n'
         '${invalidatedFiles.join('\n')}\n$boundaryKey\n';
-    if (debug) {
-      print('Sending instruction to Frontend Server:\n$command');
-    }
+    if (debug) print('Sending instruction to Frontend Server:\n$command');
     input.add(command.codeUnits);
     await synchronizer.moveNext();
   }
@@ -139,26 +132,18 @@
   }
 
   void sendAccept() {
-    if (!started) {
-      throw Exception('Frontend Server has not been started yet.');
-    }
+    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');
-    }
+    if (debug) print('Sending instruction to Frontend Server:\n$command');
     input.add(command.codeUnits);
   }
 
   void _sendQuit() {
-    if (!started) {
-      throw Exception('Frontend Server has not been started yet.');
-    }
+    if (!started) throw Exception('Frontend Server has not been started yet.');
     final command = 'quit\n';
-    if (debug) {
-      print('Sending instruction to Frontend Server:\n$command');
-    }
+    if (debug) print('Sending instruction to Frontend Server:\n$command');
     input.add(command.codeUnits);
   }
 
diff --git a/pkg/reload_test/lib/hot_reload_memory_filesystem.dart b/pkg/reload_test/lib/hot_reload_memory_filesystem.dart
index 501c9cc..58e03b0 100644
--- a/pkg/reload_test/lib/hot_reload_memory_filesystem.dart
+++ b/pkg/reload_test/lib/hot_reload_memory_filesystem.dart
@@ -136,7 +136,7 @@
           moduleName: libraryName,
           libraryName: libraryName,
           dartSourcePath: dartFileName,
-          jsSourcePath: fullyResolvedFileUri.path);
+          jsSourcePath: fullyResolvedFileUri.toFilePath());
       libraries.add(libraryInfo);
       if (generation == '0') {
         firstGenerationLibraries.add(libraryInfo);
diff --git a/pkg/reload_test/lib/test_helpers.dart b/pkg/reload_test/lib/test_helpers.dart
new file mode 100644
index 0000000..665e8a6
--- /dev/null
+++ b/pkg/reload_test/lib/test_helpers.dart
@@ -0,0 +1,50 @@
+// 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 'dart:convert';
+
+class TestResultOutcome {
+  // This encoder must generate each output element on its own line.
+  final _encoder = JsonEncoder();
+  final String configuration;
+  final String suiteName;
+  final String testName;
+  late Duration elapsedTime;
+  final String expectedResult;
+  late bool matchedExpectations;
+  String testOutput;
+
+  TestResultOutcome({
+    required this.configuration,
+    this.suiteName = 'tests/reload',
+    required this.testName,
+    this.expectedResult = 'Pass',
+    this.testOutput = '',
+  });
+
+  String toRecordJson() => _encoder.convert({
+        'name': '$suiteName/$testName',
+        'configuration': configuration,
+        'suite': suiteName,
+        'test_name': testName,
+        'time_ms': elapsedTime.inMilliseconds,
+        'expected': expectedResult,
+        'result': matchedExpectations ? 'Pass' : 'Fail',
+        'matches': expectedResult == expectedResult,
+      });
+
+  String toLogJson() => _encoder.convert({
+        'name': '$suiteName/$testName',
+        'configuration': configuration,
+        'result': matchedExpectations ? 'Pass' : 'Fail',
+        'log': testOutput,
+      });
+}
+
+/// Escapes backslashes in [unescaped].
+///
+/// Used for wrapping Windows-style paths.
+String escapedString(String unescaped) {
+  return unescaped.replaceAll(r'\', r'\\');
+}
diff --git a/pkg/reload_test/pubspec.yaml b/pkg/reload_test/pubspec.yaml
index 30b08a7..7b9311b 100644
--- a/pkg/reload_test/pubspec.yaml
+++ b/pkg/reload_test/pubspec.yaml
@@ -15,3 +15,4 @@
 # Use 'any' constraints here; we get our versions from the DEPS file.
 dev_dependencies:
   lints: any
+  test: any
diff --git a/pkg/reload_test/test/filesystem_test.dart b/pkg/reload_test/test/filesystem_test.dart
new file mode 100644
index 0000000..e6e057f
--- /dev/null
+++ b/pkg/reload_test/test/filesystem_test.dart
@@ -0,0 +1,243 @@
+// 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 'dart:io';
+
+import 'package:test/test.dart';
+import 'package:reload_test/hot_reload_memory_filesystem.dart';
+
+void main() {
+  late Directory testDirectory;
+  late Uri jsOutputUri;
+
+  setUp(() {
+    testDirectory = Directory.systemTemp.createTempSync('test_file_system');
+    jsOutputUri = testDirectory.uri.resolve('js/');
+    Directory.fromUri(jsOutputUri).createSync();
+  });
+  tearDown(() {
+    testDirectory.deleteSync(recursive: true);
+  });
+  test("Web filesystem behaves correctly across generations.", () {
+    // Initialize the filesystem and register two test files with their
+    // corresponding sourcemaps.
+    final filesystem = HotReloadMemoryFilesystem(jsOutputUri);
+
+    final source1 = '''
+      file1() {
+        foo();
+      }
+      ''';
+    final source2 = '''
+      file2() {
+        bar();
+      }
+      ''';
+    final sources = '$source1$source2'.codeUnits;
+    final sourcesFile = File.fromUri(testDirectory.uri.resolve('test.sources'))
+      ..writeAsBytesSync(sources);
+    final sourcemap1 = '{map1}';
+    final sourcemap2 = '{map2}';
+    final sourcemap = '$sourcemap1$sourcemap2'.codeUnits;
+    final sourcemapFile = File.fromUri(testDirectory.uri.resolve('test.map'))
+      ..writeAsBytesSync(sourcemap);
+
+    final manifest = '''
+      {
+        "/file1.ext": {
+          "code": [0, ${source1.codeUnits.length}],
+          "sourcemap": [0, ${sourcemap1.codeUnits.length}]
+        },
+        "/file2.ext": {
+          "code": [
+            ${source1.codeUnits.length},
+            ${source1.codeUnits.length + source2.codeUnits.length}
+          ],
+          "sourcemap":[
+            ${sourcemap1.codeUnits.length},
+            ${sourcemap1.codeUnits.length + sourcemap2.codeUnits.length}
+          ]
+        }
+      }
+      '''
+        .codeUnits;
+    final manifestFile = File.fromUri(testDirectory.uri.resolve('test.json'))
+      ..writeAsBytesSync(manifest);
+
+    var updatedFiles = filesystem
+        .update(sourcesFile, manifestFile, sourcemapFile, generation: "0");
+
+    expect(updatedFiles, equals(['file1.ext', 'file2.ext']),
+        reason: 'Updated files are correctly reported.');
+
+    expect(
+        filesystem.files,
+        equals(
+            {'file1.ext': source1.codeUnits, 'file2.ext': source2.codeUnits}),
+        reason: 'Filesystem source files are correctly stored.');
+
+    expect(
+        filesystem.sourcemaps,
+        equals({
+          'file1.ext.map': sourcemap1.codeUnits,
+          'file2.ext.map': sourcemap2.codeUnits,
+        }),
+        reason: 'Filesystem sourcemaps are correctly stored.');
+
+    expect(
+        filesystem.generationsToModifiedFilePaths,
+        equals({
+          '0': [
+            jsOutputUri.resolve('generation0/file1.ext').toFilePath(),
+            jsOutputUri.resolve('generation0/file2.ext').toFilePath()
+          ]
+        }),
+        reason:
+            'Filesystem emits correct generation to modfied files mapping.');
+
+    // Update the filesystem with two more files in the next generation.
+
+    final manifest2 = '''
+      {
+        "/file3.ext": {
+          "code": [0, ${source1.codeUnits.length}],
+          "sourcemap": [0, ${sourcemap1.codeUnits.length}]
+        },
+        "/file4.ext": {
+          "code": [
+            ${source1.codeUnits.length},
+            ${source1.codeUnits.length + source2.codeUnits.length}
+          ],
+          "sourcemap":[
+            ${sourcemap1.codeUnits.length},
+            ${sourcemap1.codeUnits.length + sourcemap2.codeUnits.length}
+          ]
+        }
+      }
+      '''
+        .codeUnits;
+    manifestFile.writeAsBytesSync(manifest2);
+
+    updatedFiles = filesystem.update(sourcesFile, manifestFile, sourcemapFile,
+        generation: "1");
+
+    expect(updatedFiles, equals(['file3.ext', 'file4.ext']),
+        reason: 'Updated files are correctly reported.');
+
+    expect(
+        filesystem.files,
+        equals({
+          'file1.ext': source1.codeUnits,
+          'file2.ext': source2.codeUnits,
+          'file3.ext': source1.codeUnits,
+          'file4.ext': source2.codeUnits,
+        }),
+        reason: 'Filesystem source files are correctly stored.');
+
+    expect(
+        filesystem.sourcemaps,
+        equals({
+          'file1.ext.map': sourcemap1.codeUnits,
+          'file2.ext.map': sourcemap2.codeUnits,
+          'file3.ext.map': sourcemap1.codeUnits,
+          'file4.ext.map': sourcemap2.codeUnits,
+        }),
+        reason: 'Filesystem sourcemaps are correctly stored.');
+
+    expect(
+        filesystem.generationsToModifiedFilePaths,
+        equals({
+          '0': [
+            jsOutputUri.resolve('generation0/file1.ext').toFilePath(),
+            jsOutputUri.resolve('generation0/file2.ext').toFilePath()
+          ],
+          '1': [
+            jsOutputUri.resolve('generation1/file3.ext').toFilePath(),
+            jsOutputUri.resolve('generation1/file4.ext').toFilePath(),
+          ],
+        }),
+        reason:
+            'Filesystem emits correct generation to modfied files mapping.');
+
+    expect(
+        filesystem.scriptDescriptorForBootstrap,
+        equals([
+          {
+            'id': 'file1.ext',
+            'src': jsOutputUri.resolve('generation0/file1.ext').toFilePath(),
+          },
+          {
+            'id': 'file2.ext',
+            'src': jsOutputUri.resolve('generation0/file2.ext').toFilePath(),
+          },
+        ]),
+        reason: 'Filesystem emits correct script descriptors.');
+
+    // Write files and check that the filesystem's state is properly cleared.
+    expect(
+        File(jsOutputUri.resolve('generation3/file1.ext').toFilePath())
+            .existsSync(),
+        isFalse);
+    expect(
+        File(jsOutputUri.resolve('generation3/file2.ext').toFilePath())
+            .existsSync(),
+        isFalse);
+    expect(
+        File(jsOutputUri.resolve('generation3/file3.ext').toFilePath())
+            .existsSync(),
+        isFalse);
+    expect(
+        File(jsOutputUri.resolve('generation3/file4.ext').toFilePath())
+            .existsSync(),
+        isFalse);
+    filesystem.writeToDisk(jsOutputUri, generation: "3");
+    expect(
+        File(jsOutputUri.resolve('generation3/file1.ext').toFilePath())
+            .existsSync(),
+        isTrue);
+    expect(
+        File(jsOutputUri.resolve('generation3/file2.ext').toFilePath())
+            .existsSync(),
+        isTrue);
+    expect(
+        File(jsOutputUri.resolve('generation3/file3.ext').toFilePath())
+            .existsSync(),
+        isTrue);
+    expect(
+        File(jsOutputUri.resolve('generation3/file4.ext').toFilePath())
+            .existsSync(),
+        isTrue);
+    expect(filesystem.files, isEmpty,
+        reason: 'Filesystem clears files after writing to disk.');
+    expect(filesystem.sourcemaps, isEmpty,
+        reason: 'Filesystem clears sourcemaps after writing to disk.');
+
+    // Check that subsequent writes don't emit already-emitted files.
+    File(jsOutputUri.resolve('generation3/file1.ext').toFilePath())
+        .deleteSync();
+    File(jsOutputUri.resolve('generation3/file2.ext').toFilePath())
+        .deleteSync();
+    File(jsOutputUri.resolve('generation3/file3.ext').toFilePath())
+        .deleteSync();
+    File(jsOutputUri.resolve('generation3/file4.ext').toFilePath())
+        .deleteSync();
+    filesystem.writeToDisk(jsOutputUri, generation: "3");
+    expect(
+        File(jsOutputUri.resolve('generation3/file1.ext').toFilePath())
+            .existsSync(),
+        isFalse);
+    expect(
+        File(jsOutputUri.resolve('generation3/file2.ext').toFilePath())
+            .existsSync(),
+        isFalse);
+    expect(
+        File(jsOutputUri.resolve('generation3/file3.ext').toFilePath())
+            .existsSync(),
+        isFalse);
+    expect(
+        File(jsOutputUri.resolve('generation3/file4.ext').toFilePath())
+            .existsSync(),
+        isFalse);
+  });
+}
diff --git a/tests/hot_reload/hot_restart_constant_equality/main.0.dart b/tests/hot_reload/hot_restart_constant_equality/main.0.dart
index 1db3e40..7350e1d 100644
--- a/tests/hot_reload/hot_restart_constant_equality/main.0.dart
+++ b/tests/hot_reload/hot_restart_constant_equality/main.0.dart
@@ -2,9 +2,6 @@
 // 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:core';
-import 'dart:html';
-
 import 'package:expect/expect.dart';
 import 'package:reload_test/reload_test_utils.dart';
 
diff --git a/tests/hot_reload/hot_restart_constant_equality/main.1.dart b/tests/hot_reload/hot_restart_constant_equality/main.1.dart
index ac9d4ec..1575140 100644
--- a/tests/hot_reload/hot_restart_constant_equality/main.1.dart
+++ b/tests/hot_reload/hot_restart_constant_equality/main.1.dart
@@ -2,9 +2,6 @@
 // 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:core';
-import 'dart:html';
-
 import 'package:expect/expect.dart';
 import 'package:reload_test/reload_test_utils.dart';
 
diff --git a/tools/bots/test_matrix.json b/tools/bots/test_matrix.json
index 475e0f8..df0a846 100644
--- a/tools/bots/test_matrix.json
+++ b/tools/bots/test_matrix.json
@@ -1779,6 +1779,16 @@
           ]
         },
         {
+          "name": "ddc hot reload tests",
+          "script": "out/ReleaseX64/dart-sdk/bin/dart",
+          "testRunner": true,
+          "arguments": [
+            "pkg/dev_compiler/test/hot_reload_suite.dart",
+            "-nddc-${system}-chrome",
+            "--verbose"
+          ]
+        },
+        {
           "name": "ddc sourcemap tests",
           "script": "out/ReleaseX64/dart",
           "arguments": [
@@ -1866,6 +1876,16 @@
           ]
         },
         {
+          "name": "ddc hot reload tests",
+          "script": "xcodebuild/ReleaseARM64/dart-sdk/bin/dart",
+          "testRunner": true,
+          "arguments": [
+            "pkg/dev_compiler/test/hot_reload_suite.dart",
+            "-nddc-mac-chrome",
+            "--verbose"
+          ]
+        },
+        {
           "name": "ddc sourcemap tests",
           "script": "xcodebuild/ReleaseARM64/dart",
           "arguments": [