diff --git a/build/lib/src/library_cycle_graph/asset_deps_loader.dart b/build/lib/src/library_cycle_graph/asset_deps_loader.dart index b2ddbd454..db0ab3690 100644 --- a/build/lib/src/library_cycle_graph/asset_deps_loader.dart +++ b/build/lib/src/library_cycle_graph/asset_deps_loader.dart @@ -72,12 +72,20 @@ class _InMemoryAssetDepsLoader implements AssetDepsLoader { ); PhasedAssetDeps phasedAssetDeps; - _InMemoryAssetDepsLoader(this.phasedAssetDeps); - - // This is important: it prevents LibraryCycleGraphLoader from trying to load - // data that is not in an incomplete [phasedAssetDeps]. + _InMemoryAssetDepsLoader(PhasedAssetDeps phasedAssetDeps) + : phasedAssetDeps = phasedAssetDeps.complete(); + + // Return very high phase to tell `LibraryCycleGraphLoader` that all data is + // available. + // + // Returning incomplete data would then cause `LibraryCycleGraphLoader` to + // get stuck, which is why `phasedAssetDeps.complete` was called to mark + // all the data complete. + // + // This loader is only used for rebuilding graphs constructed in an earlier + // run, so incomplete data won't actually be used. @override - int get phase => phasedAssetDeps.phase; + int get phase => 0xffffffff; @override ExpiringValue _parse(AssetId id, ExpiringValue content) => diff --git a/build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart b/build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart index f960ad60f..fefe36eb3 100644 --- a/build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart +++ b/build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart @@ -104,9 +104,9 @@ class LibraryCycleGraphLoader { (_idsToLoad[phase] ??= []).add(id); } - void _loadAllAtPhase(int phase, Iterable ids) { + void _loadAllAtPhaseZero(Iterable ids) { if (ids.isEmpty) return; - (_idsToLoad[phase] ??= []).addAll(ids); + (_idsToLoad[0] ??= []).addAll(ids); } /// Whether there are assets to load before or at [upToPhase]. @@ -144,17 +144,27 @@ class LibraryCycleGraphLoader { /// /// Pass a phase and ID from [_nextIdToLoad]. void _removeIdToLoad(int phase, AssetId id) { - // A recursive load might have updated `_idsToLoad` since `_nextIdToLoad` - // was called. If so it fully processed some phases: either `_idsToLoad` is - // now empty at `phase`, in which case there is nothing to do, or it's - // unchanged, in which case `id` is still the last ID. final ids = _idsToLoad[phase]; - if (ids != null) { - if (ids.removeLast() != id) { - throw StateError('$id should still be last in _idsToLoad[$phase]'); + + // It's possible that a recursive call to an earlier phase fully processed + // the phase, leaving nothing to clean up. + if (ids == null) { + return; + } + + // It's possible that a recursive call to an earlier phase encountered a + // reference to an asset generated at this phase, and so added another + // asset to load in this phase. In that case `id` is no longer the last in + // the list: search the whole list to remove it. + if (ids.last == id) { + ids.removeLast(); + } else { + final removed = ids.remove(id); + if (!removed) { + throw StateError('Failed to remove $id from _idsToLoad: $_idsToLoad'); } - if (ids.isEmpty) _idsToLoad.remove(phase); } + if (ids.isEmpty) _idsToLoad.remove(phase); } /// Loads [id] and its transitive dependencies at all phases available to @@ -221,7 +231,7 @@ class LibraryCycleGraphLoader { // for loading at any phase: if the `_load` that loads them is at a too // early phase to see generated output they will be queued for // processing by a later `_load`. - _loadAllAtPhase(0, assetDeps.lastValue.deps); + _loadAllAtPhaseZero(assetDeps.lastValue.deps); } else { // It's a generated source that has not yet been generated. Mark it for // loading later. diff --git a/build/lib/src/library_cycle_graph/phased_asset_deps.dart b/build/lib/src/library_cycle_graph/phased_asset_deps.dart index 105ba8781..d8519add1 100644 --- a/build/lib/src/library_cycle_graph/phased_asset_deps.dart +++ b/build/lib/src/library_cycle_graph/phased_asset_deps.dart @@ -2,8 +2,6 @@ // 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:math'; - import 'package:built_collection/built_collection.dart'; import 'package:built_value/built_value.dart'; import 'package:built_value/serializer.dart'; @@ -29,9 +27,6 @@ abstract class PhasedAssetDeps _$PhasedAssetDeps; PhasedAssetDeps._(); - factory PhasedAssetDeps.of(Map> assetDeps) => - _$PhasedAssetDeps._(assetDeps: assetDeps.build()); - /// Returns `this` data with [other] added to it. /// /// For each asset: if [other] has a complete value for that asset, use the @@ -53,20 +48,12 @@ abstract class PhasedAssetDeps return result.build(); } - /// The max phase before there is any incomplete data, or 0xffffffff if there - /// is no incomplete data. - @memoized - int get phase { - int? result; - for (final entry in assetDeps.values) { - if (!entry.isComplete) { - if (result == null) { - result = entry.expiresAfter; - } else { - result = min(result, entry.expiresAfter!); - } + PhasedAssetDeps complete() => rebuild((b) { + for (final entry in assetDeps.entries) { + final value = entry.value; + if (!value.isComplete) { + b.assetDeps[entry.key] = PhasedValue.fixed(value.values.single.value); } } - return result ?? 0xffffffff; - } + }); } diff --git a/build/lib/src/library_cycle_graph/phased_asset_deps.g.dart b/build/lib/src/library_cycle_graph/phased_asset_deps.g.dart index 16a210efb..712d35ab8 100644 --- a/build/lib/src/library_cycle_graph/phased_asset_deps.g.dart +++ b/build/lib/src/library_cycle_graph/phased_asset_deps.g.dart @@ -71,7 +71,6 @@ class _$PhasedAssetDepsSerializer class _$PhasedAssetDeps extends PhasedAssetDeps { @override final BuiltMap> assetDeps; - int? __phase; factory _$PhasedAssetDeps([void Function(PhasedAssetDepsBuilder)? updates]) => (new PhasedAssetDepsBuilder()..update(updates))._build(); @@ -84,9 +83,6 @@ class _$PhasedAssetDeps extends PhasedAssetDeps { ); } - @override - int get phase => __phase ??= super.phase; - @override PhasedAssetDeps rebuild(void Function(PhasedAssetDepsBuilder) updates) => (toBuilder()..update(updates)).build(); diff --git a/build_runner_core/test/invalidation/invalidation_stress_test.dart b/build_runner_core/test/invalidation/invalidation_stress_test.dart new file mode 100644 index 000000000..d9e9e1dba --- /dev/null +++ b/build_runner_core/test/invalidation/invalidation_stress_test.dart @@ -0,0 +1,146 @@ +// 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:math'; + +import 'package:build/build.dart' show AssetId; +import 'package:test/test.dart'; + +import 'invalidation_tester.dart'; + +/// Tests correctness of invalidation over many randomly generated scenarios. +/// +/// The test builders write output that is a list of files read/resolved and their +/// content hashes. This does two things: it ensures that if any input changes, +/// the output changes; and it makes it possible to determine what will +/// invalidate that output. +/// +/// In this way the test can know what output changes to assert due to a +/// particular input change. +Future main() async { + for (var iteration = 0; iteration != 500; ++iteration) { + test('invalidation stress test $iteration', () async { + final tester = InvalidationTester()..logSetup(); + final random = Random(iteration); + + // Whether to change the imports in source files between builds. + final changeImports = iteration % 10 == 0; + + // How many checked in sources and how many builders; the build is + // a rectangle of size numberOfSources x numberOfBuilders. + final numberOfSources = random.nextInt(8) + 1; + final numberOfBuilders = random.nextInt(4) + 1; + + final sources = [ + for (var i = 0; i != numberOfSources; ++i) 'a${i + 1}.1', + ]; + + // Inputs that can be randomly read/resolved. + // `numberOfBuilders + 2` to add some reads/resolves of files that + // don't exist. + final pickableInputs = []; + for (var i = 1; i != (numberOfBuilders + 2); ++i) { + for (final source in sources) { + pickableInputs.add(source.replaceAll('.1', '.$i')); + } + } + + // All outputs. + final outputs = []; + for (var i = 2; i != (numberOfBuilders + 1); ++i) { + for (final source in sources) { + outputs.add(source.replaceAll('.1', '.$i')); + } + } + + // Picks a list of files to import from `pickableInputs`. + List randomImportList() { + final result = []; + final length = random.nextInt(numberOfSources); + for (var i = 0; i != length; ++i) { + result.add(pickableInputs[random.nextInt(pickableInputs.length)]); + } + return result; + } + + tester + ..sources(sources) + ..pickableInputs(pickableInputs); + + // Set up builders. + for (var i = 1; i != numberOfBuilders; ++i) { + tester.builder( + from: '.$i', + to: '.${i + 1}', + // Cover optional+required builders. + isOptional: random.nextBool(), + // Cover builders with hidden+visible output. + outputIsVisible: random.nextBool(), + ) + // Use the input as the seed for what additional reads to do, + // so reads won't change between identical runs. + ..readsForSeedThenReadsRandomly('.$i') + ..writes('.${i + 1}'); + } + + // Initial random import graph. + final importGraph = { + for (var source in pickableInputs) source: randomImportList(), + }; + tester.importGraph(importGraph); + + // Initial build should succeed. + expect((await tester.build()).succeeded, true); + + // Do five additional builds making changes and checking what was written. + for (var build = 0; build != 5; ++build) { + // Pick which source to change, compute expected outputs. + final sourceToChange = sources[random.nextInt(sources.length)]; + final expectedOutputs = {}; + + Future addExpectedOutputs(String invalidatedInput) async { + for (final output in outputs) { + final assetId = AssetId('pkg', 'lib/$output.dart'); + final hiddenAssetId = AssetId( + 'pkg', + '.dart_tool/build/generated/pkg/lib/$output.dart', + ); + final outputContents = + tester.readerWriter!.testing.exists(assetId) + ? tester.readerWriter!.testing.readString(assetId) + : tester.readerWriter!.testing.exists(hiddenAssetId) + ? tester.readerWriter!.testing.readString(hiddenAssetId) + : ''; + // The test builder output is a list of "$name,$hash" for each input + // that was read, including transitively resolved sources. Check it + // for [input]. If found, this output is invalidated: recursively + // add its invalidated outputs. + if (outputContents.contains('$invalidatedInput.dart,')) { + if (expectedOutputs.add(output)) { + await addExpectedOutputs(output); + } + } + } + } + + await addExpectedOutputs(sourceToChange); + + // If [changeImports] then change some imports; it's only possible to + // change imports for files that will be output. + if (changeImports && expectedOutputs.isNotEmpty) { + final sourceToChangeImports = + expectedOutputs.toList()[random.nextInt(expectedOutputs.length)]; + importGraph[sourceToChangeImports] = randomImportList(); + tester.importGraph(importGraph); + } + + // Build and check exactly the expected outputs change. + expect( + await tester.build(change: sourceToChange), + Result(written: expectedOutputs), + ); + } + }); + } +} diff --git a/build_runner_core/test/invalidation/invalidation_tester.dart b/build_runner_core/test/invalidation/invalidation_tester.dart index 601a71d95..863cfb563 100644 --- a/build_runner_core/test/invalidation/invalidation_tester.dart +++ b/build_runner_core/test/invalidation/invalidation_tester.dart @@ -4,7 +4,9 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; +import 'package:analyzer/dart/element/element.dart'; import 'package:build/build.dart'; import 'package:build_runner_core/src/util/constants.dart'; import 'package:build_test/build_test.dart'; @@ -22,6 +24,9 @@ class InvalidationTester { /// The source assets on disk before the first build. final Set _sourceAssets = {}; + /// Inputs that a builder might read. + final List _pickableInputs = []; + /// Import statements. /// /// If an asset has an entry here then sources and generated sources will @@ -45,7 +50,13 @@ class InvalidationTester { final Set _generatedOutputsWritten = {}; /// The [TestReaderWriter] from the most recent build. - TestReaderWriter? _readerWriter; + TestReaderWriter? readerWriter; + + /// Whether test setup is logged. + bool _logSetup = false; + + /// Logged setup for display before a build. + final List _setupLog = []; /// The build number for "printOnFailure" output. int _buildNumber = 0; @@ -53,18 +64,34 @@ class InvalidationTester { /// Output number, for writing outputs that are different. int _outputNumber = 0; + /// Starts logging test setup. + /// + /// Useful if test setup is random or generated. + void logSetup() { + _logSetup = true; + } + /// 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) { + if (_logSetup) _setupLog.add('tester.sources($names)'); _sourceAssets.clear(); for (final name in names) { _sourceAssets.add(name.assetId); } } + void pickableInputs(Iterable names) { + if (_logSetup) _setupLog.add('tester.pickableInputs($names)'); + _pickableInputs + ..clear() + ..addAll(names); + } + // Sets the import graph for source files and generated files. void importGraph(Map> importGraph) { + if (_logSetup) _setupLog.add('tester.importGraph($importGraph)'); _importGraph.clear(); _importGraph.addAll(importGraph); } @@ -85,6 +112,13 @@ class InvalidationTester { bool isOptional = false, bool outputIsVisible = true, }) { + if (_logSetup) { + _setupLog.add( + 'tester.builder(' + '$from, $to, isOptional: $isOptional, ' + 'outputIsVisible: $outputIsVisible)', + ); + } final builder = TestBuilder(this, from, [to], isOptional, outputIsVisible); _builders.add(builder); return TestBuilderBuilder(builder); @@ -95,12 +129,14 @@ class InvalidationTester { /// 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) { + if (_logSetup) _setupLog.add('tester.fixOutput($name)'); _outputStrategies[name.assetId] = OutputStrategy.fixed; } /// Sets the output strategy for [name] back to the default, /// [OutputStrategy.inputDigest]. void digestOutput(String name) { + if (_logSetup) _setupLog.add('tester.digestOutput($name)'); _outputStrategies[name.assetId] = OutputStrategy.inputDigest; } @@ -108,6 +144,7 @@ class InvalidationTester { /// /// The generator will not output the file. void skipOutput(String name) { + if (_logSetup) _setupLog.add('tester.skipOutput($name)'); _outputStrategies[name.assetId] = OutputStrategy.none; } @@ -115,11 +152,13 @@ class InvalidationTester { /// /// The generator will write any outputs it is configured to write, then fail. void fail(String name) { + if (_logSetup) _setupLog.add('tester.fail($name)'); _failureStrategies[name.assetId] = FailureStrategy.fail; } /// Sets the failure strategy for [name] to [FailureStrategy.succeed]. void succeed(String name) { + if (_logSetup) _setupLog.add('tester.succeed($name)'); _failureStrategies[name.assetId] = FailureStrategy.succeed; } @@ -137,8 +176,9 @@ class InvalidationTester { /// 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 { + if (_logSetup) _setupLog.add('tester.build($change, $delete, $create)'); final assets = {}; - if (_readerWriter == null) { + if (readerWriter == null) { // Initial build. if (change != null || delete != null || create != null) { throw StateError('Do a build without change, delete or create first.'); @@ -148,8 +188,8 @@ class InvalidationTester { } } else { // Create the new filesystem from the previous build state. - for (final id in _readerWriter!.testing.assets) { - assets[id] = _readerWriter!.testing.readString(id); + for (final id in readerWriter!.testing.assets) { + assets[id] = readerWriter!.testing.readString(id); } } @@ -192,13 +232,15 @@ class InvalidationTester { final logString = log.toString(); printOnFailure( '=== build log #${++_buildNumber} ===\n\n' + '${_setupLog.map((l) => ' $l\n').join('')}' '${logString.trimAndIndent}', ); - _readerWriter = testBuildResult.readerWriter; + if (_logSetup) _setupLog.clear(); + readerWriter = testBuildResult.readerWriter; final written = _generatedOutputsWritten.map(_assetIdToName); final deletedAssets = startingAssets.difference( - _readerWriter!.testing.assets.toSet(), + readerWriter!.testing.assets.toSet(), ); final deleted = deletedAssets.map(_assetIdToName); @@ -209,7 +251,7 @@ class InvalidationTester { /// The size of the asset graph that was written by [build], in bytes. int get assetGraphSize => - _readerWriter!.testing.readBytes(AssetId('pkg', assetGraphPath)).length; + readerWriter!.testing.readBytes(AssetId('pkg', assetGraphPath)).length; } /// Strategy used by generators for outputting files. @@ -220,7 +262,11 @@ enum OutputStrategy { /// Output with fixed content. fixed, - /// Output with digest of all files that were read. + /// Output with a list of inputs: each line `$name,$hash` for inputs + /// that could be read, or `$name` if not. + /// + /// If a builder resolves files, this includes all transitively resolved + /// files and their hashes. inputDigest, } @@ -275,23 +321,37 @@ class Result { /// Sets test setup on a [TestBuilder]. class TestBuilderBuilder { final TestBuilder _builder; + final bool _logSetup; + final List _setupLog; - TestBuilderBuilder(this._builder); + TestBuilderBuilder(this._builder) + : _logSetup = _builder._tester._logSetup, + _setupLog = _builder._tester._setupLog; /// Test setup: the builder will read the asset that is [extension] applied to /// the primary input. void reads(String extension) { + if (_logSetup) _setupLog.add('builder.reads($extension)'); _builder.reads.add('$extension.dart'); } /// Test setup: the builder will read the asset with [name]. - void readsOther(String name) { - _builder.otherReads.add(name); + void readsOther(String name, {String? forInput}) { + if (_logSetup) _setupLog.add('builder.readsOther($name)'); + (_builder.otherReads[forInput] ??= []).add(name); } /// Test setup: the builder will parse the Dart source asset with [name]. - void resolvesOther(String name) { - _builder.otherResolves.add(name); + void resolvesOther(String name, {String? forInput}) { + if (_logSetup) _setupLog.add('builder.resolvesOther($name)'); + (_builder.otherResolves[forInput] ??= []).add(name); + } + + void readsForSeedThenReadsRandomly(String extension) { + if (_logSetup) { + _setupLog.add('builder.readsForSeedThenReadsRandomly($extension)'); + } + _builder.readsForSeedThenReadsRandomly.add('$extension.dart'); } /// Test setup: the builder will write the asset that is [extension] applied @@ -300,6 +360,7 @@ class TestBuilderBuilder { /// The output will be new for this generation, unless the asset was /// fixed with `fixOutputs`. void writes(String extension) { + if (_logSetup) _setupLog.add('builder.writes($extension)'); _builder.writes.add('$extension.dart'); } } @@ -323,11 +384,17 @@ class TestBuilder implements Builder { /// [AssetIdExtension.replaceExtensions]. List reads = []; - /// Names of assets that the builder will read. - List otherReads = []; + /// Names of assets that the builder will read, by input name. + /// + /// The `null` key is for "all inputs". + Map> otherReads = {}; + + /// Names of assets that the builder will resolve, by input name. + /// + /// The `null` key is for "all inputs". + Map> otherResolves = {}; - /// Names of assets that the builder will resolve. - List otherResolves = []; + List readsForSeedThenReadsRandomly = []; /// Extensions of assets that the builder will write. /// @@ -345,35 +412,105 @@ class TestBuilder implements Builder { @override Future build(BuildStep buildStep) async { - final content = []; + final recordedInput = []; + + void recordInput(AssetId id, String? content) { + final hash = + content == null + ? null + : base64.encode(md5.convert(utf8.encode(content)).bytes); + recordedInput.add('$id${hash == null ? '' : ',$hash'}'); + } + for (final read in reads) { final readId = buildStep.inputId.replaceExtensions('$from.dart', read); - content.add(read.toString()); if (await buildStep.canRead(readId)) { - content.add(await buildStep.readAsString(readId)); + recordInput(readId, await buildStep.readAsString(readId)); + } else { + recordInput(readId, null); + } + } + + final pickedOtherReads = []; + final pickedOtherResolves = []; + for (final read in readsForSeedThenReadsRandomly) { + final readId = buildStep.inputId.replaceExtensions('$from.dart', read); + if (await buildStep.canRead(readId)) { + final content = await buildStep.readAsString(readId); + recordInput(readId, content); + final random = Random(content.hashCode); + + final pick = + _tester._pickableInputs[random.nextInt( + _tester._pickableInputs.length, + )]; + if (_tester._logSetup) { + _tester._setupLog.add( + 'builder $buildExtensions on ' + '${_assetIdToName(buildStep.inputId)} picked read: $pick', + ); + } + pickedOtherReads.add(pick); + + final resolvePick = + _tester._pickableInputs[random.nextInt( + _tester._pickableInputs.length, + )]; + if (_tester._logSetup) { + _tester._setupLog.add( + 'builder $buildExtensions on ' + '${_assetIdToName(buildStep.inputId)} picked resolve: $resolvePick', + ); + } + pickedOtherResolves.add(resolvePick); + } else { + recordInput(readId, null); } } - for (final read in otherReads) { - content.add(read.assetId.toString()); + + final otherReadsForThisInput = (otherReads[null] ?? []).followedBy( + otherReads[_assetIdToName(buildStep.inputId)] ?? [], + ); + for (final read in [...otherReadsForThisInput, ...pickedOtherReads]) { if (await buildStep.canRead(read.assetId)) { - content.add(await buildStep.readAsString(read.assetId)); + recordInput(read.assetId, await buildStep.readAsString(read.assetId)); + } else { + recordInput(read.assetId, null); } } - for (final resolve in otherResolves) { - content.add(resolve.assetId.toString()); - await buildStep.resolver.libraryFor(resolve.assetId); + + final otherResolvesForThisInput = (otherResolves[null] ?? []).followedBy( + otherResolves[_assetIdToName(buildStep.inputId)] ?? [], + ); + for (final resolve in [ + ...otherResolvesForThisInput, + ...pickedOtherResolves, + ]) { + if (await buildStep.canRead(resolve.assetId)) { + recordInput( + resolve.assetId, + await buildStep.readAsString(resolve.assetId), + ); + final library = await buildStep.resolver.libraryFor(resolve.assetId); + for (final import in library.transitiveImports) { + recordInput( + AssetId.resolve(import.source.uri), + import.source.exists() ? import.source.contents.data : null, + ); + } + } else { + recordInput(resolve.assetId, null); + } } for (final write in writes) { final writeId = buildStep.inputId.replaceExtensions('$from.dart', write); final outputStrategy = _tester._outputStrategies[writeId] ?? OutputStrategy.inputDigest; - final inputHash = base64.encode( - md5.convert(utf8.encode(content.join('\n\n'))).bytes, - ); final output = switch (outputStrategy) { OutputStrategy.fixed => _tester._imports(writeId), OutputStrategy.inputDigest => - '${_tester._imports(writeId)}\n// $inputHash', + '${_tester._imports(writeId)}\n' + '${recordedInput.map((l) => '// $l\n').join('')}', OutputStrategy.none => null, }; if (output != null) { @@ -431,3 +568,25 @@ extension AssetIdExtension on AssetId { AssetId replaceExtensions(String from, String to) => AssetId(package, path.replaceAll(RegExp('$from\$'), to)); } + +// ignore_for_file: deprecated_member_use +extension TransitiveLibrariesExtension on LibraryElement { + /// Finds all transitive imports from this library, excluding SDK libraries. + Set get transitiveImports { + final result = Set.identity(); + final work = [this]; + + while (work.isNotEmpty) { + final current = work.removeLast(); + // For each library found, add its direct dependencies. + for (final library in current.importedLibraries) { + if (library.isInSdk) continue; + if (result.add(library)) { + work.add(library); + } + } + } + + return result; + } +} diff --git a/build_runner_core/test/invalidation/library_cycle_graph_update_test.dart b/build_runner_core/test/invalidation/library_cycle_graph_update_test.dart index 95c468794..073bf164c 100644 --- a/build_runner_core/test/invalidation/library_cycle_graph_update_test.dart +++ b/build_runner_core/test/invalidation/library_cycle_graph_update_test.dart @@ -64,4 +64,49 @@ void main() { expect(await tester.build(change: 'z2'), Result(written: ['a.3'])); }); }); + + // A build that causes incomplete library graph data to be stored + // along with complete data changing at a later phase. This checks that + // phased data is not cut off at the first incomplete phase. + group('a1 <== [a.2] <== [a.3], a.4 <== a.5 <== a.6, ' + 'a.2 resolves: z3 -> a.3, ' + 'a.6 resolves: a.5, ' + 'b.5 reads: a.2', () { + setUp(() { + tester.sources(['a.1', 'z3', 'a.4', 'b.4']); + tester.importGraph({ + 'z3': ['a.3'], + }); + tester.builder(from: '.1', to: '.2', isOptional: true) + ..reads('.1') + // This tries to resolve z3 -> a.3 when a.3 is not yet available, + // causing incomplete data. + ..resolvesOther('z3') + ..writes('.2'); + tester.builder(from: '.2', to: '.3', isOptional: true) + ..reads('.2') + ..writes('.3'); + tester.builder(from: '.4', to: '.5') + ..reads('.4') + ..writes('.5'); + tester.builder(from: '.5', to: '.6') + ..reads('.5') + // Resolve onto a.5 means there is library cycle graph data used which + // changes at that (late) phase. + ..resolvesOther('a.5', forInput: 'a.5') + // Then read a.2 to trigger resolve of z3 at an earlier phase. It reads + // incomplete data and marks it for reading "at a later phase", but no + // more reads are done at a later phase so it remains incomplete. + ..readsOther('a.2', forInput: 'b.5') + ..writes('.6'); + }); + + test('can rebuild with no changes', () async { + expect( + await tester.build(), + Result(written: ['a.2', 'a.5', 'b.5', 'a.6', 'b.6']), + ); + expect(await tester.build(), Result()); + }); + }); } diff --git a/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart b/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart index 2bca6566a..f1b997a95 100644 --- a/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart +++ b/build_runner_core/test/invalidation/resolved_input_invalidation_test.dart @@ -501,4 +501,60 @@ void main() { ); }); }); + + // Resolve to an earlier generated asset triggers resolve to a later + // generated asset in the currently-running phase. + group('a.1 <== a.2 <== [a.3] <== a.4, ' + 'b.2 resolves: b.1 --> c.3, ' + 'c.2 resolves: a.1 --> a.3', () { + setUp(() { + tester.sources(['a.1', 'b.1', 'c.1']); + tester.importGraph({ + 'a.1': ['a.3'], + 'b.1': ['c.3'], + }); + tester.builder(from: '.1', to: '.2') + ..reads('.1') + ..writes('.2'); + tester.builder( + from: '.2', + to: '.3', + isOptional: true, + outputIsVisible: true, + ) + ..reads('.2') + // Resolving b.1 causes imported c.3 to be queued for read at later + // phase. + ..resolvesOther('b.1', forInput: 'b.2') + ..resolvesOther('a.1', forInput: 'c.2') + ..writes('.3'); + tester.builder(from: '.3', to: '.4') + ..reads('.3') + // Resolving c.1 causes read of c.3 because it was queued for read at + // a later phase and can now be read. This causes generation of c.3 + // to run, which resolves a.1, causing a.3 to be queued for read at + // this currently-running phase. + ..resolvesOther('c.1', forInput: 'b.3') + ..writes('.4'); + }); + + test('a.2+a.4+a.6 are built', () async { + expect( + await tester.build(), + Result( + written: [ + 'a.2', + 'b.2', + 'c.2', + 'a.3', + 'b.3', + 'c.3', + 'a.4', + 'b.4', + 'c.4', + ], + ), + ); + }); + }); }