diff --git a/build_runner_core/test/invalidation/invalidation_tester.dart b/build_runner_core/test/invalidation/invalidation_tester.dart new file mode 100644 index 0000000000..1d755a7298 --- /dev/null +++ b/build_runner_core/test/invalidation/invalidation_tester.dart @@ -0,0 +1,306 @@ +// 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:async'; +import 'dart:convert'; + +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:crypto/crypto.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +/// Test case which build steps are triggered by changes to files. +/// +/// For concise tests, "names" are used instead of asset IDs. The name `a` maps +/// to the path `lib/a.dart`; all names map to the package `pkg`. "Hidden" asset +/// IDs under `.dart_tool` are mapped back to the same namespace. +class InvalidationTester { + /// The source assets on disk before the first build. + final Set _sourceAssets = {}; + + /// The builders that will run. + final List _builders = []; + + /// The [OutputStrategy] for generated assets. + /// + /// [OutputStrategy.inputDigest] is the default if there is no entry. + final Map _outputs = {}; + + /// Assets written by generators. + final Set _generatedOutputsWritten = {}; + + /// The [TestReaderWriter] from the most recent build. + TestReaderWriter? _readerWriter; + + /// The build number for "printOnFailure" output. + int _buildNumber = 0; + + /// Sets the assets that will be on disk before the first build. + /// + /// See the note on "names" in the class dartdoc. + void sources(Iterable names) { + _sourceAssets.clear(); + for (final name in names) { + _sourceAssets.add(name.assetId); + } + } + + /// Adds a builder to the test. + /// + /// [from] and [to] are the input and output extension of the builder, + /// without ".dart". So `from: '', to: '.g'` is a builder that takes `.dart` + /// files as primary inputs and outputs corresponding `.g.dart` files. + /// + /// Set [isOptional] to make the builder optional. Optional builders only run + /// if their output is depended on by a non-optional builder. + /// + /// Returns a [TestBuilderBuilder], use it to specify what the builder does. + TestBuilderBuilder builder({ + required String from, + required String to, + bool isOptional = false, + }) { + final builder = TestBuilder(this, from, [to], isOptional); + _builders.add(builder); + return TestBuilderBuilder(builder); + } + + /// Sets the output strategy for [name] to [OutputStrategy.fixed]. + /// + /// By default, output will be a digest of all read files. This changes it to + /// fixed: it won't change when inputs change. + void fixOutput(String name) { + _outputs[name.assetId] = OutputStrategy.fixed; + } + + /// Sets the output strategy for [name] to [OutputStrategy.none]. + /// + /// The generator will not output the file. + void skipOutput(String name) { + _outputs[name.assetId] = OutputStrategy.none; + } + + /// Does a build. + /// + /// For the initial build, do not pass [change] [delete] or [create]. + /// + /// For subsequent builds, pass asset name [change] to change that asset; + /// [delete] to delete one; and/or [create] to create one. + Future build({String? change, String? delete, String? create}) async { + final assets = {}; + if (_readerWriter == null) { + // Initial build. + if (change != null || delete != null || create != null) { + throw StateError('Do a build without change, delete or create first.'); + } + for (final id in _sourceAssets) { + assets[id] = '// initial source'; + } + } else { + // Create the new filesystem from the previous build state. + for (final id in _readerWriter!.testing.assets) { + assets[id] = _readerWriter!.testing.readString(id); + } + } + + // Make the requested updates. + if (change != null) { + assets[change.assetId] = '${assets[change.assetId]}\n// changed'; + } + if (delete != null) { + if (assets.containsKey(delete.assetId)) { + assets.remove(delete.assetId); + } else { + if (assets.containsKey(delete.generatedAssetId)) { + assets.remove(delete.generatedAssetId); + } else { + throw StateError('Did not find $delete to delete in: $assets'); + } + } + } + if (create != null) { + if (assets.containsKey(create.assetId)) { + throw StateError('Asset $create to create already exists in: $assets'); + } + assets[create.assetId] = '// initial source'; + } + + // Build and check what changed. + final startingAssets = assets.keys.toSet(); + _generatedOutputsWritten.clear(); + final log = StringBuffer(); + final testBuildResult = await testBuilders( + onLog: (record) => log.writeln(record.display), + _builders, + assets.map((id, content) => MapEntry(id.toString(), content)), + rootPackage: 'pkg', + optionalBuilders: _builders.where((b) => b.isOptional).toSet(), + testingBuilderConfig: false, + ); + printOnFailure( + '=== build log #${++_buildNumber} ===\n\n' + '${log.toString().trimAndIndent}', + ); + _readerWriter = testBuildResult.readerWriter; + + final written = _generatedOutputsWritten.map(_assetIdToName); + final deletedAssets = startingAssets.difference( + _readerWriter!.testing.assets.toSet(), + ); + final deleted = deletedAssets.map(_assetIdToName); + + return Result(written: written, deleted: deleted); + } +} + +/// Strategy used by generators for outputting files. +enum OutputStrategy { + /// Output nothing. + none, + + /// Output with fixed content. + fixed, + + /// Output with digest of all files that were read. + inputDigest, +} + +/// The changes on disk caused by the build. +class Result { + /// The "names" of the assets that were written. + BuiltSet written; + + /// The "names" of the assets that were deleted. + BuiltSet deleted; + + Result({Iterable? written, Iterable? deleted}) + : written = (written ?? {}).toBuiltSet(), + deleted = (deleted ?? {}).toBuiltSet(); + + @override + bool operator ==(Object other) { + if (other is! Result) return false; + return written == other.written && deleted == other.deleted; + } + + @override + int get hashCode => Object.hash(written, deleted); + + @override + String toString() => [ + 'Result(', + if (written.isNotEmpty) 'written: ${written.join(', ')}', + if (deleted.isNotEmpty) 'deleted: ${deleted.join(', ')}', + ')', + ].join('\n'); +} + +/// Sets test setup on a [TestBuilder]. +class TestBuilderBuilder { + final TestBuilder _builder; + + TestBuilderBuilder(this._builder); + + /// Test setup: the builder will read the asset that is [extension] applied to + /// the primary input. + void reads(String extension) { + _builder.reads.add('$extension.dart'); + } + + /// Test setup: the builder will write the asset that is [extension] applied + /// to the primary input. + /// + /// The output will be new for this generation, unless the asset was + /// fixed with `fixOutputs`. + void writes(String extension) { + _builder.writes.add('$extension.dart'); + } +} + +/// A builder that does reads and writes according to test setup. +class TestBuilder implements Builder { + @override + final Map> buildExtensions; + + final bool isOptional; + + final InvalidationTester _tester; + + /// Assets that the builder will read. + List reads = []; + + /// Assets that the builder will write. + List writes = []; + + TestBuilder(this._tester, String from, Iterable to, this.isOptional) + : buildExtensions = {'$from.dart': to.map((to) => '$to.dart').toList()}; + + @override + Future build(BuildStep buildStep) async { + final content = []; + for (final read in reads) { + final readId = buildStep.inputId.replaceAllPathExtensions(read); + content.add(read.toString()); + content.add(await buildStep.readAsString(readId)); + } + for (final write in writes) { + final writeId = buildStep.inputId.replaceAllPathExtensions(write); + final outputStrategy = + _tester._outputs[writeId] ?? OutputStrategy.inputDigest; + final output = switch (outputStrategy) { + OutputStrategy.fixed => '', + OutputStrategy.inputDigest => + '// ${base64.encode(md5.convert(utf8.encode(content.join('\n\n'))).bytes)}', + OutputStrategy.none => null, + }; + if (output != null) { + await buildStep.writeAsString(writeId, output); + _tester._generatedOutputsWritten.add(writeId); + } + } + } +} + +extension LogRecordExtension on LogRecord { + /// Displays [message] with error and stack trace if present. + String get display { + if (error == null && stackTrace == null) return message; + return '$message\n$error\n$stackTrace'; + } +} + +extension StringExtension on String { + /// Maps "names" to [AssetId]s. + /// + /// See [InvalidationTester] class dartdoc. + AssetId get assetId => AssetId('pkg', 'lib/$this.dart'); + + /// Maps "names" to [AssetId]s under `build/generated`. + AssetId get generatedAssetId => + AssetId('pkg', '.dart_tool/build/generated/pkg/lib/$this.dart'); + + /// Displays trimmed and with two space indent. + String get trimAndIndent => ' ${toString().trim().replaceAll('\n', '\n ')}'; +} + +/// Converts [StringExtension.assetId] and [StringExtension.generatedAssetId] +/// back to [AssetId]. +String _assetIdToName(AssetId id) { + final path = id.path.replaceAll('.dart_tool/build/generated/pkg/', ''); + if (id.package != 'pkg' || + !path.startsWith('lib/') || + !path.endsWith('.dart')) { + throw ArgumentError('Unexpected: $id'); + } + return path.substring('lib/'.length, path.length - '.dart'.length); +} + +extension AssetIdExtension on AssetId { + static final extensionsRegexp = RegExp(r'\..*'); + + AssetId replaceAllPathExtensions(String extension) => + AssetId(package, path.replaceAll(extensionsRegexp, '') + extension); +} diff --git a/build_runner_core/test/invalidation/primary_input_invalidation_test.dart b/build_runner_core/test/invalidation/primary_input_invalidation_test.dart new file mode 100644 index 0000000000..2c0e46a0f5 --- /dev/null +++ b/build_runner_core/test/invalidation/primary_input_invalidation_test.dart @@ -0,0 +1,315 @@ +// 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 'package:test/test.dart'; + +import 'invalidation_tester.dart'; + +/// Invalidation tests in which the only inputs are primary inputs. +/// +/// In the test names: +/// +/// - `a1 <-- a2` means a1 is the primary input of a2, but _not_ an input: +/// the builder does not read a1 +/// - `a1 <== a2` means a1 is the primary input of a2 _and_ is an input: +/// the builder _does_ read a1 +/// - `[a1]` means a1 is an optional output +void main() { + late InvalidationTester tester; + + setUp(() { + tester = InvalidationTester(); + tester.sources(['a']); + }); + + group('a <-- a.1', () { + setUp(() { + tester.builder(from: '', to: '.1').writes('.1'); + }); + + test('a.1 is built', () async { + expect(await tester.build(), Result(written: ['a.1'])); + }); + + test('change a, nothing is rebuilt', () async { + await tester.build(); + expect(await tester.build(change: 'a'), Result()); + }); + + test('delete a, a.1 is deleted', () async { + await tester.build(); + expect(await tester.build(delete: 'a'), Result(deleted: ['a.1'])); + }); + + test('delete a.1, a.1 is rebuilt', () async { + await tester.build(); + expect(await tester.build(delete: 'a.1'), Result(written: ['a.1'])); + }); + + test('create b, b.1 is built', () async { + await tester.build(); + expect(await tester.build(create: 'b'), Result(written: ['b.1'])); + }); + }); + + group('a <== a.1', () { + setUp(() { + tester.builder(from: '', to: '.1') + ..reads('') + ..writes('.1'); + }); + + test('change a, a.1 is rebuilt', () async { + await tester.build(); + expect(await tester.build(change: 'a'), Result(written: ['a.1'])); + }); + }); + + group('a <-- [a.1]', () { + setUp(() { + tester.builder(from: '', to: '.1', isOptional: true).writes('.1'); + }); + + test('a.1 is not built', () async { + expect(await tester.build(), Result()); + }); + + test('change a, a.1 is not built', () async { + await tester.build(); + expect(await tester.build(change: 'a'), Result()); + }); + }); + + group('a <-- a.1 <-- a.2', () { + setUp(() { + tester.builder(from: '', to: '.1').writes('.1'); + tester.builder(from: '.1', to: '.2').writes('.2'); + }); + + test('a.1 is not output, a.2 is not built', () async { + tester.skipOutput('a.1'); + expect(await tester.build(), Result()); + }); + + test('a.1+a.2 are built', () async { + expect(await tester.build(), Result(written: ['a.1', 'a.2'])); + }); + + test('change a, no rebuilds', () async { + await tester.build(); + expect(await tester.build(change: 'a'), Result()); + }); + + test('delete a, a.1+a.2 are deleted', () async { + await tester.build(); + expect(await tester.build(delete: 'a'), Result(deleted: ['a.1', 'a.2'])); + }); + + test('delete a.1, a.1 is rebuilt', () async { + await tester.build(); + expect(await tester.build(delete: 'a.1'), Result(written: ['a.1'])); + }); + + test('delete a.2, a.2 is rebuilt', () async { + await tester.build(); + expect(await tester.build(delete: 'a.2'), Result(written: ['a.2'])); + }); + }); + + group('a <== a.1 <-- a.2', () { + setUp(() { + tester.builder(from: '', to: '.1') + ..reads('') + ..writes('.1'); + tester.builder(from: '.1', to: '.2').writes('.2'); + }); + + test('change a, a.1 is rebuilt', () async { + await tester.build(); + expect(await tester.build(change: 'a'), Result(written: ['a.1'])); + }); + + test('change a, on rebuild a.1 is not output, a.2 is deleted', () async { + expect(await tester.build(), Result(written: ['a.1', 'a.2'])); + tester.skipOutput('a.1'); + expect( + await tester.build(change: 'a'), + // TODO(davidmorgan): this would be the correct result, see + // https://github.com/dart-lang/build/issues/3875. + // Result(deleted: ['a.1', 'a.2']), + Result(deleted: ['a.1']), + ); + }); + }); + + group('a <-- a.1 <== a.2', () { + setUp(() { + tester.builder(from: '', to: '.1').writes('.1'); + tester.builder(from: '.1', to: '.2') + ..reads('.1') + ..writes('.2'); + }); + + test('change a, nothing is rebuilt', () async { + await tester.build(); + expect(await tester.build(change: 'a'), Result()); + }); + }); + + group('a <== a.1 <== a.2', () { + setUp(() { + tester.builder(from: '', to: '.1') + ..reads('') + ..writes('.1'); + tester.builder(from: '.1', to: '.2') + ..reads('.1') + ..writes('.2'); + }); + + test('change a, a.1+a.2 are rebuilt', () async { + await tester.build(); + expect(await tester.build(change: 'a'), Result(written: ['a.1', 'a.2'])); + }); + + test( + 'change a, a.1 is rebuilt, produces same output so a.2 is not rebuilt', + () async { + tester.fixOutput('a.1'); + await tester.build(); + expect(await tester.build(change: 'a'), Result(written: ['a.1'])); + }, + ); + + test('change a, on rebuild a.1 is not output, a.2 is deleted', () async { + expect(await tester.build(), Result(written: ['a.1', 'a.2'])); + tester.skipOutput('a.1'); + expect( + await tester.build(change: 'a'), + // TODO(davidmorgan): this would be the correct result, see + // https://github.com/dart-lang/build/issues/3875. + // Result(deleted: ['a.1', 'a.2']), + Result(deleted: ['a.1']), + ); + }); + }); + + group('a <-- [a.1] <-- a.2', () { + setUp(() { + tester.builder(from: '', to: '.1', isOptional: true).writes('.1'); + tester.builder(from: '.1', to: '.2').writes('.2'); + }); + + test('a.1+a.2 are built', () async { + expect(await tester.build(), Result(written: ['a.1', 'a.2'])); + }); + + test('change a, no rebuilds', () async { + await tester.build(); + expect(await tester.build(change: 'a'), Result()); + }); + + test('delete a, a.1+a.2 are deleted', () async { + await tester.build(); + expect(await tester.build(delete: 'a'), Result(deleted: ['a.1', 'a.2'])); + }); + + test('delete a.1, a.1 is rebuilt', () async { + await tester.build(); + expect(await tester.build(delete: 'a.1'), Result(written: ['a.1'])); + }); + + test('delete a.2, a.2 is rebuilt', () async { + await tester.build(); + expect(await tester.build(delete: 'a.2'), Result(written: ['a.2'])); + }); + }); + + group('a <-- [a.1] <-- a.2 <-- [a.3]', () { + setUp(() { + tester.builder(from: '', to: '.1', isOptional: true).writes('.1'); + tester.builder(from: '.1', to: '.2').writes('.2'); + tester.builder(from: '.2', to: '.3', isOptional: true).writes('.3'); + }); + + test('a.3 is not built', () async { + expect(await tester.build(), Result(written: ['a.1', 'a.2'])); + }); + }); + + group('a <-- [a.1] <-- a.2 <-- [a.3] <-- a.4', () { + setUp(() { + tester.builder(from: '', to: '.1', isOptional: true).writes('.1'); + tester.builder(from: '.1', to: '.2').writes('.2'); + tester.builder(from: '.2', to: '.3', isOptional: true).writes('.3'); + tester.builder(from: '.3', to: '.4').writes('.4'); + }); + + test('a.1+a.2+a.3+a.4 are built', () async { + expect( + await tester.build(), + Result(written: ['a.1', 'a.2', 'a.3', 'a.4']), + ); + }); + + test('change a, no rebuilds', () async { + await tester.build(); + expect((await tester.build(change: 'a')).written, isEmpty); + }); + + test('create b, b.1+b.2+b.3+b.4 are built', () async { + await tester.build(); + expect( + await tester.build(create: 'b'), + Result(written: ['b.1', 'b.2', 'b.3', 'b.4']), + ); + }); + }); + + group('a <== [a.1] <== a.2 <== [a.3] <== a.4', () { + setUp(() { + tester.builder(from: '', to: '.1', isOptional: true) + ..reads('') + ..writes('.1'); + tester.builder(from: '.1', to: '.2') + ..reads('.1') + ..writes('.2'); + tester.builder(from: '.2', to: '.3', isOptional: true) + ..reads('.2') + ..writes('.3'); + tester.builder(from: '.3', to: '.4') + ..reads('.3') + ..writes('.4'); + }); + + test('change a, a.1+a.2+a.3+a.4 are rebuilt', () async { + await tester.build(); + expect( + await tester.build(change: 'a'), + Result(written: ['a.1', 'a.2', 'a.3', 'a.4']), + ); + }); + + test('change a, a.1+a.2 are rebuilt, ' + 'produces same output so a.3+a.4 are not rebuilt', () async { + tester.fixOutput('a.2'); + await tester.build(); + expect(await tester.build(change: 'a'), Result(written: ['a.1', 'a.2'])); + }); + + test('change a, on rebuild a.2 is not output', () async { + expect( + await tester.build(), + Result(written: ['a.1', 'a.2', 'a.3', 'a.4']), + ); + tester.skipOutput('a.2'); + expect( + await tester.build(change: 'a'), + // TODO(davidmorgan): this would be the correct result, see + // https://github.com/dart-lang/build/issues/3875. + // Result(written: ['a.1'], deleted: ['a.2', 'a.3', 'a.4']), + Result(written: ['a.1'], deleted: ['a.2']), + ); + }); + }); +} diff --git a/build_test/CHANGELOG.md b/build_test/CHANGELOG.md index 27cc6fc589..186fd12925 100644 --- a/build_test/CHANGELOG.md +++ b/build_test/CHANGELOG.md @@ -5,6 +5,9 @@ - `resolveSources` and `testBuilder` now do a full `build_runner` build, with configuration as much as possible based on the some parameters. - Add `testBuilders` to run a test build with multiple builders. +- Add `optionalBuilder` to `testBuilders` to have some builders be optional. +- Add `testingBuilderConfig` to `testBuilders` to control builder config + override. - Add `resolvers` parameter to `testBuild` and `testBuilders`. - Breaking change: removed `tearDown` parameter to `resolveSources` for keeping resolvers across multiple tests. diff --git a/build_test/lib/src/test_builder.dart b/build_test/lib/src/test_builder.dart index 7a6792091e..0cc73c56dc 100644 --- a/build_test/lib/src/test_builder.dart +++ b/build_test/lib/src/test_builder.dart @@ -189,6 +189,13 @@ Future testBuilder( /// Enabling of language experiments is supported through the /// `withEnabledExperiments` method from package:build. /// +/// To mark a builder as optional, add it to [optionalBuilders]. Optional +/// builders only run if their output is used by a non-optional builder. +/// +/// The default builder config will be overwritten with one that causes the +/// builder to run for all inputs. To use the default builder config instead, +/// set [testingBuilderConfig] to `false`. +/// /// Returns a [TestBuilderResult] with the [TestReaderWriter] used for /// the build, which can be used for further checks. Future testBuilders( @@ -202,6 +209,8 @@ Future testBuilders( void Function(AssetId, Iterable)? reportUnusedAssetsForInput, PackageConfig? packageConfig, Resolvers? resolvers, + Set optionalBuilders = const {}, + bool testingBuilderConfig = true, }) async { onLog ??= (log) => @@ -268,27 +277,31 @@ Future testBuilders( packageGraph: packageGraph, reportUnusedAssetsForInput: reportUnusedAssetsForInput, resolvers: resolvers, - // Override sources to all inputs, optionally restricted by [inputFilter] or - // [generateFor]. Without this, the defaults would be used, for example - // picking up `lib/**` but not all files in the package root. - overrideBuildConfig: { - for (final package in allPackages) - package: BuildConfig.fromMap(package, [], { - 'targets': { - package: { - 'sources': [ - r'\$package$', - r'lib/$lib$', - r'test/$test$', - r'web/$web$', - ...inputIds - .where((id) => id.package == package) - .map((id) => id.path), - ], - }, - }, - }), - }, + overrideBuildConfig: + // Override sources to all inputs, optionally restricted by + // [inputFilter] or [generateFor]. Or if [testingBuilderConfig] is + // false, use the defaults. These skip some files, for example + // picking up `lib/**` but not all files in the package root. + testingBuilderConfig + ? { + for (final package in allPackages) + package: BuildConfig.fromMap(package, [], { + 'targets': { + package: { + 'sources': [ + r'\$package$', + r'lib/$lib$', + r'test/$test$', + r'web/$web$', + ...inputIds + .where((id) => id.package == package) + .map((id) => id.path), + ], + }, + }, + }), + } + : const {}, // Tests always trigger the "build script updated" check, even if it // didn't change. Skip it to allow testing with preserved state. skipBuildScriptCheck: true, @@ -296,9 +309,12 @@ Future testBuilders( final buildSeries = await BuildSeries.create(buildOptions, environment, [ for (final builder in builders) - apply(builderName(builder), [ - (_) => builder, - ], (package) => package.name != r'$sdk'), + apply( + builderName(builder), + [(_) => builder], + (package) => package.name != r'$sdk', + isOptional: optionalBuilders.contains(builder), + ), ], {}); // Run the build.