diff --git a/build_compilers/lib/src/summary_builder.dart b/build_compilers/lib/src/summary_builder.dart index 700f9c842..5ab1a128c 100644 --- a/build_compilers/lib/src/summary_builder.dart +++ b/build_compilers/lib/src/summary_builder.dart @@ -115,6 +115,8 @@ Future createLinkedSummary(Module module, BuildStep buildStep, var scratchSpace = await buildStep.fetchResource(scratchSpaceResource); var allAssetIds = new Set() + // TODO: Why can't we just add the unlinked summary? + // That would help invalidation. ..addAll(module.sources) ..addAll(transitiveLinkedSummaryDeps) ..addAll(transitiveUnlinkedSummaryDeps); @@ -141,7 +143,8 @@ Future createLinkedSummary(Module module, BuildStep buildStep, request.arguments.addAll(_analyzerSourceArgsForModule(module, scratchSpace)); var analyzer = await buildStep.fetchResource(analyzerDriverResource); var response = await analyzer.doWork(request); - if (response.exitCode == EXIT_CODE_ERROR) { + var summaryFile = scratchSpace.fileFor(module.linkedSummaryId); + if (response.exitCode == EXIT_CODE_ERROR || !await summaryFile.exists()) { throw new AnalyzerSummaryException(module.linkedSummaryId, response.output); } diff --git a/build_runner/lib/src/asset/reader.dart b/build_runner/lib/src/asset/reader.dart index a7e196614..b8feb13a2 100644 --- a/build_runner/lib/src/asset/reader.dart +++ b/build_runner/lib/src/asset/reader.dart @@ -45,7 +45,7 @@ abstract class Md5DigestReader implements DigestAssetReader { /// /// Tracks the assets and globs read during this step for input dependency /// tracking. -class SingleStepReader implements AssetReader { +class SingleStepReader implements DigestAssetReader { final AssetGraph _assetGraph; final _assetsRead = new Set(); final DigestAssetReader _delegate; @@ -95,6 +95,13 @@ class SingleStepReader implements AssetReader { return result; } + @override + Future digest(AssetId id) async { + if (!_isReadable(id)) throw new AssetNotFoundException(id); + await _ensureAssetIsBuilt(id); + return _ensureDigest(id); + } + @override Future> readAsBytes(AssetId id) async { if (!_isReadable(id)) throw new AssetNotFoundException(id); @@ -135,8 +142,8 @@ class SingleStepReader implements AssetReader { } } - Future _ensureDigest(AssetId id) async { + Future _ensureDigest(AssetId id) async { var node = _assetGraph.get(id); - node.lastKnownDigest ??= await _delegate.digest(id); + return node.lastKnownDigest ??= await _delegate.digest(id); } } diff --git a/build_runner/lib/src/asset_graph/graph.dart b/build_runner/lib/src/asset_graph/graph.dart index c4e9d3c26..2dfdd46ce 100644 --- a/build_runner/lib/src/asset_graph/graph.dart +++ b/build_runner/lib/src/asset_graph/graph.dart @@ -66,7 +66,9 @@ class AssetGraph { var existing = get(node.id); if (existing != null) { if (existing is SyntheticAssetNode) { - _remove(existing.id); + // Don't call _removeRecursive, that recursively removes all transitive + // primary outputs. We only want to remove this node. + _nodesByPackage[existing.id.package].remove(existing.id.path); node.outputs.addAll(existing.outputs); node.primaryOutputs.addAll(existing.primaryOutputs); } else { @@ -100,14 +102,16 @@ class AssetGraph { /// Removes the node representing [id] from the graph, and all of its /// `primaryOutput`s. /// + /// Also removes all edges between all removed nodes and other nodes. + /// /// Returns a [Set] of all removed nodes. - Set _remove(AssetId id, {Set removedIds}) { + Set _removeRecursive(AssetId id, {Set removedIds}) { removedIds ??= new Set(); var node = get(id); if (node == null) return removedIds; removedIds.add(id); for (var output in node.primaryOutputs) { - _remove(output, removedIds: removedIds); + _removeRecursive(output, removedIds: removedIds); } for (var output in node.outputs) { var generatedNode = get(output) as GeneratedAssetNode; @@ -146,30 +150,24 @@ class AssetGraph { Future delete(AssetId id), DigestAssetReader digestReader) async { var invalidatedIds = new Set(); - // All the assets that should be deleted. - var idsToDelete = new Set(); - // Builds up `idsToDelete` and `idsToRemove` by recursively invalidating - // the outputs of `id`. - void clearNodeAndDeps(AssetId id, ChangeType rootChangeType) { + // Transitively invalidates all assets. + void invalidateNodeAndDeps(AssetId id, ChangeType rootChangeType) { var node = this.get(id); if (node == null) return; if (!invalidatedIds.add(id)) return; if (node is GeneratedAssetNode) { - idsToDelete.add(id); node.needsUpdate = true; - node.wasOutput = false; - node.globs = new Set(); } // Update all outputs of this asset as well. for (var output in node.outputs) { - clearNodeAndDeps(output, rootChangeType); + invalidateNodeAndDeps(output, rootChangeType); } } - updates.forEach(clearNodeAndDeps); + updates.forEach(invalidateNodeAndDeps); var newIds = new Set(); var modifyIds = new Set(); @@ -197,10 +195,33 @@ class AssetGraph { newAndModifiedNodes.where((node) => node.outputs.isNotEmpty), digestReader); + // Collects the set of all transitive ids to be removed from the graph, + // based on the removed `SourceAssetNode`s by following the + // `primaryOutputs`. + var transitiveRemovedIds = new Set(); + addTransitivePrimaryOutputs(AssetId id) { + transitiveRemovedIds.add(id); + get(id).primaryOutputs.forEach(addTransitivePrimaryOutputs); + } + + removeIds + .where((id) => get(id) is SourceAssetNode) + .forEach(addTransitivePrimaryOutputs); + + // The generated nodes to actually delete from the file system. + var idsToDelete = new Set.from(transitiveRemovedIds) + ..removeAll(removeIds); + + // For manually deleted generated outputs, we bash away their + // `previousInputsDigest` to make sure they actually get regenerated. + for (var deletedOutput + in removeIds.where((id) => get(id) is GeneratedAssetNode)) { + (get(deletedOutput) as GeneratedAssetNode).previousInputsDigest = null; + } + var allNewAndDeletedIds = _addOutputsForSources(buildActions, newIds, rootPackage) - ..addAll(removeIds) - ..addAll(idsToDelete); + ..addAll(transitiveRemovedIds); // For all new or deleted assets, check if they match any globs. for (var id in allNewAndDeletedIds) { @@ -211,7 +232,7 @@ class AssetGraph { .globs .any((glob) => glob.matches(id.path))) { // The change type is irrelevant here. - clearNodeAndDeps(node.id, null); + invalidateNodeAndDeps(node.id, null); } } } @@ -223,7 +244,9 @@ class AssetGraph { // Remove all deleted source assets from the graph, which also recursively // removes all their primary outputs. - removeIds.where((id) => get(id) is SourceAssetNode).forEach(_remove); + removeIds + .where((id) => get(id) is SourceAssetNode) + .forEach(_removeRecursive); return invalidatedIds; } @@ -290,7 +313,7 @@ class AssetGraph { // outputs, and replace them with a `GeneratedAssetNode`. if (contains(output)) { assert(get(output) is! GeneratedAssetNode); - _remove(output, removedIds: removed); + _removeRecursive(output, removedIds: removed); } _add(new GeneratedAssetNode( @@ -304,5 +327,5 @@ class AssetGraph { // TODO remove once tests are updated void add(AssetNode node) => _add(node); - Set remove(AssetId id) => _remove(id); + Set remove(AssetId id) => _removeRecursive(id); } diff --git a/build_runner/lib/src/asset_graph/node.dart b/build_runner/lib/src/asset_graph/node.dart index 64179802b..2798dc584 100644 --- a/build_runner/lib/src/asset_graph/node.dart +++ b/build_runner/lib/src/asset_graph/node.dart @@ -2,6 +2,8 @@ // 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:collection'; + import 'package:build/build.dart'; import 'package:crypto/crypto.dart'; import 'package:glob/glob.dart'; @@ -74,13 +76,27 @@ class GeneratedAssetNode extends AssetNode { /// All the inputs that were read when generating this asset, or deciding not /// to generate it. - final Set inputs; + /// + /// This needs to be an ordered set because we compute combined input digests + /// using this later on. + final SplayTreeSet inputs; + + /// A digest combining all digests of all previous inputs. + /// + /// Used to determine whether all the inputs to a build step are identical to + /// the previous run, indicating that the previous output is still valid. + Digest previousInputsDigest; GeneratedAssetNode(this.phaseNumber, this.primaryInput, this.needsUpdate, this.wasOutput, AssetId id, - {Digest lastKnownDigest, Set globs, Iterable inputs}) + {Digest lastKnownDigest, + Set globs, + Iterable inputs, + this.previousInputsDigest}) : this.globs = globs ?? new Set(), - this.inputs = inputs?.toSet() ?? new Set(), + this.inputs = inputs != null + ? new SplayTreeSet.from(inputs) + : new SplayTreeSet(), super(id, lastKnownDigest: lastKnownDigest); @override diff --git a/build_runner/lib/src/asset_graph/serialization.dart b/build_runner/lib/src/asset_graph/serialization.dart index 340b38932..0917f3a8a 100644 --- a/build_runner/lib/src/asset_graph/serialization.dart +++ b/build_runner/lib/src/asset_graph/serialization.dart @@ -8,7 +8,7 @@ part of 'graph.dart'; /// /// This should be incremented any time the serialize/deserialize formats /// change. -const _version = 10; +const _version = 11; /// Deserializes an [AssetGraph] from a [Map]. class _AssetGraphDeserializer { @@ -57,9 +57,7 @@ class _AssetGraphDeserializer { var typeId = _NodeType.values[serializedNode[_Field.NodeType.index] as int]; var id = _idToAssetId[serializedNode[_Field.Id.index] as int]; var serializedDigest = serializedNode[_Field.Digest.index] as String; - var digest = serializedDigest == null - ? null - : new Digest(BASE64.decode(serializedDigest)); + var digest = _deserializeDigest(serializedDigest); switch (typeId) { case _NodeType.Source: assert(serializedNode.length == _WrappedAssetNode._length); @@ -72,16 +70,17 @@ class _AssetGraphDeserializer { case _NodeType.Generated: assert(serializedNode.length == _WrappedGeneratedAssetNode._length); node = new GeneratedAssetNode( - serializedNode[_Field.PhaseNumber.index] as int, - _idToAssetId[serializedNode[_Field.PrimaryInput.index] as int], - _deserializeBool(serializedNode[_Field.NeedsUpdate.index] as int), - _deserializeBool(serializedNode[_Field.WasOutput.index] as int), - id, - globs: (serializedNode[_Field.Globs.index] as Iterable) - .map((pattern) => new Glob(pattern)) - .toSet(), - lastKnownDigest: digest, - ); + serializedNode[_Field.PhaseNumber.index] as int, + _idToAssetId[serializedNode[_Field.PrimaryInput.index] as int], + _deserializeBool(serializedNode[_Field.NeedsUpdate.index] as int), + _deserializeBool(serializedNode[_Field.WasOutput.index] as int), + id, + globs: (serializedNode[_Field.Globs.index] as Iterable) + .map((pattern) => new Glob(pattern)) + .toSet(), + lastKnownDigest: digest, + previousInputsDigest: _deserializeDigest( + serializedNode[_Field.PreviousInputsDigest.index] as String)); break; } node.outputs.addAll(_deserializeAssetIds( @@ -95,6 +94,10 @@ class _AssetGraphDeserializer { serializedIds.map((id) => _idToAssetId[id]).toList(); bool _deserializeBool(int value) => value == 0 ? false : true; + + Digest _deserializeDigest(String serializedDigest) => serializedDigest == null + ? null + : new Digest(BASE64.decode(serializedDigest)); } /// Serializes an [AssetGraph] into a [Map]. @@ -159,7 +162,8 @@ enum _Field { WasOutput, PhaseNumber, Globs, - NeedsUpdate + NeedsUpdate, + PreviousInputsDigest } /// Wraps an [AssetNode] in a class that implements [List] instead of @@ -253,6 +257,10 @@ class _WrappedGeneratedAssetNode extends _WrappedAssetNode { return generatedNode.globs.map((glob) => glob.pattern).toList(); case _Field.NeedsUpdate: return _serializeBool(generatedNode.needsUpdate); + case _Field.PreviousInputsDigest: + return generatedNode.previousInputsDigest == null + ? null + : BASE64.encode(generatedNode.previousInputsDigest.bytes); default: throw new RangeError.index(index, this); } diff --git a/build_runner/lib/src/generate/build_impl.dart b/build_runner/lib/src/generate/build_impl.dart index ed4a4e1c6..35f975c68 100644 --- a/build_runner/lib/src/generate/build_impl.dart +++ b/build_runner/lib/src/generate/build_impl.dart @@ -9,6 +9,8 @@ import 'package:build/build.dart'; import 'package:build/src/builder/build_step_impl.dart'; import 'package:build/src/builder/logging.dart'; import 'package:build_barback/build_barback.dart' show BarbackResolvers; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; import 'package:logging/logging.dart'; import 'package:stack_trace/stack_trace.dart'; import 'package:watcher/watcher.dart'; @@ -235,10 +237,10 @@ class BuildImpl { outputs.addAll(await _runPackageBuilder( phase, action.package, action.builder, resourceManager)); } else if (action is AssetBuildAction) { - var inputs = - await _matchingInputs(action.inputSet, phase, resourceManager); + var primaryInputs = + await _matchingPrimaryInputs(action, phase, resourceManager); outputs.addAll(await _runBuilder( - phase, action.builder, inputs, resourceManager)); + phase, action.builder, primaryInputs, resourceManager)); } else { throw new InvalidBuildActionException.unrecognizedType(action); } @@ -252,16 +254,24 @@ class BuildImpl { performance: performanceTracker..stop()); } - /// Gets a list of all inputs matching [inputSet]. + /// Gets a list of all inputs matching the [InputSet] of [action], as well as + /// its [Builder]s primary inputs. /// - /// Lazily builds any optional build actions matching [inputSet]. - Future> _matchingInputs(InputSet inputSet, int phaseNumber, - ResourceManager resourceManager) async { + /// Lazily builds any optional build actions that might potentially produce + /// a primary input to [action]. + Future> _matchingPrimaryInputs(AssetBuildAction action, + int phaseNumber, ResourceManager resourceManager) async { var ids = new Set(); + var inputSet = action.inputSet; + var builder = action.builder; await Future .wait(_assetGraph.packageNodes(inputSet.package).map((node) async { if (node is SyntheticAssetNode) return; if (!inputSet.matches(node.id)) return; + if (!builder.buildExtensions.keys + .any((inputExtension) => node.id.path.endsWith(inputExtension))) { + return; + } if (node is GeneratedAssetNode) { if (node.phaseNumber >= phaseNumber) return; if (node.needsUpdate) { @@ -336,14 +346,20 @@ class BuildImpl { .where((id) => !inputNode.primaryOutputs.contains(id)) .join(', ')); - if (!_buildShouldRun(builderOutputs)) return []; - var wrappedReader = new SingleStepReader( _reader, _assetGraph, phaseNumber, input.package, (phase, input) => _runLazyPhaseForInput(phase, input, resourceManager)); + + if (!await _buildShouldRun(builderOutputs, wrappedReader)) { + return []; + } + // We may have read some inputs in the call to `_buildShouldRun`, we want + // to remove those. + wrappedReader.assetsRead.clear(); + var wrappedWriter = new AssetWriterSpy(_writer); var logger = new Logger('$builder on $input'); await runBuilder(builder, [input], wrappedReader, wrappedWriter, _resolvers, @@ -365,14 +381,20 @@ class BuildImpl { PackageBuilder builder, ResourceManager resourceManager) async { var builderOutputs = outputIdsForBuilder(builder, package); - if (!_buildShouldRun(builderOutputs)) return []; - var wrappedReader = new SingleStepReader( _reader, _assetGraph, phaseNumber, package, (phase, input) => _runLazyPhaseForInput(phase, input, resourceManager)); + + if (!await _buildShouldRun(builderOutputs, wrappedReader)) { + return []; + } + // We may have read some inputs in the call to `_buildShouldRun`, we want + // to remove those. + wrappedReader.assetsRead.clear(); + var wrappedWriter = new AssetWriterSpy(_writer); var logger = new Logger('$builder on $package'); @@ -396,15 +418,64 @@ class BuildImpl { } /// Checks and returns whether any [outputs] need to be updated. - bool _buildShouldRun(Iterable outputs) { + Future _buildShouldRun( + Iterable outputs, DigestAssetReader reader) async { assert( outputs.every((o) => _assetGraph.contains(o)), 'Outputs should be known statically. Missing ' '${outputs.where((o) => !_assetGraph.contains(o)).toList()}'); + assert(outputs.length >= 1, 'Can\'t run a build with zero outputs'); + var firstOutput = outputs.first; + var node = _assetGraph.get(firstOutput) as GeneratedAssetNode; + assert( + outputs.skip(1).every((output) => + (_assetGraph.get(output) as GeneratedAssetNode) + .inputs + .difference(node.inputs) + .isEmpty), + 'All outputs of a build action should share the same inputs.'); + + // We only check the first output, because all outputs share the same inputs + // and invalidation state. + if (!node.needsUpdate) return false; + if (node.previousInputsDigest == null) { + return true; + } + var digest = await _computeCombinedDigest(node.inputs, reader); + if (digest != node.previousInputsDigest) { + return true; + } else { + // Make sure to update the `needsUpdate` field for all outputs. + for (var id in outputs) { + (_assetGraph.get(id) as GeneratedAssetNode).needsUpdate = false; + } + return false; + } + } - // A build should be ran if any output needs updating - return outputs.any((output) => - (_assetGraph.get(output) as GeneratedAssetNode).needsUpdate); + /// Computes a single [Digest] based on the combined [Digest]s of [ids]. + Future _computeCombinedDigest( + Iterable ids, DigestAssetReader reader) async { + var digestSink = new AccumulatorSink(); + var bytesSink = md5.startChunkedConversion(digestSink); + + for (var id in ids) { + var node = _assetGraph.get(id); + if (node is SyntheticAssetNode || !await reader.canRead(id)) { + // We want to add something here, a missing/unreadable input should be + // different from no input at all. + bytesSink.add([1]); + continue; + } + if (node.lastKnownDigest == null) { + node.lastKnownDigest = await reader.digest(id); + } + bytesSink.add(node.lastKnownDigest.bytes); + } + + bytesSink.close(); + assert(digestSink.events.length == 1); + return digestSink.events.first; } /// Sets the state for all [declaredOutputs] of a build step, by: @@ -414,13 +485,12 @@ class BuildImpl { /// - Setting `globs` on each output based on `reader.globsRan` /// - Adding `declaredOutputs` as outputs to all `reader.assetsRead`. /// - Setting the `lastKnownDigest` on each output based on the new contents. + /// - Setting the `previousInputsDigest` on each output based on the inputs. Future _setOutputsState(Iterable declaredOutputs, SingleStepReader reader, AssetWriterSpy writer) async { - // Reset the state for each output, setting `wasOutput` to false for now - // (will set to true in the next loop for written assets). - // - // Also updates the `inputs` set for each output, and the `outputs` sets for - // all inputs. + // All inputs are the same, so we only compute this once, but lazily. + Digest inputsDigest; + for (var output in declaredOutputs) { var node = _assetGraph.get(output) as GeneratedAssetNode; node @@ -429,30 +499,14 @@ class BuildImpl { ..lastKnownDigest = null ..globs = reader.globsRan.toSet(); - // Update dependencies that don't exist any more. - var removedInputs = node.inputs.difference(reader.assetsRead); - node.inputs.removeAll(removedInputs); - for (var input in removedInputs) { - // TODO: special type of dependency here? This means the primary input - // was never actually read. - if (input == node.primaryInput) continue; - - var inputNode = _assetGraph.get(input); - assert(inputNode != null, 'Asset Graph is missing $input'); - inputNode.outputs.remove(output); - } + _removeOldInputs(node, reader.assetsRead); + _addNewInputs(node, reader.assetsRead); - // Add the new dependencies. - var newInputs = reader.assetsRead.difference(node.inputs); - node.inputs.addAll(newInputs); - for (var input in newInputs) { - var inputNode = _assetGraph.get(input); - assert(inputNode != null, 'Asset Graph is missing $input'); - inputNode.outputs.add(output); - } + node.previousInputsDigest = + inputsDigest ??= await _computeCombinedDigest(node.inputs, reader); } - // Mark the actual outputs as output. + // Mark the actual outputs as output, and update their digests. await Future.wait(writer.assetsWritten.map((output) async { (_assetGraph.get(output) as GeneratedAssetNode) ..wasOutput = true @@ -460,6 +514,34 @@ class BuildImpl { })); } + /// Removes old inputs from [node] based on [updatedInputs], and cleans up all + /// the old edges. + void _removeOldInputs(GeneratedAssetNode node, Set updatedInputs) { + var removedInputs = node.inputs.difference(updatedInputs); + node.inputs.removeAll(removedInputs); + for (var input in removedInputs) { + // TODO: special type of dependency here? This means the primary input + // was never actually read. + if (input == node.primaryInput) continue; + + var inputNode = _assetGraph.get(input); + assert(inputNode != null, 'Asset Graph is missing $input'); + inputNode.outputs.remove(node.id); + } + } + + /// Adds new inputs to [node] based on [updatedInputs], and adds the + /// appropriate edges. + void _addNewInputs(GeneratedAssetNode node, Set updatedInputs) { + var newInputs = updatedInputs.difference(node.inputs); + node.inputs.addAll(newInputs); + for (var input in newInputs) { + var inputNode = _assetGraph.get(input); + assert(inputNode != null, 'Asset Graph is missing $input'); + inputNode.outputs.add(node.id); + } + } + Future _delete(AssetId id) { _onDelete?.call(id); return _writer.delete(id); diff --git a/build_runner/test/asset_graph/graph_test.dart b/build_runner/test/asset_graph/graph_test.dart index 59df00f4f..7515b2012 100644 --- a/build_runner/test/asset_graph/graph_test.dart +++ b/build_runner/test/asset_graph/graph_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:build/build.dart'; +import 'package:crypto/crypto.dart'; import 'package:test/test.dart'; import 'package:watcher/watcher.dart'; @@ -89,6 +90,12 @@ void main() { syntheticNode.outputs.add(generatedNode.id); generatedNode.inputs.addAll([node.id, syntheticNode.id]); + if (g % 2 == 1) { + // Fake a digest using the id, we just care that this gets + // serialized/deserialized properly. + generatedNode.previousInputsDigest = + md5.convert(UTF8.encode(generatedNode.id.toString())); + } graph.add(syntheticNode); graph.add(generatedNode); @@ -175,7 +182,11 @@ void main() { (id) async => deletes.add(id), digestReader); expect(graph.contains(primaryInputId), isTrue); expect(graph.contains(primaryOutputId), isTrue); - expect(deletes, equals([primaryOutputId])); + // We don't pre-emptively delete the file in the case of modifications + expect(deletes, equals([])); + var outputNode = graph.get(primaryOutputId) as GeneratedAssetNode; + // But we should mark it as needing an update + expect(outputNode.needsUpdate, isTrue); }); test('add new primary input which replaces a synthetic node', () async { diff --git a/build_runner/test/common/matchers.dart b/build_runner/test/common/matchers.dart index 6566679c9..dcefd47b9 100644 --- a/build_runner/test/common/matchers.dart +++ b/build_runner/test/common/matchers.dart @@ -92,6 +92,12 @@ class _AssetGraphMatcher extends Matcher { ]; matches = false; } + if (node.previousInputsDigest != expectedNode.previousInputsDigest) { + matchState['previousInputDigest of ${node.id}'] = [ + node.previousInputsDigest, + expectedNode.previousInputsDigest + ]; + } } else { matchState['Type of ${node.id}'] = [ node.runtimeType, diff --git a/build_runner/test/generate/build_test.dart b/build_runner/test/generate/build_test.dart index 3ae033c84..983f4626a 100644 --- a/build_runner/test/generate/build_test.dart +++ b/build_runner/test/generate/build_test.dart @@ -657,5 +657,46 @@ void main() { expect(bTxtNode.outputs, isEmpty); expect(cTxtNode.outputs, unorderedEquals([outputNode.id])); }); + + test('Ouputs aren\'t rebuilt if their inputs didn\'t change', () async { + var buildActions = [ + new BuildAction( + new CopyBuilder(copyFromAsset: new AssetId('a', 'lib/b.txt')), 'a', + inputs: ['lib/a.txt']), + new BuildAction(new CopyBuilder(), 'a', inputs: ['lib/a.txt.copy']), + ]; + + // Initial build. + var writer = new InMemoryRunnerAssetWriter(); + await testActions( + buildActions, + { + 'a|lib/a.txt': 'a', + 'a|lib/b.txt': 'b', + }, + outputs: { + 'a|lib/a.txt.copy': 'b', + 'a|lib/a.txt.copy.copy': 'b', + }, + writer: writer); + + // Modify the primary input of `a.txt.copy`, but its output doesn't change + // so `a.txt.copy.copy` shouldn't be rebuilt. + var serializedGraph = writer.assets[makeAssetId('a|$assetGraphPath')]; + writer.assets.clear(); + await testActions( + buildActions, + { + 'a|lib/a.txt': 'a2', + 'a|lib/b.txt': 'b', + 'a|lib/a.txt.copy': 'b', + 'a|lib/a.txt.copy.copy': 'b', + 'a|$assetGraphPath': serializedGraph, + }, + outputs: { + 'a|lib/a.txt.copy': 'b', + }, + writer: writer); + }); }); } diff --git a/build_runner/test/generate/watch_test.dart b/build_runner/test/generate/watch_test.dart index 4bdbc3eda..2df27b835 100644 --- a/build_runner/test/generate/watch_test.dart +++ b/build_runner/test/generate/watch_test.dart @@ -331,11 +331,11 @@ void main() { ChangeType.REMOVE, path.absolute('a', 'web', 'a.txt.copy'))); result = await results.next; - // Should rebuild the generated asset and its outputs. + // Should rebuild the generated asset, but not its outputs because its + // content didn't change. checkBuild(result, outputs: { 'a|web/a.txt.copy': 'a', - 'a|web/a.txt.copy.copy': 'a', }, writer: writer); });