From 03247160c4126f203567dfbca5ef4511683539d4 Mon Sep 17 00:00:00 2001 From: d-markey Date: Sat, 20 Sep 2025 22:39:58 +0200 Subject: [PATCH 1/3] Support `BuilderOptions` & `BuildStep` with multiple outputs. - Add support for `BuilderOptions` via a custom `TestBuildStep` implementation (handles patterns in `build_extensions` + multiple outputs). `TestBuildStep` stored generated code in memory. - Implement `generateForLibrary()` to generate all outputs for the specified library (includes support for generating the golden files). Clients can inspect build outputs via the returned `TestBuildResults`. - Normalize line-endings and paths for better support of Windows platforms. --- CHANGELOG.md | 6 + lib/source_gen_test.dart | 3 + lib/src/_expected_outputs.dart | 225 ++++++++++++++++++++++++++++ lib/src/generate_for_element.dart | 13 +- lib/src/generate_for_library.dart | 43 ++++++ lib/src/goldens.dart | 5 + lib/src/test_annotated_classes.dart | 51 ++++--- lib/src/test_build_result.dart | 20 +++ lib/src/test_build_step.dart | 96 ++++++++++++ lib/src/utils.dart | 28 ++++ pubspec.yaml | 9 +- test/src/test_library.dart | 2 +- test/test_build_step_test.dart | 215 ++++++++++++++++++++++++++ 13 files changed, 684 insertions(+), 32 deletions(-) create mode 100644 lib/src/_expected_outputs.dart create mode 100644 lib/src/generate_for_library.dart create mode 100644 lib/src/goldens.dart create mode 100644 lib/src/test_build_result.dart create mode 100644 lib/src/test_build_step.dart create mode 100644 lib/src/utils.dart create mode 100644 test/test_build_step_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db06ab..300b7c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.3.3-genLib + +- Add support for `BuilderOptions` via a custom `TestBuildStep` implementation (handle patterns in extensions and multiple outputs). `TestBuildStep` keeps generated code in memory. +- Implement `generateForLibrary()` to generate all outputs for the specified library (with support for generating the golden files). Clients can inspect build outputs via the returned `TestBuildResults`. +- Normalize line-endings and paths for better support of Windows/MacOS platforms. + ## 1.3.2 - Allow `build: '>=3.0.0 <5.0.0'`. diff --git a/lib/source_gen_test.dart b/lib/source_gen_test.dart index f30c552..bf24530 100644 --- a/lib/source_gen_test.dart +++ b/lib/source_gen_test.dart @@ -2,7 +2,10 @@ export 'annotations.dart' show ShouldGenerate, ShouldGenerateFile, ShouldThrow; export 'src/build_log_tracking.dart' show buildLogItems, clearBuildLog, initializeBuildLogTracking; export 'src/generate_for_element.dart' show generateForElement; +export 'src/generate_for_library.dart' show generateForLibrary; export 'src/init_library_reader.dart' show initializeLibraryReader, initializeLibraryReaderForDirectory; export 'src/matchers.dart' show throwsInvalidGenerationSourceError; export 'src/test_annotated_classes.dart' show testAnnotatedElements; +export 'src/test_build_result.dart' show TestBuildResult; +export 'src/test_build_step.dart' show TestBuildStep; diff --git a/lib/src/_expected_outputs.dart b/lib/src/_expected_outputs.dart new file mode 100644 index 0000000..5517f1e --- /dev/null +++ b/lib/src/_expected_outputs.dart @@ -0,0 +1,225 @@ +// Based on / copied from build-4.0.0\lib\src\expected_outputs.dart + +import 'package:build/build.dart'; + +// Regexp for capture groups. +// ignore: use_raw_strings +final RegExp _captureGroupRegexp = RegExp('{{(\\w*)}}'); + +abstract class ParsedBuildOutputs { + ParsedBuildOutputs._(); + + factory ParsedBuildOutputs.parse(String input, List outputs) { + final matches = _captureGroupRegexp.allMatches(input).toList(); + if (matches.isNotEmpty) { + return _CapturingBuildOutputs.parse(input, outputs, matches); + } + + // Make sure that no outputs use capture groups, if they aren't used in + // inputs. + for (final output in outputs) { + if (_captureGroupRegexp.hasMatch(output)) { + throw ArgumentError( + 'Output "$output" is using a capture group, but input "$input" does ' + 'not use a capture group: this is forbidden.', + ); + } + } + + if (input.startsWith('^')) { + return _FullMatchBuildOutputs(input.substring(1), outputs); + } else { + return _SuffixBuildOutputs(input, outputs); + } + } + + bool hasAnyOutputFor(AssetId input); + Iterable matchingOutputsFor(AssetId input); +} + +extension on AssetId { + /// Replaces the last [suffixLength] characters with [newSuffix]. + AssetId replaceSuffix(int suffixLength, String newSuffix) => AssetId( + package, + path.substring(0, path.length - suffixLength) + newSuffix, + ); +} + +/// A simple build input/output set that matches an entire path and doesn't use +/// capture groups. +class _FullMatchBuildOutputs extends ParsedBuildOutputs { + final String inputExtension; + final List outputExtensions; + + _FullMatchBuildOutputs(this.inputExtension, this.outputExtensions) + : super._(); + + @override + bool hasAnyOutputFor(AssetId input) => input.path == inputExtension; + + @override + Iterable matchingOutputsFor(AssetId input) { + if (!hasAnyOutputFor(input)) return const Iterable.empty(); + + // If we expect an output, the asset's path ends with the input extension. + // Expected outputs just replace the matched suffix in the path. + return outputExtensions.map( + (extension) => AssetId(input.package, extension), + ); + } +} + +/// A simple build input/output set which matches file suffixes and doesn't use +/// capture groups. +class _SuffixBuildOutputs extends ParsedBuildOutputs { + final String inputExtension; + final List outputExtensions; + + _SuffixBuildOutputs(this.inputExtension, this.outputExtensions) : super._(); + + @override + bool hasAnyOutputFor(AssetId input) => input.path.endsWith(inputExtension); + + @override + Iterable matchingOutputsFor(AssetId input) { + if (!hasAnyOutputFor(input)) return const Iterable.empty(); + + // If we expect an output, the asset's path ends with the input extension. + // Expected outputs just replace the matched suffix in the path. + return outputExtensions.map( + (extension) => input.replaceSuffix(inputExtension.length, extension), + ); + } +} + +/// A build input with a capture group `{{}}` that's referenced in the outputs. +class _CapturingBuildOutputs extends ParsedBuildOutputs { + final RegExp _pathMatcher; + + /// The names of all capture groups used in the inputs. + /// + /// The [_pathMatcher] will always match the same amount of groups in the + /// same order. + final List _groupNames; + final List _outputs; + + _CapturingBuildOutputs(this._pathMatcher, this._groupNames, this._outputs) + : super._(); + + factory _CapturingBuildOutputs.parse( + String input, + List outputs, + List matches, + ) { + final regexBuffer = StringBuffer(); + var positionInInput = 0; + if (input.startsWith('^')) { + regexBuffer.write('^'); + positionInInput = 1; + } + + // Builders can have multiple capture groups, which are disambiguated by + // their name. Names can be empty as well: `{{}}` is a valid capture group. + final names = []; + + for (final match in matches) { + final name = match.group(1)!; + if (names.contains(name)) { + throw ArgumentError( + 'Input "$input" contains multiple capture groups with the same name ' + '(`{{$name}}`): this is not allowed.', + ); + } + names.add(name); + + // Write the input regex from the last position up until the start of + // this capture group. + assert(positionInInput <= match.start); + regexBuffer + ..write(RegExp.escape(input.substring(positionInInput, match.start))) + // Introduce the capture group. + ..write('(.+)'); + positionInInput = match.end; + } + + // Write the input part after the last capture group. + regexBuffer + ..write(RegExp.escape(input.substring(positionInInput))) + // This is a build extension, so we're always matching suffixes. + ..write(r'$'); + + // When using a capture group in the build input, it must also be used in + // every output to ensure outputs have unique names. + // Also, an output must not refer to capture groups that aren't included in + // the input. + for (final output in outputs) { + final remainingNames = names.toSet(); + + // Ensure that the output extension does not refer to unknown groups, and + // that no group appears in the output multiple times. + for (final outputMatch in _captureGroupRegexp.allMatches(output)) { + final outputName = outputMatch.group(1)!; + if (!remainingNames.remove(outputName)) { + throw ArgumentError( + 'Output "$output" uses the capture group "$outputName", but this ' + 'group does not exist or has been referenced multiple times: this ' + 'is not allowed.', + ); + } + } + + // Finally, ensure that each capture group from the input appears in this + // output. + if (remainingNames.isNotEmpty) { + throw ArgumentError( + 'Input "$input" is using a capture group but at least one of its ' + 'outputs does not refer to that capture group exactly once. The ' + 'following capture groups are not referenced: ' + '${remainingNames.join(', ')}.', + ); + } + } + + return _CapturingBuildOutputs( + RegExp(regexBuffer.toString()), + names, + outputs, + ); + } + + @override + bool hasAnyOutputFor(AssetId input) => _pathMatcher.hasMatch(input.path); + + @override + Iterable matchingOutputsFor(AssetId input) { + // There may be multiple matches when a capture group appears at the + // beginning or end of an input string. We always want a group to match as + // much as possible, so we use the first match. + final match = _pathMatcher.firstMatch(input.path); + if (match == null) { + // The build input doesn't match the input asset, so the builder shouldn't + // run and no outputs are expected. + return const Iterable.empty(); + } + + final lengthOfMatch = match.end - match.start; + + return _outputs.map((output) { + final resolvedOutput = output.replaceAllMapped(_captureGroupRegexp, ( + outputMatch, + ) { + final name = outputMatch.group(1)!; + final index = _groupNames.indexOf(name); + assert( + !index.isNegative, + 'Output refers to a group not declared in the input extension. ' + 'Validation was supposed to catch that.', + ); + + // Regex group indices start at 1. + return match.group(index + 1)!; + }); + return input.replaceSuffix(lengthOfMatch, resolvedOutput); + }); + } +} diff --git a/lib/src/generate_for_element.dart b/lib/src/generate_for_element.dart index 98d1cde..5d08d5a 100644 --- a/lib/src/generate_for_element.dart +++ b/lib/src/generate_for_element.dart @@ -11,14 +11,16 @@ import 'package:source_gen/src/output_helpers.dart' show normalizeGeneratorOutput; import 'init_library_reader.dart' show testPackageName; +import 'test_build_step.dart'; final _testAnnotationWarnings = {}; Future generateForElement( GeneratorForAnnotation generator, LibraryReader libraryReader, - String name, -) async { + String name, [ + BuildStep? buildStep, +]) async { final elements = libraryReader.allElements.where((e) => e.name3 == name).toList(); @@ -87,7 +89,7 @@ Future generateForElement( generator.generateForAnnotatedElement( element, ConstantReader(annotation), - _MockBuildStep(), + buildStep ?? MockBuildStep(), ), ); @@ -99,8 +101,3 @@ Future generateForElement( return formatter.format(generated); } - -class _MockBuildStep extends BuildStep { - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} diff --git a/lib/src/generate_for_library.dart b/lib/src/generate_for_library.dart new file mode 100644 index 0000000..b260753 --- /dev/null +++ b/lib/src/generate_for_library.dart @@ -0,0 +1,43 @@ +import 'package:build/build.dart'; +import 'package:source_gen/source_gen.dart'; + +import 'goldens.dart'; +import 'init_library_reader.dart'; +import 'test_build_result.dart'; +import 'test_build_step.dart'; + +Future generateForLibrary( + GeneratorForAnnotation generator, + LibraryReader libraryReader, [ + BuildStep? buildStep, +]) async { + buildStep ??= MockBuildStep(); + final generatedCode = await generator.generate(libraryReader, buildStep); + + // set generated code for the main output asset + if (buildStep is! MockBuildStep) { + await buildStep.writeAsString( + buildStep.allowedOutputs.first, + generatedCode, + ); + } + + if (updateGoldens) { + final step = _check(buildStep, 'buildStep'); + final reader = _check( + libraryReader, + 'libraryReader', + ); + await step.saveGoldens(reader.directory); + } + + return TestBuildResult.from(buildStep); +} + +T _check(Object? instance, String name) { + if (instance is T) return instance; + throw InvalidGenerationSourceError( + 'To create or update all golden files, $name must be an instance of $T ' + 'it is currently an instance of ${instance.runtimeType}.', + ); +} diff --git a/lib/src/goldens.dart b/lib/src/goldens.dart new file mode 100644 index 0000000..d067a58 --- /dev/null +++ b/lib/src/goldens.dart @@ -0,0 +1,5 @@ +import 'dart:io'; + +const updateGoldensVariable = 'SOURCE_GEN_TEST_UPDATE_GOLDENS'; + +bool get updateGoldens => Platform.environment[updateGoldensVariable] == '1'; diff --git a/lib/src/test_annotated_classes.dart b/lib/src/test_annotated_classes.dart index ea45533..25e724c 100644 --- a/lib/src/test_annotated_classes.dart +++ b/lib/src/test_annotated_classes.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:analyzer/dart/element/element2.dart'; +import 'package:build/build.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:source_gen/source_gen.dart'; @@ -11,11 +12,13 @@ import 'annotations.dart'; import 'build_log_tracking.dart'; import 'expectation_element.dart'; import 'generate_for_element.dart'; +import 'goldens.dart'; import 'init_library_reader.dart'; import 'matchers.dart'; +import 'test_build_step.dart'; +import 'utils.dart'; const _defaultConfigurationName = 'default'; -const _updateGoldensVariable = 'SOURCE_GEN_TEST_UPDATE_GOLDENS'; /// If [defaultConfiguration] is not provided or `null`, "default" and the keys /// from [additionalGenerators] (if provided) are used. @@ -33,6 +36,7 @@ void testAnnotatedElements( Map>? additionalGenerators, Iterable? expectedAnnotatedTests, Iterable? defaultConfiguration, + BuilderOptions? options, }) { for (var entry in getAnnotatedClasses( libraryReader, @@ -40,6 +44,7 @@ void testAnnotatedElements( additionalGenerators: additionalGenerators, expectedAnnotatedTests: expectedAnnotatedTests, defaultConfiguration: defaultConfiguration, + options: options, )) { entry._registerTest(); } @@ -54,6 +59,7 @@ List> getAnnotatedClasses( Map>? additionalGenerators, Iterable? expectedAnnotatedTests, Iterable? defaultConfiguration, + BuilderOptions? options, }) { final generators = >{ _defaultConfigurationName: defaultGenerator, @@ -181,6 +187,7 @@ List> getAnnotatedClasses( configuration, entry.elementName, entry.expectation, + options, ), ); } @@ -216,6 +223,7 @@ class AnnotatedTest { final LibraryReader _libraryReader; final TestExpectation expectation; final String _elementName; + final BuilderOptions? _options; String get _testName { var value = _elementName; @@ -231,6 +239,7 @@ class AnnotatedTest { this.configuration, this._elementName, this.expectation, + this._options, ); void _registerTest() { @@ -247,19 +256,19 @@ class AnnotatedTest { throw StateError('Should never get here.'); } - Future _generate() => - generateForElement(generator, _libraryReader, _elementName); + Future _generate([BuildStep? buildStep]) => + generateForElement(generator, _libraryReader, _elementName, buildStep); Future _shouldGenerateTest() async { - final output = await _generate(); + final output = normalizeLineEndings(await _generate()); final exp = expectation as ShouldGenerate; + final expectedOutput = normalizeLineEndings(exp.expectedOutput); + try { expect( output, - exp.contains - ? contains(exp.expectedOutput) - : equals(exp.expectedOutput), + exp.contains ? contains(expectedOutput) : equals(expectedOutput), ); } on TestFailure { printOnFailure("ACTUAL CONTENT:\nr'''\n$output'''"); @@ -275,26 +284,28 @@ class AnnotatedTest { } Future _shouldGenerateFileTest() async { - final output = await _generate(); - final exp = expectation as ShouldGenerateFile; - if (_libraryReader is! PathAwareLibraryReader) { throw TestFailure( 'Cannot run the test because _libraryReader does not contain ' - 'the directory information, and so the golden cannot be located. ' + 'the directory information, and so the golden files cannot be located. ' 'Use initializeLibraryReaderForDirectory() to automatically set it.', ); } + final buildStep = TestBuildStep(_libraryReader.fileName, _options); + + final output = await _generate(buildStep); + final exp = expectation as ShouldGenerateFile; + final reader = _libraryReader; final path = p.join(reader.directory, exp.expectedOutputFileName); - final testOutput = _padOutputForFile(output); + final testOutput = normalizeLineEndings(_padOutputForFile(output)); try { - if (Platform.environment[_updateGoldensVariable] == '1') { - File(path).writeAsStringSync(testOutput); + if (updateGoldens) { + await File(path).writeAsString(testOutput); } else { - final content = File(path).readAsStringSync(); + final content = normalizeLineEndings(await File(path).readAsString()); expect(testOutput, exp.contains ? contains(content) : equals(content)); } } on FileSystemException catch (ex) { @@ -303,14 +314,14 @@ class AnnotatedTest { 'Absolute path: ${Directory.current.path}/$path\n' '$ex\n\n' 'To create or update all golden files, set the environment variable ' - '$_updateGoldensVariable=1\n\n' + '$updateGoldensVariable=1\n\n' 'Make sure the directory exists and you can write the file in it.', ); } on TestFailure { printOnFailure("ACTUAL CONTENT:\nr'''\n$output'''"); printOnFailure( 'To update all golden files, set the environment variable ' - '$_updateGoldensVariable=1', + '$updateGoldensVariable=1', ); rethrow; } @@ -335,7 +346,11 @@ class AnnotatedTest { final outputDirectory = File(p.join(reader.directory, exp.expectedOutputFileName)).parent; - final path = p.relative(reader.path, from: outputDirectory.path); + final path = p + .relative(reader.path, from: outputDirectory.path) + .split(p.separator) + .join('/'); + return "part of '$path';\n\n$output"; } diff --git a/lib/src/test_build_result.dart b/lib/src/test_build_result.dart new file mode 100644 index 0000000..415c5b2 --- /dev/null +++ b/lib/src/test_build_result.dart @@ -0,0 +1,20 @@ +import 'package:build/build.dart'; + +class TestBuildResult { + TestBuildResult._(); + + final _outputs = {}; + + static Future from(BuildStep step) async { + final results = TestBuildResult._(); + for (var id in step.allowedOutputs) { + try { + final code = await step.readAsString(id); + results._outputs[id] = code; + } catch (_) {} + } + return results; + } + + String? getGeneratedContents(AssetId outputId) => _outputs[outputId]; +} diff --git a/lib/src/test_build_step.dart b/lib/src/test_build_step.dart new file mode 100644 index 0000000..8223aec --- /dev/null +++ b/lib/src/test_build_step.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:build/build.dart'; +import 'package:dart_style/dart_style.dart'; + +import '_expected_outputs.dart'; +import 'init_library_reader.dart'; +import 'utils.dart'; + +const _defaultBuildExtensions = { + '.dart': ['.g.dart'], +}; + +class TestBuildStep extends BuildStep { + TestBuildStep(String path, [BuilderOptions? options]) { + final buildExtensions = + (options?.config['build_extensions'] as Map>?) ?? + _defaultBuildExtensions; + + final expectedOutputs = + buildExtensions.entries + .map(($) => ParsedBuildOutputs.parse($.key, $.value)) + .toList(); + + path = normalizePath(path); + inputId = AssetId.parse('$testPackageName|$path'); + + final output = + expectedOutputs.where(($) => $.hasAnyOutputFor(inputId)).firstOrNull; + if (output == null) { + throw InvalidOutputException(inputId, 'no matching outputs'); + } + + _allowedOutputs.addAll(output.matchingOutputsFor(inputId)); + + // stderr.writeln( + // '\nINPUT ID = $inputId\nALLOWED OUTPUTS = $allowedOutputs\n', + // ); + } + + @override + late final AssetId inputId; + + final _generatedContents = {}; + final _allowedOutputs = []; + + @override + Iterable get allowedOutputs => _allowedOutputs.where((_) => true); + + @override + Future readAsString(AssetId id, {Encoding encoding = utf8}) async => + _generatedContents[id] ?? ''; + + @override + Future writeAsString( + AssetId id, + FutureOr contents, { + Encoding encoding = utf8, + }) async { + if (!allowedOutputs.contains(id)) { + throw InvalidOutputException(id, 'Invalid output'); + } + _generatedContents[id] = normalizeLineEndings(await contents); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class MockBuildStep extends BuildStep { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +extension GoldenExt on TestBuildStep { + Future> saveGoldens( + String directory, { + bool dryRun = false, + DartFormatter? formatter, + }) => Future.wait( + _generatedContents.entries.map((contents) async { + final filepath = normalizePath('$directory/${contents.key.path}'); + final file = File(filepath); + if (!dryRun) { + await file.parent.create(recursive: true); + await file.writeAsString( + formatter?.format(contents.value) ?? contents.value, + flush: true, + ); + } + return file.path; + }), + ); +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 0000000..2770102 --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,28 @@ +import 'package:build/build.dart'; + +String normalizeLineEndings(String code) => + code.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + +String normalizePath(String path) => + path.contains(r'\') ? path.split(r'\').join('/') : path; + +extension AssetIdRelPathExt on AssetId { + String getRelativePathFor(AssetId target) { + final targetSegments = target.pathSegments; + final currentSegments = pathSegments; + + while (targetSegments.isNotEmpty && + currentSegments.isNotEmpty && + targetSegments.first == currentSegments.first) { + targetSegments.removeAt(0); + currentSegments.removeAt(0); + } + + while (currentSegments.length > 1) { + targetSegments.insert(0, '..'); + currentSegments.removeAt(0); + } + + return targetSegments.join('/'); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 9ed7241..acb17fc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,21 +1,20 @@ name: source_gen_test -version: 1.3.2 +version: 1.3.3-genLib description: >- Test support for the source_gen package. Includes helpers to make it easy to validate both success and failure cases. repository: https://github.com/kevmoo/source_gen_test - environment: sdk: ^3.7.0 dependencies: - analyzer: '>=7.4.0 <9.0.0' - build: '>=3.0.0 <5.0.0' + analyzer: ">=7.4.0 <9.0.0" + build: ">=3.0.0 <5.0.0" build_test: ^3.3.0 dart_style: ^3.0.0 meta: ^1.15.0 path: ^1.9.0 - source_gen: '>=3.0.0 <5.0.0' + source_gen: ">=3.0.0 <5.0.0" test: ^1.25.9 dev_dependencies: diff --git a/test/src/test_library.dart b/test/src/test_library.dart index 8167672..63b8bf9 100644 --- a/test/src/test_library.dart +++ b/test/src/test_library.dart @@ -2,8 +2,8 @@ import 'package:source_gen_test/annotations.dart'; import 'test_annotation.dart'; -part 'test_part.dart'; part 'goldens/test_library_file_part_of_current.dart'; +part 'test_part.dart'; @ShouldGenerate( r''' diff --git a/test/test_build_step_test.dart b/test/test_build_step_test.dart new file mode 100644 index 0000000..f62fc2b --- /dev/null +++ b/test/test_build_step_test.dart @@ -0,0 +1,215 @@ +import 'package:build/build.dart'; +import 'package:source_gen_test/src/test_build_step.dart'; +import 'package:source_gen_test/src/utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('AssetId relative paths', () { + test('', () { + String $relPath(String ref, String target) => + AssetId('xxx', ref).getRelativePathFor(AssetId('xxx', target)); + + expect($relPath('f1.dart', 'f2.dart'), equals('f2.dart')); + expect($relPath('a/f1.dart', 'f2.dart'), equals('../f2.dart')); + expect($relPath('a/b/f1.dart', 'a/f2.dart'), equals('../f2.dart')); + expect($relPath('a/b/f1.dart', 'a/b/f2.dart'), equals('f2.dart')); + expect($relPath('a/b/f1.dart', 'c/f2.dart'), equals('../../c/f2.dart')); + expect($relPath('a/b/f1.dart', 'a/../f2.dart'), equals('../../f2.dart')); + }); + }); + + group('TestBuildStep', () { + test('inputId', () { + final buildStep = TestBuildStep('input_file.dart'); + expect(buildStep.inputId.path, equals('input_file.dart')); + }); + + group('allowedOutputs', () { + test('default build options', () { + final buildStep = TestBuildStep('input_file.dart'); + expect(buildStep.allowedOutputs.length, equals(1)); + expect( + buildStep.allowedOutputs.first.path, + equals('input_file.g.dart'), + ); + }); + + test('specific build options', () { + final extensions = { + '.dart': ['.custom.dart'], + }; + final buildStep = TestBuildStep( + 'input_file.dart', + BuilderOptions({'build_extensions': extensions}), + ); + expect(buildStep.allowedOutputs.length, equals(1)); + expect( + buildStep.allowedOutputs.first.path, + equals('input_file.custom.dart'), + ); + }); + + test('several outputs', () { + final extensions = { + '.dart': ['.g.dart', '.custom.g.dart'], + }; + final buildStep = TestBuildStep( + 'input_file.dart', + BuilderOptions({'build_extensions': extensions}), + ); + expect(buildStep.allowedOutputs.length, equals(2)); + expect( + buildStep.allowedOutputs.first.path, + equals('input_file.g.dart'), + ); + expect( + buildStep.allowedOutputs.last.path, + equals('input_file.custom.g.dart'), + ); + }); + + test('output with a simple capture group', () { + final extensions = { + '{{file}}.dart': ['goldens/{{file}}.g.dart'], + }; + final buildStep = TestBuildStep( + 'input_file.dart', + BuilderOptions({'build_extensions': extensions}), + ); + expect(buildStep.allowedOutputs.length, equals(1)); + expect( + buildStep.allowedOutputs.first.path, + equals('goldens/input_file.g.dart'), + ); + }); + + test('output with multiple capture groups', () { + final extensions = { + '{{path}}/{{file}}.dart': ['{{path}}/goldens/{{file}}.g.dart'], + }; + final buildStep = TestBuildStep( + 'dir/input_file.dart', + BuilderOptions({'build_extensions': extensions}), + ); + expect(buildStep.allowedOutputs.length, equals(1)); + expect( + buildStep.allowedOutputs.first.path, + equals('dir/goldens/input_file.g.dart'), + ); + }); + + test('output with multiple capture groups and relative path', () { + final extensions = { + '{{path}}/{{file}}.dart': ['{{path}}/../goldens/{{file}}.g.dart'], + }; + final buildStep1 = TestBuildStep( + 'dir/input_file.dart', + BuilderOptions({'build_extensions': extensions}), + ); + expect(buildStep1.allowedOutputs.length, equals(1)); + expect( + buildStep1.allowedOutputs.first.path, + equals('goldens/input_file.g.dart'), + ); + final buildStep2 = TestBuildStep( + 'dir/subdir/input_file.dart', + BuilderOptions({'build_extensions': extensions}), + ); + expect(buildStep2.allowedOutputs.length, equals(1)); + expect( + buildStep2.allowedOutputs.first.path, + equals('dir/goldens/input_file.g.dart'), + ); + }); + + test('no match', () { + expectLater( + () => TestBuildStep('image.png'), + throwsA( + isA().having( + (e) => e.message.toLowerCase(), + 'message', + contains('no matching outputs'), + ), + ), + ); + + expectLater( + () => TestBuildStep( + 'top_level.dart', + const BuilderOptions({ + 'build_extensions': { + '{{path}}/{{file}}.dart': ['{{path}}/goldens/{{file}}.g.dart'], + }, + }), + ), + throwsA( + isA().having( + (e) => e.message.toLowerCase(), + 'message', + contains('no matching outputs'), + ), + ), + ); + }); + }); + + group('saveGoldens', () { + const basePath = '/some/path'; + + test('default build options', () async { + final buildStep = TestBuildStep('dir/input_file.dart'); + await buildStep.writeAsString( + buildStep.allowedOutputs.first, + '// generated code', + ); + final paths = await buildStep.saveGoldens(basePath, dryRun: true); + expect(paths, equals(['$basePath/dir/input_file.g.dart'])); + }); + + test('custom build options', () async { + final buildStep = TestBuildStep( + 'dir/input_file.dart', + const BuilderOptions({ + 'build_extensions': { + '{{file}}.dart': ['{{file}}_test.g.dart', '{{file}}.g.dart'], + }, + }), + ); + await buildStep.writeAsString( + buildStep.allowedOutputs.first, + '// generated code', + ); + await buildStep.writeAsString( + buildStep.allowedOutputs.last, + '// generated code', + ); + final paths = await buildStep.saveGoldens(basePath, dryRun: true); + expect( + paths, + equals([ + '$basePath/dir/input_file_test.g.dart', + '$basePath/dir/input_file.g.dart', + ]), + ); + }); + + test('custom build options with partial outputs', () async { + final buildStep = TestBuildStep( + 'dir/input_file.dart', + const BuilderOptions({ + 'build_extensions': { + '{{file}}.dart': ['{{file}}_test.g.dart', '{{file}}.g.dart'], + }, + }), + ); + await buildStep.writeAsString( + buildStep.allowedOutputs.first, + '// generated code', + ); + final paths = await buildStep.saveGoldens(basePath, dryRun: true); + expect(paths, equals(['$basePath/dir/input_file_test.g.dart'])); + }); + }); + }); +} From f9063596c68104fffc3d7fe7a04f775e3d333b4e Mon Sep 17 00:00:00 2001 From: d-markey Date: Sat, 20 Sep 2025 22:49:05 +0200 Subject: [PATCH 2/3] ignore empty outputs --- lib/src/test_build_result.dart | 4 +++- lib/src/test_build_step.dart | 28 +++++++++++++++------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/lib/src/test_build_result.dart b/lib/src/test_build_result.dart index 415c5b2..a142b61 100644 --- a/lib/src/test_build_result.dart +++ b/lib/src/test_build_result.dart @@ -10,7 +10,9 @@ class TestBuildResult { for (var id in step.allowedOutputs) { try { final code = await step.readAsString(id); - results._outputs[id] = code; + if (code.isNotEmpty) { + results._outputs[id] = code; + } } catch (_) {} } return results; diff --git a/lib/src/test_build_step.dart b/lib/src/test_build_step.dart index 8223aec..053a01c 100644 --- a/lib/src/test_build_step.dart +++ b/lib/src/test_build_step.dart @@ -62,7 +62,7 @@ class TestBuildStep extends BuildStep { if (!allowedOutputs.contains(id)) { throw InvalidOutputException(id, 'Invalid output'); } - _generatedContents[id] = normalizeLineEndings(await contents); + _generatedContents[id] = normalizeLineEndings(await contents).trim(); } @override @@ -80,17 +80,19 @@ extension GoldenExt on TestBuildStep { bool dryRun = false, DartFormatter? formatter, }) => Future.wait( - _generatedContents.entries.map((contents) async { - final filepath = normalizePath('$directory/${contents.key.path}'); - final file = File(filepath); - if (!dryRun) { - await file.parent.create(recursive: true); - await file.writeAsString( - formatter?.format(contents.value) ?? contents.value, - flush: true, - ); - } - return file.path; - }), + _generatedContents.entries.where((content) => content.value.isNotEmpty).map( + (content) async { + final filepath = normalizePath('$directory/${content.key.path}'); + final file = File(filepath); + if (!dryRun) { + await file.parent.create(recursive: true); + await file.writeAsString( + formatter?.format(content.value) ?? content.value, + flush: true, + ); + } + return file.path; + }, + ), ); } From 119b2dcfef03fe7d76da12c14a4ff8f15f7ea70c Mon Sep 17 00:00:00 2001 From: d-markey Date: Sun, 21 Sep 2025 00:25:53 +0200 Subject: [PATCH 3/3] Expose asset IDs in `TestBuildResult` --- lib/src/test_build_result.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/src/test_build_result.dart b/lib/src/test_build_result.dart index a142b61..36d7bba 100644 --- a/lib/src/test_build_result.dart +++ b/lib/src/test_build_result.dart @@ -1,12 +1,13 @@ import 'package:build/build.dart'; class TestBuildResult { - TestBuildResult._(); + TestBuildResult._(this.inputId); + final AssetId inputId; final _outputs = {}; static Future from(BuildStep step) async { - final results = TestBuildResult._(); + final results = TestBuildResult._(step.inputId); for (var id in step.allowedOutputs) { try { final code = await step.readAsString(id); @@ -18,5 +19,7 @@ class TestBuildResult { return results; } + Iterable get outputs => _outputs.keys; + String? getGeneratedContents(AssetId outputId) => _outputs[outputId]; }