Skip to content

Commit 47e3ad7

Browse files
nshahanCommit Queue
authored andcommitted
[ddc] Add rejection to hot reload test suite
Allows tests to define generation files that are expected to be rejected as invalid for hot reload. Tests generation files now support the extension `.reject.dart` to signal a rejection is expected. A corresponding rejection error message should be added to the map in the config.json file. - Add new rejection logic to the file discovery and config file parsing. - Add new reload receipts that are output by the runtime utils when running hot reload tests. These receipts are used to verify that each file generation was visited (not necessarily accepted) at runtime. Change-Id: Ifee698f0d6502ecfceba2d5c733a2c88fb5daf7c Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/394564 Commit-Queue: Nicholas Shahan <[email protected]> Reviewed-by: Nate Biggs <[email protected]>
1 parent 8f40b28 commit 47e3ad7

File tree

11 files changed

+542
-151
lines changed

11 files changed

+542
-151
lines changed

pkg/dev_compiler/test/hot_reload_suite.dart

Lines changed: 273 additions & 80 deletions
Large diffs are not rendered by default.

pkg/reload_test/lib/ddc_helpers.dart

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -158,18 +158,10 @@ self.\$dartReloadModifiedModules = async function(subAppName, callback) {
158158
}
159159
160160
// Append a helper function for hot reload.
161-
self.\$injectedFilesAndLibrariesToReload = function() {
162-
// Resolve the next generation's directory and load all modified files.
163-
let nextGeneration = self.dartDevEmbedder.hotReloadGeneration + 1;
164-
if (previousGenerations.has(nextGeneration)) {
165-
throw Error('Fatal error: Previous generations are being re-run.');
166-
}
167-
previousGenerations.add(nextGeneration);
168-
let modifiedFilePaths = modifiedFilesPerGeneration[nextGeneration];
169-
// Stop if the next generation does not exist.
170-
if (modifiedFilePaths == void 0) {
171-
return;
172-
}
161+
self.\$injectedFilesAndLibrariesToReload = function(fileGeneration) {
162+
modifiedFilePaths = modifiedFilesPerGeneration[fileGeneration];
163+
if (modifiedFilePaths == null) return null;
164+
// Collect reload generation resources.
173165
let fileUrls = [];
174166
let libraryIds = [];
175167
for (let i = 0; i < modifiedFilePaths.length; i++) {
@@ -443,20 +435,12 @@ let _scriptUrls = {
443435
444436
// Append hot reload runner-specific logic.
445437
let modifiedFilesPerGeneration = ${_encoder.convert(modifiedFilesPerGeneration)};
446-
let previousGenerations = new Set();
447438
448-
self.\$injectedFilesAndLibrariesToReload = function() {
449-
// Resolve the next generation's directory and load all modified files.
450-
let nextGeneration = self.dartDevEmbedder.hotReloadGeneration + 1;
451-
if (previousGenerations.has(nextGeneration)) {
452-
throw Error('Fatal error: Previous generations are being re-run.');
453-
}
454-
previousGenerations.add(nextGeneration);
455-
let modifiedFilePaths = modifiedFilesPerGeneration[nextGeneration];
456-
// Stop if the next generation does not exist.
457-
if (modifiedFilePaths == void 0) {
458-
return;
459-
}
439+
// Append a helper function for hot reload.
440+
self.\$injectedFilesAndLibrariesToReload = function(fileGeneration) {
441+
modifiedFilePaths = modifiedFilesPerGeneration[fileGeneration];
442+
if (modifiedFilePaths == null) return null;
443+
// Collect reload generation resources.
460444
let fileUrls = [];
461445
let libraryIds = [];
462446
for (let i = 0; i < modifiedFilePaths.length; i++) {
@@ -468,6 +452,7 @@ let _scriptUrls = {
468452
return [fileUrls, libraryIds];
469453
}
470454
455+
let previousGenerations = new Set();
471456
self.\$dartReloadModifiedModules = async function(subAppName, callback) {
472457
let expectedName = "$entrypointModuleName";
473458
if (subAppName !== expectedName) {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) 2024, 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:convert';
6+
7+
enum Status {
8+
accepted,
9+
rejected,
10+
restarted;
11+
}
12+
13+
/// Reports the result of a hot reload or restart at runtime for test validation
14+
/// purposes only.
15+
class HotReloadReceipt {
16+
final int generation;
17+
final Status status;
18+
19+
/// Optional message describing the reason this reload was rejected.
20+
///
21+
/// Expected to be `null` when this reload was accepted.
22+
final String? rejectionMessage;
23+
24+
static final _generationKey = 'generation';
25+
static final _statusKey = 'status';
26+
static final _rejectionMessageKey = 'rejectionMessage';
27+
28+
HotReloadReceipt({
29+
required this.generation,
30+
required this.status,
31+
this.rejectionMessage,
32+
});
33+
34+
@override
35+
String toString() => jsonEncode(toJson());
36+
37+
HotReloadReceipt.fromJson(Map<String, dynamic> json)
38+
: generation = json[_generationKey] as int,
39+
status = Status.values.byName(json[_statusKey] as String),
40+
rejectionMessage = json[_rejectionMessageKey] as String?;
41+
42+
Map<String, dynamic> toJson() {
43+
return {
44+
_generationKey: generation,
45+
_statusKey: status.name,
46+
if (rejectionMessage != null) _rejectionMessageKey: rejectionMessage,
47+
};
48+
}
49+
50+
static final hotReloadReceiptTag = '_!# HOT RELOAD RECEIPT #!_: ';
51+
52+
static final compileTimeErrorMessage =
53+
'Correct error verified at compile time.';
54+
}

pkg/reload_test/lib/src/_ddc_reload_utils.dart

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
// The structure here reflects interfaces defined in DDC's
88
// `ddc_module_loader.js`.
99

10+
import 'dart:convert';
1011
import 'dart:js_interop';
1112

13+
import '../hot_reload_receipt.dart';
14+
1215
extension type _DartLoader(JSObject _) implements JSObject {
1316
external _DDCLoader get loader;
1417
}
@@ -22,8 +25,8 @@ extension type _DDCLoader(JSObject _) implements JSObject {
2225
}
2326

2427
extension type _DartDevEmbedder(JSObject _) implements JSObject {
25-
external JSFunction hotReload;
26-
external JSFunction hotRestart;
28+
external JSPromise hotReload(JSArray<JSString> files, JSArray<JSString> ids);
29+
external JSPromise hotRestart();
2730
external JSNumber get hotReloadGeneration;
2831
external JSNumber get hotRestartGeneration;
2932
}
@@ -32,7 +35,8 @@ extension type _DartDevEmbedder(JSObject _) implements JSObject {
3235
external _DartDevEmbedder get _dartDevEmbedder;
3336

3437
@JS('\$injectedFilesAndLibrariesToReload')
35-
external JSFunction injectedFilesAndLibrariesToReload;
38+
external JSArray<JSArray<JSString>>? injectedFilesAndLibrariesToReload(
39+
JSNumber requestedFileGeneration);
3640

3741
@JS('\$dartLoader')
3842
external _DartLoader get _dartLoader;
@@ -43,15 +47,71 @@ int get hotRestartGeneration => _dartDevEmbedder.hotRestartGeneration.toDartInt;
4347

4448
Future<void> hotRestart() async {
4549
_ddcLoader.intendedHotRestartGeneration++;
46-
await (_dartDevEmbedder.hotRestart.callAsFunction() as JSPromise).toDart;
50+
final restartReceipt = HotReloadReceipt(
51+
generation: _ddcLoader.intendedHotRestartGeneration,
52+
status: Status.restarted,
53+
);
54+
print('${HotReloadReceipt.hotReloadReceiptTag}'
55+
'${jsonEncode(restartReceipt.toJson())}');
56+
await _dartDevEmbedder.hotRestart().toDart;
4757
}
4858

59+
/// The reload generation of the currently running application.
4960
int get hotReloadGeneration => _dartDevEmbedder.hotReloadGeneration.toDartInt;
5061

51-
Future<void> hotReload() async {
52-
var hotReloadArgs =
53-
injectedFilesAndLibrariesToReload.callAsFunction() as JSArray;
54-
await (_dartDevEmbedder.hotReload.callAsFunction(
55-
null, hotReloadArgs[0], hotReloadArgs[1]) as JSPromise)
62+
/// The generation of reload requests by the test application.
63+
///
64+
/// This could differ from [hotReloadGeneration] when file generations get
65+
/// rejected and the running application stays in the previous "application"
66+
/// generation.
67+
int _hotReloadFileGeneration = 0;
68+
69+
Future<void> hotReload({bool expectRejection = false}) async {
70+
_hotReloadFileGeneration++;
71+
final generationFileInfo =
72+
injectedFilesAndLibrariesToReload(_hotReloadFileGeneration.toJS);
73+
final HotReloadReceipt reloadStatus = expectRejection
74+
? _rejectNextGeneration(generationFileInfo)
75+
: await _reloadNextGeneration(generationFileInfo);
76+
// Write reload receipt with a leading tag to be recognized by the reload
77+
// suite runner and validated.
78+
print('${HotReloadReceipt.hotReloadReceiptTag}'
79+
'${jsonEncode(reloadStatus.toJson())}');
80+
}
81+
82+
HotReloadReceipt _rejectNextGeneration(
83+
JSArray<JSArray<JSString>>? generationFileInfo) {
84+
if (generationFileInfo != null) {
85+
throw Exception(
86+
'Generation $_hotReloadFileGeneration was not rejected at compile '
87+
'time. Verify the calls of `hotReload(expectRejection: true)` in the '
88+
'test source match the rejected generation files.');
89+
}
90+
// This reload wasn't expected to find any files so this is OK.
91+
// * The correct reason for rejection was already validated at compile time
92+
// so nothing to check here.
93+
// * Expected number and order of reloads are validated at the end of the test
94+
// run.
95+
return HotReloadReceipt(
96+
generation: _hotReloadFileGeneration,
97+
status: Status.rejected,
98+
rejectionMessage: HotReloadReceipt.compileTimeErrorMessage,
99+
);
100+
}
101+
102+
Future<HotReloadReceipt> _reloadNextGeneration(
103+
JSArray<JSArray<JSString>>? generationFileInfo) async {
104+
if (generationFileInfo == null) {
105+
throw Exception(
106+
'No compiled files found for generation $_hotReloadFileGeneration. '
107+
'Verify the calls of `hotReload()` in the test match the accepted '
108+
'generation source files.');
109+
}
110+
await (_dartDevEmbedder.hotReload(
111+
generationFileInfo[0], generationFileInfo[1]))
56112
.toDart;
113+
return HotReloadReceipt(
114+
generation: _hotReloadFileGeneration,
115+
status: Status.accepted,
116+
);
57117
}

pkg/reload_test/lib/src/_reload_utils_api.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ Future<void> hotRestart() async =>
1313
int get hotReloadGeneration =>
1414
throw Exception('Not implemented on this platform.');
1515

16-
Future<void> hotReload() async =>
16+
Future<void> hotReload({bool expectRejection = false}) async =>
1717
throw Exception('Not implemented on this platform.');

pkg/reload_test/lib/src/_vm_reload_utils.dart

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44

55
// Helper functions for the hot reload test suite.
66

7+
import 'dart:convert';
78
import 'dart:developer' show Service;
89
import 'dart:io' as io;
910
import 'package:vm_service/vm_service.dart' show VmService, ReloadReport;
1011
import 'package:vm_service/vm_service_io.dart' as vm_service_io;
1112

13+
import '../hot_reload_receipt.dart';
14+
1215
int get hotRestartGeneration =>
1316
throw Exception('Not implemented on this platform.');
1417

@@ -20,10 +23,19 @@ int get hotReloadGeneration => _reloadCounter;
2023

2124
HotReloadHelper? _hotReloadHelper;
2225

23-
Future<void> hotReload() async {
26+
Future<void> hotReload({bool expectRejection = false}) async {
2427
_hotReloadHelper ??= await HotReloadHelper.create();
25-
_reloadCounter++;
26-
await _hotReloadHelper!.reloadNextGeneration();
28+
HotReloadReceipt reloadReceipt;
29+
if (expectRejection) {
30+
reloadReceipt = await _hotReloadHelper!._rejectNextGeneration();
31+
} else {
32+
_reloadCounter++;
33+
reloadReceipt = await _hotReloadHelper!._reloadNextGeneration();
34+
}
35+
// Write reload receipt with a leading tag to be recognized by the reload
36+
// suite runner and validated.
37+
print('${HotReloadReceipt.hotReloadReceiptTag}'
38+
'${jsonEncode(reloadReceipt.toJson())}');
2739
}
2840

2941
/// Helper to mediate with the vm service protocol.
@@ -49,11 +61,17 @@ class HotReloadHelper {
4961
/// * All dill files have the same name across every generation
5062
final String dillName;
5163

64+
/// File name of a dill that contains compile time errors.
65+
///
66+
/// We assume that this generation contained expected compile time errors and
67+
/// should be rejected at runtime.
68+
final String errorDillName;
69+
5270
/// The current generation being executed by the VM.
5371
int generation = 0;
5472

55-
HotReloadHelper._(
56-
this._vmService, this._id, this.testOutputDirUri, this.dillName);
73+
HotReloadHelper._(this._vmService, this._id, this.testOutputDirUri,
74+
this.dillName, this.errorDillName);
5775

5876
/// Create a helper that is bound to the current VM and isolate.
5977
static Future<HotReloadHelper> create() async {
@@ -80,29 +98,92 @@ class HotReloadHelper {
8098
print('Error: Unable to find generation in dill file: $dillUri.');
8199
io.exit(1);
82100
}
101+
final dillName = dillUri.pathSegments.last;
102+
final errorDillName = dillName.replaceAll('.dill', '.error.dill');
83103

84104
return HotReloadHelper._(
85-
vmService, id, dillUri.resolve('../'), dillUri.pathSegments.last);
105+
vmService, id, dillUri.resolve('../'), dillName, errorDillName);
86106
}
87107

88108
/// Trigger a hot-reload on the current isolate for the next generation.
89109
///
90-
/// Also checks that the generation aftewards exists. If not, the VM service
110+
/// Also checks that the generation afterwards exists. If not, the VM service
91111
/// is disconnected to allow the VM to complete.
92-
Future<ReloadReport> reloadNextGeneration() async {
112+
Future<HotReloadReceipt> _reloadNextGeneration() async {
93113
generation += 1;
94114
final nextGenerationDillUri =
95115
testOutputDirUri.resolve('generation$generation/$dillName');
96116
print('Reloading: $nextGenerationDillUri');
97117
var reloadReport = await _vmService.reloadSources(_id,
98118
rootLibUri: nextGenerationDillUri.path);
99-
final nextNextGenerationDillUri =
100-
testOutputDirUri.resolve('generation${generation + 1}/$dillName');
101-
final hasNextNextGeneration =
102-
io.File.fromUri(nextNextGenerationDillUri).existsSync();
103-
if (!hasNextNextGeneration) {
104-
await _vmService.dispose();
119+
if (!reloadReport.success!) {
120+
throw Exception('Reload for generation $generation was rejected.\n'
121+
'${reloadReport.reasonForCancelling}');
122+
}
123+
var reloadReceipt = HotReloadReceipt(
124+
generation: generation,
125+
status: Status.accepted,
126+
);
127+
if (!hasNextGeneration) await cleanUp();
128+
return reloadReceipt;
129+
}
130+
131+
Future<HotReloadReceipt> _rejectNextGeneration() async {
132+
generation += 1;
133+
HotReloadReceipt reloadReceipt;
134+
final errorDillFile = io.File.fromUri(
135+
testOutputDirUri.resolve('generation$generation/$errorDillName'));
136+
if (errorDillFile.existsSync()) {
137+
// This generation contained a compile time error that has already been
138+
// validated and should be rejected.
139+
reloadReceipt = HotReloadReceipt(
140+
generation: generation,
141+
status: Status.rejected,
142+
rejectionMessage: HotReloadReceipt.compileTimeErrorMessage);
143+
} else {
144+
final nextGenerationDillUri =
145+
testOutputDirUri.resolve('generation$generation/$dillName');
146+
print('Reloading (expecting rejection): $nextGenerationDillUri');
147+
final reloadReport = await _vmService.reloadSources(_id,
148+
rootLibUri: nextGenerationDillUri.path);
149+
if (reloadReport.success!) {
150+
throw Exception('Generation $generation was not rejected. Verify the '
151+
'calls of `hotReload(expectRejection: true)` in the test source '
152+
'match the rejected generation files.');
153+
}
154+
reloadReceipt = HotReloadReceipt(
155+
generation: generation,
156+
status: Status.rejected,
157+
rejectionMessage: reloadReport.reasonForCancelling,
158+
);
159+
}
160+
if (!hasNextGeneration) await cleanUp();
161+
return reloadReceipt;
162+
}
163+
164+
bool get hasNextGeneration {
165+
final nextNextGenerationDirUri =
166+
testOutputDirUri.resolve('generation${generation + 1}');
167+
return io.Directory.fromUri(nextNextGenerationDirUri).existsSync();
168+
}
169+
170+
Future<void> cleanUp() async => await _vmService.dispose();
171+
}
172+
173+
/// Extension to expose the reason for a failed reload.
174+
///
175+
/// This is currently in the json response from the vm-service, but not exposed
176+
/// as an API in [ReloadReport].
177+
extension on ReloadReport {
178+
String? get reasonForCancelling {
179+
final notices = this.json?['notices'] as List?;
180+
if (notices != null) {
181+
for (final notice in notices) {
182+
if (notice['type'] == 'ReasonForCancelling') {
183+
return notice['message'] as String?;
184+
}
185+
}
105186
}
106-
return reloadReport;
187+
return null;
107188
}
108189
}

0 commit comments

Comments
 (0)