Skip to content

Commit 49821e9

Browse files
authored
Add unit test coverage for incremental build invalidation. (#3985)
* Add unit test coverage for incremental build invalidation. * Address review comments.
1 parent 1a0aa3b commit 49821e9

File tree

4 files changed

+664
-24
lines changed

4 files changed

+664
-24
lines changed
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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:async';
6+
import 'dart:convert';
7+
8+
import 'package:build/build.dart';
9+
import 'package:build_test/build_test.dart';
10+
import 'package:built_collection/built_collection.dart';
11+
import 'package:crypto/crypto.dart';
12+
import 'package:logging/logging.dart';
13+
import 'package:test/test.dart';
14+
15+
/// Test case which build steps are triggered by changes to files.
16+
///
17+
/// For concise tests, "names" are used instead of asset IDs. The name `a` maps
18+
/// to the path `lib/a.dart`; all names map to the package `pkg`. "Hidden" asset
19+
/// IDs under `.dart_tool` are mapped back to the same namespace.
20+
class InvalidationTester {
21+
/// The source assets on disk before the first build.
22+
final Set<AssetId> _sourceAssets = {};
23+
24+
/// The builders that will run.
25+
final List<TestBuilder> _builders = [];
26+
27+
/// The [OutputStrategy] for generated assets.
28+
///
29+
/// [OutputStrategy.inputDigest] is the default if there is no entry.
30+
final Map<AssetId, OutputStrategy> _outputs = {};
31+
32+
/// Assets written by generators.
33+
final Set<AssetId> _generatedOutputsWritten = {};
34+
35+
/// The [TestReaderWriter] from the most recent build.
36+
TestReaderWriter? _readerWriter;
37+
38+
/// The build number for "printOnFailure" output.
39+
int _buildNumber = 0;
40+
41+
/// Sets the assets that will be on disk before the first build.
42+
///
43+
/// See the note on "names" in the class dartdoc.
44+
void sources(Iterable<String> names) {
45+
_sourceAssets.clear();
46+
for (final name in names) {
47+
_sourceAssets.add(name.assetId);
48+
}
49+
}
50+
51+
/// Adds a builder to the test.
52+
///
53+
/// [from] and [to] are the input and output extension of the builder,
54+
/// without ".dart". So `from: '', to: '.g'` is a builder that takes `.dart`
55+
/// files as primary inputs and outputs corresponding `.g.dart` files.
56+
///
57+
/// Set [isOptional] to make the builder optional. Optional builders only run
58+
/// if their output is depended on by a non-optional builder.
59+
///
60+
/// Returns a [TestBuilderBuilder], use it to specify what the builder does.
61+
TestBuilderBuilder builder({
62+
required String from,
63+
required String to,
64+
bool isOptional = false,
65+
}) {
66+
final builder = TestBuilder(this, from, [to], isOptional);
67+
_builders.add(builder);
68+
return TestBuilderBuilder(builder);
69+
}
70+
71+
/// Sets the output strategy for [name] to [OutputStrategy.fixed].
72+
///
73+
/// By default, output will be a digest of all read files. This changes it to
74+
/// fixed: it won't change when inputs change.
75+
void fixOutput(String name) {
76+
_outputs[name.assetId] = OutputStrategy.fixed;
77+
}
78+
79+
/// Sets the output strategy for [name] to [OutputStrategy.none].
80+
///
81+
/// The generator will not output the file.
82+
void skipOutput(String name) {
83+
_outputs[name.assetId] = OutputStrategy.none;
84+
}
85+
86+
/// Does a build.
87+
///
88+
/// For the initial build, do not pass [change] [delete] or [create].
89+
///
90+
/// For subsequent builds, pass asset name [change] to change that asset;
91+
/// [delete] to delete one; and/or [create] to create one.
92+
Future<Result> build({String? change, String? delete, String? create}) async {
93+
final assets = <AssetId, String>{};
94+
if (_readerWriter == null) {
95+
// Initial build.
96+
if (change != null || delete != null || create != null) {
97+
throw StateError('Do a build without change, delete or create first.');
98+
}
99+
for (final id in _sourceAssets) {
100+
assets[id] = '// initial source';
101+
}
102+
} else {
103+
// Create the new filesystem from the previous build state.
104+
for (final id in _readerWriter!.testing.assets) {
105+
assets[id] = _readerWriter!.testing.readString(id);
106+
}
107+
}
108+
109+
// Make the requested updates.
110+
if (change != null) {
111+
assets[change.assetId] = '${assets[change.assetId]}\n// changed';
112+
}
113+
if (delete != null) {
114+
if (assets.containsKey(delete.assetId)) {
115+
assets.remove(delete.assetId);
116+
} else {
117+
if (assets.containsKey(delete.generatedAssetId)) {
118+
assets.remove(delete.generatedAssetId);
119+
} else {
120+
throw StateError('Did not find $delete to delete in: $assets');
121+
}
122+
}
123+
}
124+
if (create != null) {
125+
if (assets.containsKey(create.assetId)) {
126+
throw StateError('Asset $create to create already exists in: $assets');
127+
}
128+
assets[create.assetId] = '// initial source';
129+
}
130+
131+
// Build and check what changed.
132+
final startingAssets = assets.keys.toSet();
133+
_generatedOutputsWritten.clear();
134+
final log = StringBuffer();
135+
final testBuildResult = await testBuilders(
136+
onLog: (record) => log.writeln(record.display),
137+
_builders,
138+
assets.map((id, content) => MapEntry(id.toString(), content)),
139+
rootPackage: 'pkg',
140+
optionalBuilders: _builders.where((b) => b.isOptional).toSet(),
141+
testingBuilderConfig: false,
142+
);
143+
printOnFailure(
144+
'=== build log #${++_buildNumber} ===\n\n'
145+
'${log.toString().trimAndIndent}',
146+
);
147+
_readerWriter = testBuildResult.readerWriter;
148+
149+
final written = _generatedOutputsWritten.map(_assetIdToName);
150+
final deletedAssets = startingAssets.difference(
151+
_readerWriter!.testing.assets.toSet(),
152+
);
153+
final deleted = deletedAssets.map(_assetIdToName);
154+
155+
return Result(written: written, deleted: deleted);
156+
}
157+
}
158+
159+
/// Strategy used by generators for outputting files.
160+
enum OutputStrategy {
161+
/// Output nothing.
162+
none,
163+
164+
/// Output with fixed content.
165+
fixed,
166+
167+
/// Output with digest of all files that were read.
168+
inputDigest,
169+
}
170+
171+
/// The changes on disk caused by the build.
172+
class Result {
173+
/// The "names" of the assets that were written.
174+
BuiltSet<String> written;
175+
176+
/// The "names" of the assets that were deleted.
177+
BuiltSet<String> deleted;
178+
179+
Result({Iterable<String>? written, Iterable<String>? deleted})
180+
: written = (written ?? {}).toBuiltSet(),
181+
deleted = (deleted ?? {}).toBuiltSet();
182+
183+
@override
184+
bool operator ==(Object other) {
185+
if (other is! Result) return false;
186+
return written == other.written && deleted == other.deleted;
187+
}
188+
189+
@override
190+
int get hashCode => Object.hash(written, deleted);
191+
192+
@override
193+
String toString() => [
194+
'Result(',
195+
if (written.isNotEmpty) 'written: ${written.join(', ')}',
196+
if (deleted.isNotEmpty) 'deleted: ${deleted.join(', ')}',
197+
')',
198+
].join('\n');
199+
}
200+
201+
/// Sets test setup on a [TestBuilder].
202+
class TestBuilderBuilder {
203+
final TestBuilder _builder;
204+
205+
TestBuilderBuilder(this._builder);
206+
207+
/// Test setup: the builder will read the asset that is [extension] applied to
208+
/// the primary input.
209+
void reads(String extension) {
210+
_builder.reads.add('$extension.dart');
211+
}
212+
213+
/// Test setup: the builder will write the asset that is [extension] applied
214+
/// to the primary input.
215+
///
216+
/// The output will be new for this generation, unless the asset was
217+
/// fixed with `fixOutputs`.
218+
void writes(String extension) {
219+
_builder.writes.add('$extension.dart');
220+
}
221+
}
222+
223+
/// A builder that does reads and writes according to test setup.
224+
class TestBuilder implements Builder {
225+
@override
226+
final Map<String, List<String>> buildExtensions;
227+
228+
final bool isOptional;
229+
230+
final InvalidationTester _tester;
231+
232+
/// Assets that the builder will read.
233+
List<String> reads = [];
234+
235+
/// Assets that the builder will write.
236+
List<String> writes = [];
237+
238+
TestBuilder(this._tester, String from, Iterable<String> to, this.isOptional)
239+
: buildExtensions = {'$from.dart': to.map((to) => '$to.dart').toList()};
240+
241+
@override
242+
Future<void> build(BuildStep buildStep) async {
243+
final content = <String>[];
244+
for (final read in reads) {
245+
final readId = buildStep.inputId.replaceAllPathExtensions(read);
246+
content.add(read.toString());
247+
content.add(await buildStep.readAsString(readId));
248+
}
249+
for (final write in writes) {
250+
final writeId = buildStep.inputId.replaceAllPathExtensions(write);
251+
final outputStrategy =
252+
_tester._outputs[writeId] ?? OutputStrategy.inputDigest;
253+
final output = switch (outputStrategy) {
254+
OutputStrategy.fixed => '',
255+
OutputStrategy.inputDigest =>
256+
'// ${base64.encode(md5.convert(utf8.encode(content.join('\n\n'))).bytes)}',
257+
OutputStrategy.none => null,
258+
};
259+
if (output != null) {
260+
await buildStep.writeAsString(writeId, output);
261+
_tester._generatedOutputsWritten.add(writeId);
262+
}
263+
}
264+
}
265+
}
266+
267+
extension LogRecordExtension on LogRecord {
268+
/// Displays [message] with error and stack trace if present.
269+
String get display {
270+
if (error == null && stackTrace == null) return message;
271+
return '$message\n$error\n$stackTrace';
272+
}
273+
}
274+
275+
extension StringExtension on String {
276+
/// Maps "names" to [AssetId]s.
277+
///
278+
/// See [InvalidationTester] class dartdoc.
279+
AssetId get assetId => AssetId('pkg', 'lib/$this.dart');
280+
281+
/// Maps "names" to [AssetId]s under `build/generated`.
282+
AssetId get generatedAssetId =>
283+
AssetId('pkg', '.dart_tool/build/generated/pkg/lib/$this.dart');
284+
285+
/// Displays trimmed and with two space indent.
286+
String get trimAndIndent => ' ${toString().trim().replaceAll('\n', '\n ')}';
287+
}
288+
289+
/// Converts [StringExtension.assetId] and [StringExtension.generatedAssetId]
290+
/// back to [AssetId].
291+
String _assetIdToName(AssetId id) {
292+
final path = id.path.replaceAll('.dart_tool/build/generated/pkg/', '');
293+
if (id.package != 'pkg' ||
294+
!path.startsWith('lib/') ||
295+
!path.endsWith('.dart')) {
296+
throw ArgumentError('Unexpected: $id');
297+
}
298+
return path.substring('lib/'.length, path.length - '.dart'.length);
299+
}
300+
301+
extension AssetIdExtension on AssetId {
302+
static final extensionsRegexp = RegExp(r'\..*');
303+
304+
AssetId replaceAllPathExtensions(String extension) =>
305+
AssetId(package, path.replaceAll(extensionsRegexp, '') + extension);
306+
}

0 commit comments

Comments
 (0)