Skip to content

Add test for invalidation with resolved inputs. #3993

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ class SingleStepReaderWriter extends AssetReader
node = _runningBuild.assetGraph.get(id)!;
}
return PhasedValue.generated(
atPhase: phase,
atPhase: nodePhase,
before: '',
node.generatedNodeState!.isSuccessfulFreshOutput
? await _delegate.readAsString(id)
Expand Down
51 changes: 46 additions & 5 deletions build_runner_core/test/invalidation/invalidation_tester.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ class InvalidationTester {
/// The source assets on disk before the first build.
final Set<AssetId> _sourceAssets = {};

/// Import statements.
///
/// If an asset has an entry here then sources and generated sources will
/// start with the import statements specified.
final Map<String, List<String>> _importGraph = {};

/// The builders that will run.
final List<TestBuilder> _builders = [];

Expand All @@ -43,6 +49,9 @@ class InvalidationTester {
/// The build number for "printOnFailure" output.
int _buildNumber = 0;

/// Output number, for writing outputs that are different.
int _outputNumber = 0;

/// Sets the assets that will be on disk before the first build.
///
/// See the note on "names" in the class dartdoc.
Expand All @@ -53,6 +62,12 @@ class InvalidationTester {
}
}

// Sets the import graph for source files and generated files.
void importGraph(Map<String, List<String>> importGraph) {
_importGraph.clear();
_importGraph.addAll(importGraph);
}

/// Adds a builder to the test.
///
/// [from] and [to] are the input and output extension of the builder,
Expand Down Expand Up @@ -106,6 +121,13 @@ class InvalidationTester {
_failureStrategies[name.assetId] = FailureStrategy.succeed;
}

String _imports(AssetId id) {
final imports = _importGraph[_assetIdToName(id)];
return imports == null
? ''
: imports.map((i) => "import '${i.pathForImport}';").join('\n');
}

/// Does a build.
///
/// For the initial build, do not pass [change] [delete] or [create].
Expand All @@ -120,7 +142,7 @@ class InvalidationTester {
throw StateError('Do a build without change, delete or create first.');
}
for (final id in _sourceAssets) {
assets[id] = '// initial source';
assets[id] = '${_imports(id)}// initial source';
}
} else {
// Create the new filesystem from the previous build state.
Expand All @@ -131,7 +153,8 @@ class InvalidationTester {

// Make the requested updates.
if (change != null) {
assets[change.assetId] = '${assets[change.assetId]}\n// changed';
assets[change.assetId] =
'${_imports(change.assetId)}}\n// ${++_outputNumber}';
}
if (delete != null) {
if (assets.containsKey(delete.assetId)) {
Expand All @@ -148,7 +171,7 @@ class InvalidationTester {
if (assets.containsKey(create.assetId)) {
throw StateError('Asset $create to create already exists in: $assets');
}
assets[create.assetId] = '// initial source';
assets[create.assetId] = '${_imports(create.assetId)}// initial source';
}

// Build and check what changed.
Expand Down Expand Up @@ -259,6 +282,11 @@ class TestBuilderBuilder {
_builder.otherReads.add(name);
}

/// Test setup: the builder will parse the Dart source asset with [name].
void resolvesOther(String name) {
_builder.otherResolves.add(name);
}

/// Test setup: the builder will write the asset that is [extension] applied
/// to the primary input.
///
Expand Down Expand Up @@ -287,6 +315,9 @@ class TestBuilder implements Builder {
/// Names of assets that the builder will read.
List<String> otherReads = [];

/// Names of assets that the builder will resolve.
List<String> otherResolves = [];

/// Extensions of assets that the builder will write.
///
/// The extensions are applied to the primary input asset ID with
Expand All @@ -312,14 +343,21 @@ class TestBuilder implements Builder {
content.add(await buildStep.readAsString(read.assetId));
}
}
for (final resolve in otherResolves) {
content.add(resolve.assetId.toString());
await buildStep.resolver.libraryFor(resolve.assetId);
}
for (final write in writes) {
final writeId = buildStep.inputId.replaceAllPathExtensions(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 => '',
OutputStrategy.fixed => _tester._imports(writeId),
OutputStrategy.inputDigest =>
'// ${base64.encode(md5.convert(utf8.encode(content.join('\n\n'))).bytes)}',
'${_tester._imports(writeId)}\n// $inputHash',
OutputStrategy.none => null,
};
if (output != null) {
Expand Down Expand Up @@ -354,6 +392,9 @@ extension StringExtension on String {
AssetId get generatedAssetId =>
AssetId('pkg', '.dart_tool/build/generated/pkg/lib/$this.dart');

/// Maps "names" to relative import path.
String get pathForImport => '$this.dart';

/// Displays trimmed and with two space indent.
String get trimAndIndent => ' ${toString().trim().replaceAll('\n', '\n ')}';
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// 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';

/// In the test names:
///
/// - `a1 <== a2` means a1 is the primary input of a2 _and_ is an input:
/// the builder _does_ read a1
/// - dart source import graphs are introduced with "resolves", so
/// `a1 resolves: a2 --> a3 --> a4` means that the generation of a1
/// resolves a2, which imports a3, which imports a4
void main() {
late InvalidationTester tester;

setUp(() {
tester = InvalidationTester();
});

group('a.1 <== a.2, a.2 resolves: a.1 --> za --> zb', () {
setUp(() {
tester.sources(['a.1', 'za', 'zb']);
tester.importGraph({
'a.1': ['za'],
'za': ['zb'],
});
tester.builder(from: '.1', to: '.2')
..reads('.1')
..resolvesOther('a.1')
..writes('.2');
});

test('a.2 is built', () async {
expect(await tester.build(), Result(written: ['a.2']));
});

test('change za, a.2 is rebuilt', () async {
await tester.build();
expect(await tester.build(change: 'za'), Result(written: ['a.2']));
});

test('change zb, a.2 is rebuilt', () async {
await tester.build();
expect(await tester.build(change: 'zb'), Result(written: ['a.2']));
});
});

// Transitive dependencies via files generated in earlier phases.
group('a.1 <== a.2, a.3 <== a.4, a.5 <== a.6, '
'a.6 resolves: z -> a.4 --> a.2', () {
setUp(() {
tester.sources(['a.1', 'a.3', 'a.5', 'z']);
tester.importGraph({
'z': ['a.4'],
'a.4': ['a.2'],
});
tester.builder(from: '.1', to: '.2')
..reads('.1')
..writes('.2');
tester.builder(from: '.3', to: '.4')
..reads('.3')
..writes('.4');
tester.builder(from: '.5', to: '.6')
..reads('.5')
..resolvesOther('a.4')
..writes('.6');
});

test('a.6 is built', () async {
expect(await tester.build(), Result(written: ['a.2', 'a.4', 'a.6']));
});

test('change a.1, a.6 is rebuilt', () async {
expect(await tester.build(), Result(written: ['a.2', 'a.4', 'a.6']));
expect(
await tester.build(change: 'a.1'),
Result(written: ['a.2', 'a.6']),
);
});
});

// As previous group, but with builders in reverse order so the transitive
// import is not available and is not an input.
group('a.5 <== a.6, a.3 <== a.4, a.1 <== a.2, a.6 resolves: z -> a.4', () {
setUp(() {
tester.sources(['a.1', 'a.3', 'a.5', 'z']);
tester.importGraph({
'z': ['a.4'],
'a.4': ['a.2'],
});
tester.builder(from: '.5', to: '.6')
..reads('.5')
..resolvesOther('z')
..writes('.6');
tester.builder(from: '.3', to: '.4')
..reads('.3')
..writes('.4');
tester.builder(from: '.1', to: '.2')
..reads('.1')
..writes('.2');
});

test('a.6 is built', () async {
expect(await tester.build(), Result(written: ['a.2', 'a.4', 'a.6']));
});

test('change a.1, a.6 is not rebuilt', () async {
expect(await tester.build(), Result(written: ['a.2', 'a.4', 'a.6']));
expect(await tester.build(change: 'a.1'), Result(written: ['a.2']));
});
});
}
Loading