|
| 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.dart'` is a builder that takes |
| 55 | + /// `.dart` 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: 'a', |
| 140 | + optionalBuilders: _builders.where((b) => b.isOptional).toSet(), |
| 141 | + testingBuilderConfig: false, |
| 142 | + ); |
| 143 | + printOnFailure( |
| 144 | + '=== build ${++_buildNumber} log ===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 | +class Result { |
| 172 | + BuiltSet<String> written; |
| 173 | + BuiltSet<String> deleted; |
| 174 | + |
| 175 | + Result({Iterable<String>? written, Iterable<String>? deleted}) |
| 176 | + : written = (written ?? {}).toBuiltSet(), |
| 177 | + deleted = (deleted ?? {}).toBuiltSet(); |
| 178 | + |
| 179 | + @override |
| 180 | + bool operator ==(Object other) { |
| 181 | + if (other is! Result) return false; |
| 182 | + return written == other.written && deleted == other.deleted; |
| 183 | + } |
| 184 | + |
| 185 | + @override |
| 186 | + int get hashCode => written.hashCode ^ deleted.hashCode; |
| 187 | + |
| 188 | + @override |
| 189 | + String toString() => [ |
| 190 | + 'Result(', |
| 191 | + if (written.isNotEmpty) 'written: ${written.join(', ')}', |
| 192 | + if (deleted.isNotEmpty) 'deleted: ${deleted.join(', ')}', |
| 193 | + ')', |
| 194 | + ].join('\n'); |
| 195 | +} |
| 196 | + |
| 197 | +/// Sets test setup on a [TestBuilder]. |
| 198 | +class TestBuilderBuilder { |
| 199 | + final TestBuilder _builder; |
| 200 | + |
| 201 | + TestBuilderBuilder(this._builder); |
| 202 | + |
| 203 | + /// Test setup: the builder will read the asset with [extension]. |
| 204 | + void reads(String extension) { |
| 205 | + _builder.reads.add('$extension.dart'); |
| 206 | + } |
| 207 | + |
| 208 | + /// Test setup: the builder will write the asset with [extension]. |
| 209 | + /// |
| 210 | + /// The output will be new for this generaton, unless the asset was |
| 211 | + /// fixed with `fixOutputs`. |
| 212 | + void writes(String extension) { |
| 213 | + _builder.writes.add('$extension.dart'); |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +String _assetIdToName(AssetId id) { |
| 218 | + final path = id.path.replaceAll('.dart_tool/build/generated/a/', ''); |
| 219 | + if (id.package != 'a' || |
| 220 | + !path.startsWith('lib/') || |
| 221 | + !path.endsWith('.dart')) { |
| 222 | + throw ArgumentError('Unexpected: $id'); |
| 223 | + } |
| 224 | + return path.substring('lib/'.length, path.length - '.dart'.length); |
| 225 | +} |
| 226 | + |
| 227 | +/// A builder that does reads and writes according to test setup. |
| 228 | +class TestBuilder implements Builder { |
| 229 | + @override |
| 230 | + final Map<String, List<String>> buildExtensions; |
| 231 | + |
| 232 | + final bool isOptional; |
| 233 | + |
| 234 | + final InvalidationTester _tester; |
| 235 | + |
| 236 | + /// Assets that the builder will read. |
| 237 | + List<String> reads = []; |
| 238 | + |
| 239 | + /// Assets that the builder will write. |
| 240 | + List<String> writes = []; |
| 241 | + |
| 242 | + TestBuilder(this._tester, String from, Iterable<String> to, this.isOptional) |
| 243 | + : buildExtensions = {'$from.dart': to.map((to) => '$to.dart').toList()}; |
| 244 | + |
| 245 | + @override |
| 246 | + Future<void> build(BuildStep buildStep) async { |
| 247 | + final content = <String>[]; |
| 248 | + for (final read in reads) { |
| 249 | + final readId = buildStep.inputId.changeAllExtensions(read); |
| 250 | + content.add(read.toString()); |
| 251 | + content.add(await buildStep.readAsString(readId)); |
| 252 | + } |
| 253 | + for (final write in writes) { |
| 254 | + final writeId = buildStep.inputId.changeAllExtensions(write); |
| 255 | + final outputStrategy = |
| 256 | + _tester._outputs[writeId] ?? OutputStrategy.inputDigest; |
| 257 | + final output = switch (outputStrategy) { |
| 258 | + OutputStrategy.fixed => '', |
| 259 | + OutputStrategy.inputDigest => |
| 260 | + '// ${base64.encode(md5.convert(utf8.encode(content.join('\n\n'))).bytes)}', |
| 261 | + OutputStrategy.none => null, |
| 262 | + }; |
| 263 | + if (output != null) { |
| 264 | + await buildStep.writeAsString(writeId, output); |
| 265 | + _tester._generatedOutputsWritten.add(writeId); |
| 266 | + } |
| 267 | + } |
| 268 | + } |
| 269 | +} |
| 270 | + |
| 271 | +extension LogRecordExtension on LogRecord { |
| 272 | + /// Displays [message] with error and stack trace if present. |
| 273 | + String get display { |
| 274 | + if (error == null && stackTrace == null) return message; |
| 275 | + return '$message\n$error\n$stackTrace'; |
| 276 | + } |
| 277 | +} |
| 278 | + |
| 279 | +extension StringExtension on String { |
| 280 | + /// Maps "names" to [AssetId]s. |
| 281 | + /// |
| 282 | + /// See [InvalidationTester] class dartdoc. |
| 283 | + AssetId get assetId => AssetId('a', 'lib/$this.dart'); |
| 284 | + |
| 285 | + /// Maps "names" to [AssetId]s under `build/generated`. |
| 286 | + AssetId get generatedAssetId => |
| 287 | + AssetId('a', '.dart_tool/build/generated/a/lib/$this.dart'); |
| 288 | + |
| 289 | + /// Displays trimmed and with two space indent. |
| 290 | + String get trimAndIndent => ' ${toString().trim().replaceAll('\n', '\n ')}'; |
| 291 | +} |
| 292 | + |
| 293 | +extension AssetIdExtension on AssetId { |
| 294 | + static final extensionsRegexp = RegExp(r'\..*'); |
| 295 | + |
| 296 | + AssetId changeAllExtensions(String extension) => |
| 297 | + AssetId(package, path.replaceAll(extensionsRegexp, '') + extension); |
| 298 | +} |
0 commit comments