diff --git a/dwds/test/fixtures/context.dart b/dwds/test/fixtures/context.dart index 1d9f497fb..abe8f8f1d 100644 --- a/dwds/test/fixtures/context.dart +++ b/dwds/test/fixtures/context.dart @@ -24,6 +24,7 @@ import 'package:dwds/src/services/expression_compiler_service.dart'; import 'package:dwds/src/utilities/dart_uri.dart'; import 'package:dwds/src/utilities/server.dart'; import 'package:file/local.dart'; +import 'package:frontend_server_common/src/devfs.dart'; import 'package:frontend_server_common/src/resident_runner.dart'; import 'package:http/http.dart'; import 'package:http/io_client.dart'; @@ -371,6 +372,9 @@ class TestContext { packageUriMapper, () async => {}, buildSettings, + hotReloadSourcesUri: Uri.parse( + 'http://localhost:$port/${WebDevFS.reloadScriptsFileName}', + ), ).strategy : FrontendServerDdcStrategyProvider( testSettings.reloadConfiguration, diff --git a/dwds/test/fixtures/project.dart b/dwds/test/fixtures/project.dart index 652fd5db2..25f58e017 100644 --- a/dwds/test/fixtures/project.dart +++ b/dwds/test/fixtures/project.dart @@ -135,6 +135,14 @@ class TestProject { htmlEntryFileName: 'index.html', ); + static const testHotReload = TestProject._( + packageName: '_test_hot_reload', + packageDirectory: '_testHotReload', + webAssetsPath: 'web', + dartEntryFileName: 'main.dart', + htmlEntryFileName: 'index.html', + ); + const TestProject._({ required this.packageName, required this.packageDirectory, diff --git a/dwds/test/hot_reload_test.dart b/dwds/test/hot_reload_test.dart new file mode 100644 index 000000000..a836426c8 --- /dev/null +++ b/dwds/test/hot_reload_test.dart @@ -0,0 +1,97 @@ +// 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. + +@Tags(['daily']) +@TestOn('vm') +@Timeout(Duration(minutes: 5)) +library; + +import 'package:dwds/expression_compiler.dart'; +import 'package:test/test.dart'; +import 'package:test_common/logging.dart'; +import 'package:test_common/test_sdk_configuration.dart'; +import 'package:vm_service/vm_service.dart'; + +import 'fixtures/context.dart'; +import 'fixtures/project.dart'; +import 'fixtures/utilities.dart'; + +const originalString = 'Hello World!'; +const newString = 'Bonjour le monde!'; + +void main() { + // Enable verbose logging for debugging. + final debug = false; + final provider = TestSdkConfigurationProvider( + verbose: debug, + canaryFeatures: true, + ddcModuleFormat: ModuleFormat.ddc, + ); + final project = TestProject.testHotReload; + final context = TestContext(project, provider); + + tearDownAll(provider.dispose); + + Future makeEditAndRecompile() async { + context.makeEditToDartLibFile( + libFileName: 'library1.dart', + toReplace: originalString, + replaceWith: newString, + ); + await context.recompile(fullRestart: false); + } + + void undoEdit() { + context.makeEditToDartLibFile( + libFileName: 'library1.dart', + toReplace: newString, + replaceWith: originalString, + ); + } + + group('Injected client', () { + late VmService fakeClient; + + setUp(() async { + setCurrentLogWriter(debug: debug); + await context.setUp( + testSettings: TestSettings( + enableExpressionEvaluation: true, + compilationMode: CompilationMode.frontendServer, + moduleFormat: ModuleFormat.ddc, + canaryFeatures: true, + ), + ); + fakeClient = await context.connectFakeClient(); + }); + + tearDown(() async { + undoEdit(); + await context.tearDown(); + }); + + test('can hot reload', () async { + final client = context.debugConnection.vmService; + await makeEditAndRecompile(); + + final vm = await client.getVM(); + final isolate = await client.getIsolate(vm.isolates!.first.id!); + + final report = await fakeClient.reloadSources(isolate.id!); + expect(report.success, true); + + var source = await context.webDriver.pageSource; + // Should not contain the change until the function that updates the page + // is evaluated in a hot reload. + expect(source, contains(originalString)); + expect(source.contains(newString), false); + + final rootLib = isolate.rootLib; + await client.evaluate(isolate.id!, rootLib!.id!, 'evaluate()'); + source = await context.webDriver.pageSource; + expect(source, contains(newString)); + expect(source.contains(originalString), false); + }); + }, timeout: Timeout.factor(2)); +} diff --git a/fixtures/_testHotReload/lib/library1.dart b/fixtures/_testHotReload/lib/library1.dart new file mode 100644 index 000000000..1daf3d4c3 --- /dev/null +++ b/fixtures/_testHotReload/lib/library1.dart @@ -0,0 +1,5 @@ +// 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. + +String get reloadValue => 'Hello World!'; diff --git a/fixtures/_testHotReload/pubspec.yaml b/fixtures/_testHotReload/pubspec.yaml new file mode 100644 index 000000000..3354b80f9 --- /dev/null +++ b/fixtures/_testHotReload/pubspec.yaml @@ -0,0 +1,9 @@ +name: _test_hot_reload +version: 1.0.0 +description: >- + A fake package used for testing hot reload. +publish_to: none + +environment: + sdk: ^3.7.0 + diff --git a/fixtures/_testHotReload/web/index.html b/fixtures/_testHotReload/web/index.html new file mode 100644 index 000000000..d93440a94 --- /dev/null +++ b/fixtures/_testHotReload/web/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fixtures/_testHotReload/web/main.dart b/fixtures/_testHotReload/web/main.dart new file mode 100644 index 000000000..03fe05d3b --- /dev/null +++ b/fixtures/_testHotReload/web/main.dart @@ -0,0 +1,23 @@ +// 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:core'; +import 'dart:js_interop'; + +import 'package:_test_hot_reload/library1.dart'; + +@JS('document.body.innerHTML') +external set innerHtml(String html); + +@JS('console.log') +external void log(String s); + +void evaluate() { + log('evaluate called $reloadValue'); + innerHtml = 'Program is running!\n $reloadValue}\n'; +} + +void main() { + evaluate(); +} diff --git a/frontend_server_common/lib/src/devfs.dart b/frontend_server_common/lib/src/devfs.dart index 2fb7bb96a..02a24839e 100644 --- a/frontend_server_common/lib/src/devfs.dart +++ b/frontend_server_common/lib/src/devfs.dart @@ -10,6 +10,8 @@ import 'dart:io'; import 'package:dwds/asset_reader.dart'; import 'package:dwds/config.dart'; import 'package:dwds/expression_compiler.dart'; +// ignore: implementation_imports +import 'package:dwds/src/debugging/metadata/module_metadata.dart'; import 'package:dwds/utilities.dart'; import 'package:file/file.dart'; import 'package:path/path.dart' as p; @@ -217,8 +219,7 @@ class WebDevFS { if (fullRestart) { performRestart(modules); } else { - // TODO(srujzs): Support hot reload testing. - throw Exception('Hot reload is not supported yet.'); + performReload(modules, prefix); } } return UpdateFSReport( @@ -249,6 +250,66 @@ class WebDevFS { assetServer.writeFile('restart_scripts.json', json.encode(srcIdsList)); } + static const String reloadScriptsFileName = 'reload_scripts.json'; + + /// Given a list of [modules] that need to be reloaded, writes a file that + /// contains a list of objects each with two fields: + /// + /// `src`: A string that corresponds to the file path containing a DDC library + /// bundle. + /// `libraries`: An array of strings containing the libraries that were + /// compiled in `src`. + /// + /// For example: + /// ```json + /// [ + /// { + /// "src": "", + /// "libraries": ["", ""], + /// }, + /// ] + /// ``` + /// + /// The path of the output file should stay consistent across the lifetime of + /// the app. + /// + /// [entrypointDirectory] is used to make the module paths relative to the + /// entrypoint, which is needed in order to load `src`s correctly. + void performReload(List modules, String entrypointDirectory) { + final moduleToLibrary = >[]; + for (final module in modules) { + final metadata = ModuleMetadata.fromJson( + json.decode(utf8 + .decode(assetServer.getMetadata('$module.metadata').toList())) + as Map, + ); + final libraries = metadata.libraries.keys.toList(); + moduleToLibrary.add({ + 'src': _findModuleToLoad(module, entrypointDirectory), + 'libraries': libraries + }); + } + assetServer.writeFile(reloadScriptsFileName, json.encode(moduleToLibrary)); + } + + /// Given a [module] location from the [ModuleMetadata], return its path in + /// the server relative to the entrypoint in [entrypointDirectory]. + /// + /// This is needed in cases where the entrypoint is in a subdirectory in the + /// package. + String _findModuleToLoad(String module, String entrypointDirectory) { + if (entrypointDirectory.isEmpty) return module; + assert(entrypointDirectory.endsWith('/')); + if (module.startsWith(entrypointDirectory)) { + return module.substring(entrypointDirectory.length); + } + var numDirs = entrypointDirectory.split('/').length - 1; + while (numDirs-- > 0) { + module = '../$module'; + } + return module; + } + File get ddcModuleLoaderJS => fileSystem.file(sdkLayout.ddcModuleLoaderJsPath); File get requireJS => fileSystem.file(sdkLayout.requireJsPath);