Skip to content

Commit 5bf833d

Browse files
authored
Support hot reload testing (#2611)
- Adds code from flutter_tools to pipe a list of modules and their corresponding libraries to the embedder. Includes some path modifications not in flutter_tools to handle the test project. - Adds a test and test project to modify a variable, and check that the new value is read on reload only when reevaluated.
1 parent fa0b74b commit 5bf833d

File tree

8 files changed

+216
-2
lines changed

8 files changed

+216
-2
lines changed

dwds/test/fixtures/context.dart

+4
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import 'package:dwds/src/services/expression_compiler_service.dart';
2424
import 'package:dwds/src/utilities/dart_uri.dart';
2525
import 'package:dwds/src/utilities/server.dart';
2626
import 'package:file/local.dart';
27+
import 'package:frontend_server_common/src/devfs.dart';
2728
import 'package:frontend_server_common/src/resident_runner.dart';
2829
import 'package:http/http.dart';
2930
import 'package:http/io_client.dart';
@@ -371,6 +372,9 @@ class TestContext {
371372
packageUriMapper,
372373
() async => {},
373374
buildSettings,
375+
hotReloadSourcesUri: Uri.parse(
376+
'http://localhost:$port/${WebDevFS.reloadScriptsFileName}',
377+
),
374378
).strategy
375379
: FrontendServerDdcStrategyProvider(
376380
testSettings.reloadConfiguration,

dwds/test/fixtures/project.dart

+8
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,14 @@ class TestProject {
135135
htmlEntryFileName: 'index.html',
136136
);
137137

138+
static const testHotReload = TestProject._(
139+
packageName: '_test_hot_reload',
140+
packageDirectory: '_testHotReload',
141+
webAssetsPath: 'web',
142+
dartEntryFileName: 'main.dart',
143+
htmlEntryFileName: 'index.html',
144+
);
145+
138146
const TestProject._({
139147
required this.packageName,
140148
required this.packageDirectory,

dwds/test/hot_reload_test.dart

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
@Tags(['daily'])
6+
@TestOn('vm')
7+
@Timeout(Duration(minutes: 5))
8+
library;
9+
10+
import 'package:dwds/expression_compiler.dart';
11+
import 'package:test/test.dart';
12+
import 'package:test_common/logging.dart';
13+
import 'package:test_common/test_sdk_configuration.dart';
14+
import 'package:vm_service/vm_service.dart';
15+
16+
import 'fixtures/context.dart';
17+
import 'fixtures/project.dart';
18+
import 'fixtures/utilities.dart';
19+
20+
const originalString = 'Hello World!';
21+
const newString = 'Bonjour le monde!';
22+
23+
void main() {
24+
// Enable verbose logging for debugging.
25+
final debug = false;
26+
final provider = TestSdkConfigurationProvider(
27+
verbose: debug,
28+
canaryFeatures: true,
29+
ddcModuleFormat: ModuleFormat.ddc,
30+
);
31+
final project = TestProject.testHotReload;
32+
final context = TestContext(project, provider);
33+
34+
tearDownAll(provider.dispose);
35+
36+
Future<void> makeEditAndRecompile() async {
37+
context.makeEditToDartLibFile(
38+
libFileName: 'library1.dart',
39+
toReplace: originalString,
40+
replaceWith: newString,
41+
);
42+
await context.recompile(fullRestart: false);
43+
}
44+
45+
void undoEdit() {
46+
context.makeEditToDartLibFile(
47+
libFileName: 'library1.dart',
48+
toReplace: newString,
49+
replaceWith: originalString,
50+
);
51+
}
52+
53+
group('Injected client', () {
54+
late VmService fakeClient;
55+
56+
setUp(() async {
57+
setCurrentLogWriter(debug: debug);
58+
await context.setUp(
59+
testSettings: TestSettings(
60+
enableExpressionEvaluation: true,
61+
compilationMode: CompilationMode.frontendServer,
62+
moduleFormat: ModuleFormat.ddc,
63+
canaryFeatures: true,
64+
),
65+
);
66+
fakeClient = await context.connectFakeClient();
67+
});
68+
69+
tearDown(() async {
70+
undoEdit();
71+
await context.tearDown();
72+
});
73+
74+
test('can hot reload', () async {
75+
final client = context.debugConnection.vmService;
76+
await makeEditAndRecompile();
77+
78+
final vm = await client.getVM();
79+
final isolate = await client.getIsolate(vm.isolates!.first.id!);
80+
81+
final report = await fakeClient.reloadSources(isolate.id!);
82+
expect(report.success, true);
83+
84+
var source = await context.webDriver.pageSource;
85+
// Should not contain the change until the function that updates the page
86+
// is evaluated in a hot reload.
87+
expect(source, contains(originalString));
88+
expect(source.contains(newString), false);
89+
90+
final rootLib = isolate.rootLib;
91+
await client.evaluate(isolate.id!, rootLib!.id!, 'evaluate()');
92+
source = await context.webDriver.pageSource;
93+
expect(source, contains(newString));
94+
expect(source.contains(originalString), false);
95+
});
96+
}, timeout: Timeout.factor(2));
97+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
String get reloadValue => 'Hello World!';

fixtures/_testHotReload/pubspec.yaml

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
name: _test_hot_reload
2+
version: 1.0.0
3+
description: >-
4+
A fake package used for testing hot reload.
5+
publish_to: none
6+
7+
environment:
8+
sdk: ^3.7.0
9+
+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<html>
2+
3+
<head>
4+
<script defer src="main.dart.js"></script>
5+
</head>
6+
7+
</html>

fixtures/_testHotReload/web/main.dart

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:core';
6+
import 'dart:js_interop';
7+
8+
import 'package:_test_hot_reload/library1.dart';
9+
10+
@JS('document.body.innerHTML')
11+
external set innerHtml(String html);
12+
13+
@JS('console.log')
14+
external void log(String s);
15+
16+
void evaluate() {
17+
log('evaluate called $reloadValue');
18+
innerHtml = 'Program is running!\n $reloadValue}\n';
19+
}
20+
21+
void main() {
22+
evaluate();
23+
}

frontend_server_common/lib/src/devfs.dart

+63-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import 'dart:io';
1010
import 'package:dwds/asset_reader.dart';
1111
import 'package:dwds/config.dart';
1212
import 'package:dwds/expression_compiler.dart';
13+
// ignore: implementation_imports
14+
import 'package:dwds/src/debugging/metadata/module_metadata.dart';
1315
import 'package:dwds/utilities.dart';
1416
import 'package:file/file.dart';
1517
import 'package:path/path.dart' as p;
@@ -217,8 +219,7 @@ class WebDevFS {
217219
if (fullRestart) {
218220
performRestart(modules);
219221
} else {
220-
// TODO(srujzs): Support hot reload testing.
221-
throw Exception('Hot reload is not supported yet.');
222+
performReload(modules, prefix);
222223
}
223224
}
224225
return UpdateFSReport(
@@ -249,6 +250,66 @@ class WebDevFS {
249250
assetServer.writeFile('restart_scripts.json', json.encode(srcIdsList));
250251
}
251252

253+
static const String reloadScriptsFileName = 'reload_scripts.json';
254+
255+
/// Given a list of [modules] that need to be reloaded, writes a file that
256+
/// contains a list of objects each with two fields:
257+
///
258+
/// `src`: A string that corresponds to the file path containing a DDC library
259+
/// bundle.
260+
/// `libraries`: An array of strings containing the libraries that were
261+
/// compiled in `src`.
262+
///
263+
/// For example:
264+
/// ```json
265+
/// [
266+
/// {
267+
/// "src": "<file_name>",
268+
/// "libraries": ["<lib1>", "<lib2>"],
269+
/// },
270+
/// ]
271+
/// ```
272+
///
273+
/// The path of the output file should stay consistent across the lifetime of
274+
/// the app.
275+
///
276+
/// [entrypointDirectory] is used to make the module paths relative to the
277+
/// entrypoint, which is needed in order to load `src`s correctly.
278+
void performReload(List<String> modules, String entrypointDirectory) {
279+
final moduleToLibrary = <Map<String, Object>>[];
280+
for (final module in modules) {
281+
final metadata = ModuleMetadata.fromJson(
282+
json.decode(utf8
283+
.decode(assetServer.getMetadata('$module.metadata').toList()))
284+
as Map<String, dynamic>,
285+
);
286+
final libraries = metadata.libraries.keys.toList();
287+
moduleToLibrary.add(<String, Object>{
288+
'src': _findModuleToLoad(module, entrypointDirectory),
289+
'libraries': libraries
290+
});
291+
}
292+
assetServer.writeFile(reloadScriptsFileName, json.encode(moduleToLibrary));
293+
}
294+
295+
/// Given a [module] location from the [ModuleMetadata], return its path in
296+
/// the server relative to the entrypoint in [entrypointDirectory].
297+
///
298+
/// This is needed in cases where the entrypoint is in a subdirectory in the
299+
/// package.
300+
String _findModuleToLoad(String module, String entrypointDirectory) {
301+
if (entrypointDirectory.isEmpty) return module;
302+
assert(entrypointDirectory.endsWith('/'));
303+
if (module.startsWith(entrypointDirectory)) {
304+
return module.substring(entrypointDirectory.length);
305+
}
306+
var numDirs = entrypointDirectory.split('/').length - 1;
307+
while (numDirs-- > 0) {
308+
module = '../$module';
309+
}
310+
return module;
311+
}
312+
252313
File get ddcModuleLoaderJS =>
253314
fileSystem.file(sdkLayout.ddcModuleLoaderJsPath);
254315
File get requireJS => fileSystem.file(sdkLayout.requireJsPath);

0 commit comments

Comments
 (0)