From 4e814a5f3cd421bf178e0e83fe9824b01e1eebd2 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Fri, 23 Feb 2024 14:48:08 -0800 Subject: [PATCH] Enable asset transformation for `flutter build` for iOS, Android, Windows, MacOS, Linux, and web (also `flutter run` without hot reload support) (#143815) See title. These are are the platforms that use the `CopyAssets` `Target` as part of their build target. Partial implementation of https://github.com/flutter/flutter/issues/143348. --- packages/flutter_tools/lib/src/asset.dart | 51 ++- .../flutter_tools/lib/src/base/utils.dart | 16 + .../lib/src/build_system/targets/assets.dart | 29 +- .../build_system/tools/asset_transformer.dart | 143 +++++++ .../lib/src/flutter_manifest.dart | 3 +- .../targets/asset_transformer_test.dart | 373 ++++++++++++++++++ .../build_system/targets/assets_test.dart | 158 +++++++- .../test/general.shard/devfs_test.dart | 4 + 8 files changed, 767 insertions(+), 10 deletions(-) create mode 100644 packages/flutter_tools/lib/src/build_system/tools/asset_transformer.dart create mode 100644 packages/flutter_tools/test/general.shard/build_system/targets/asset_transformer_test.dart diff --git a/packages/flutter_tools/lib/src/asset.dart b/packages/flutter_tools/lib/src/asset.dart index bc38ef84615..685a0a4e93c 100644 --- a/packages/flutter_tools/lib/src/asset.dart +++ b/packages/flutter_tools/lib/src/asset.dart @@ -88,10 +88,12 @@ enum AssetKind { final class AssetBundleEntry { const AssetBundleEntry(this.content, { required this.kind, + required this.transformers, }); final DevFSContent content; final AssetKind kind; + final List transformers; Future> contentsAsBytes() => content.contentsAsBytes(); } @@ -264,6 +266,7 @@ class ManifestAssetBundle implements AssetBundle { entries[_kAssetManifestJsonFilename] = AssetBundleEntry( DevFSStringContent('{}'), kind: AssetKind.regular, + transformers: const [], ); final ByteData emptyAssetManifest = const StandardMessageCodec().encodeMessage({})!; @@ -272,12 +275,14 @@ class ManifestAssetBundle implements AssetBundle { emptyAssetManifest.buffer.asUint8List(0, emptyAssetManifest.lengthInBytes), ), kind: AssetKind.regular, + transformers: const [], ); // Create .bin.json on web builds. if (targetPlatform == TargetPlatform.web_javascript) { entries[_kAssetManifestBinJsonFilename] = AssetBundleEntry( DevFSStringContent('""'), kind: AssetKind.regular, + transformers: const [], ); } return 0; @@ -423,6 +428,7 @@ class ManifestAssetBundle implements AssetBundle { entries[variant.entryUri.path] ??= AssetBundleEntry( DevFSFileContent(variantFile), kind: variant.kind, + transformers: variant.transformers, ); } } @@ -456,6 +462,7 @@ class ManifestAssetBundle implements AssetBundle { deferredComponentsEntries[componentName]![variant.entryUri.path] ??= AssetBundleEntry( DevFSFileContent(variantFile), kind: AssetKind.regular, + transformers: variant.transformers, ); } } @@ -471,7 +478,11 @@ class ManifestAssetBundle implements AssetBundle { for (final _Asset asset in materialAssets) { final File assetFile = asset.lookupAssetFile(_fileSystem); assert(assetFile.existsSync(), 'Missing ${assetFile.path}'); - entries[asset.entryUri.path] ??= AssetBundleEntry(DevFSFileContent(assetFile), kind: asset.kind); + entries[asset.entryUri.path] ??= AssetBundleEntry( + DevFSFileContent(assetFile), + kind: asset.kind, + transformers: const [], + ); } // Update wildcard directories we can detect changes in them. @@ -534,6 +545,7 @@ class ManifestAssetBundle implements AssetBundle { entries[key] = AssetBundleEntry( content, kind: assetKind, + transformers: const [], ); } @@ -579,6 +591,7 @@ class ManifestAssetBundle implements AssetBundle { hintString: 'copyrightsoftwaretothisinandorofthe', ), kind: AssetKind.regular, + transformers: const[], ); } } @@ -684,6 +697,8 @@ class ManifestAssetBundle implements AssetBundle { cache, componentAssets, assetsEntry.uri, + flavors: assetsEntry.flavors, + transformers: assetsEntry.transformers, ); } else { _parseAssetFromFile( @@ -693,6 +708,8 @@ class ManifestAssetBundle implements AssetBundle { cache, componentAssets, assetsEntry.uri, + flavors: assetsEntry.flavors, + transformers: assetsEntry.transformers, ); } } @@ -863,6 +880,7 @@ class ManifestAssetBundle implements AssetBundle { packageName: packageName, attributedPackage: attributedPackage, flavors: assetsEntry.flavors, + transformers: assetsEntry.transformers, ); } else { _parseAssetFromFile( @@ -875,6 +893,7 @@ class ManifestAssetBundle implements AssetBundle { packageName: packageName, attributedPackage: attributedPackage, flavors: assetsEntry.flavors, + transformers: assetsEntry.transformers, ); } } @@ -900,6 +919,8 @@ class ManifestAssetBundle implements AssetBundle { packageName: packageName, attributedPackage: attributedPackage, assetKind: AssetKind.shader, + flavors: {}, + transformers: [], ); } @@ -914,6 +935,8 @@ class ManifestAssetBundle implements AssetBundle { packageName: packageName, attributedPackage: attributedPackage, assetKind: AssetKind.model, + flavors: {}, + transformers: [], ); } @@ -927,6 +950,8 @@ class ManifestAssetBundle implements AssetBundle { packageName, attributedPackage, assetKind: AssetKind.font, + flavors: {}, + transformers: [], ); final File baseAssetFile = baseAsset.lookupAssetFile(_fileSystem); if (!baseAssetFile.existsSync()) { @@ -949,7 +974,8 @@ class ManifestAssetBundle implements AssetBundle { Uri assetUri, { String? packageName, Package? attributedPackage, - Set? flavors, + required Set flavors, + required List transformers, }) { final String directoryPath; try { @@ -985,6 +1011,7 @@ class ManifestAssetBundle implements AssetBundle { attributedPackage: attributedPackage, originUri: assetUri, flavors: flavors, + transformers: transformers, ); } } @@ -1000,7 +1027,8 @@ class ManifestAssetBundle implements AssetBundle { String? packageName, Package? attributedPackage, AssetKind assetKind = AssetKind.regular, - Set? flavors, + required Set flavors, + required List transformers, }) { final _Asset asset = _resolveAsset( packageConfig, @@ -1011,6 +1039,7 @@ class ManifestAssetBundle implements AssetBundle { assetKind: assetKind, originUri: originUri, flavors: flavors, + transformers: transformers, ); _checkForFlavorConflicts(asset, result.keys.toList()); @@ -1032,6 +1061,8 @@ class ManifestAssetBundle implements AssetBundle { relativeUri: relativeUri, package: attributedPackage, kind: assetKind, + flavors: flavors, + transformers: transformers, ), ); } @@ -1116,7 +1147,8 @@ class ManifestAssetBundle implements AssetBundle { Package? attributedPackage, { Uri? originUri, AssetKind assetKind = AssetKind.regular, - Set? flavors, + required Set flavors, + required List transformers, }) { final String assetPath = _fileSystem.path.fromUri(assetUri); if (assetUri.pathSegments.first == 'packages' @@ -1130,6 +1162,7 @@ class ManifestAssetBundle implements AssetBundle { assetKind: assetKind, originUri: originUri, flavors: flavors, + transformers: transformers, ); if (packageAsset != null) { return packageAsset; @@ -1146,6 +1179,7 @@ class ManifestAssetBundle implements AssetBundle { originUri: originUri, kind: assetKind, flavors: flavors, + transformers: transformers, ); } @@ -1156,6 +1190,7 @@ class ManifestAssetBundle implements AssetBundle { AssetKind assetKind = AssetKind.regular, Uri? originUri, Set? flavors, + List? transformers, }) { assert(assetUri.pathSegments.first == 'packages'); if (assetUri.pathSegments.length > 1) { @@ -1171,6 +1206,7 @@ class ManifestAssetBundle implements AssetBundle { kind: assetKind, originUri: originUri, flavors: flavors, + transformers: transformers, ); } } @@ -1193,7 +1229,10 @@ class _Asset { required this.package, this.kind = AssetKind.regular, Set? flavors, - }): originUri = originUri ?? entryUri, flavors = flavors ?? const {}; + List? transformers, + }) : originUri = originUri ?? entryUri, + flavors = flavors ?? const {}, + transformers = transformers ?? const []; final String baseDir; @@ -1214,6 +1253,8 @@ class _Asset { final Set flavors; + final List transformers; + File lookupAssetFile(FileSystem fileSystem) { return fileSystem.file(fileSystem.path.join(baseDir, fileSystem.path.fromUri(relativeUri))); } diff --git a/packages/flutter_tools/lib/src/base/utils.dart b/packages/flutter_tools/lib/src/base/utils.dart index ad522ddb036..cf49ca162ac 100644 --- a/packages/flutter_tools/lib/src/base/utils.dart +++ b/packages/flutter_tools/lib/src/base/utils.dart @@ -498,3 +498,19 @@ bool setEquals(Set? a, Set? b) { } return true; } + +/// Tests for shallow equality on two lists. +bool listEquals(List a, List b) { + if (identical(a, b)) { + return true; + } + if (a.length != b.length) { + return false; + } + for (int index = 0; index < a.length; index++) { + if (a[index] != b[index]) { + return false; + } + } + return true; +} diff --git a/packages/flutter_tools/lib/src/build_system/targets/assets.dart b/packages/flutter_tools/lib/src/build_system/targets/assets.dart index 14424b51b4b..795cbf77da7 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/assets.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/assets.dart @@ -4,14 +4,18 @@ import 'package:pool/pool.dart'; +import '../../artifacts.dart'; import '../../asset.dart'; +import '../../base/common.dart'; import '../../base/file_system.dart'; import '../../base/logger.dart'; import '../../build_info.dart'; import '../../convert.dart'; import '../../devfs.dart'; +import '../../flutter_manifest.dart'; import '../build_system.dart'; import '../depfile.dart'; +import '../tools/asset_transformer.dart'; import '../tools/scene_importer.dart'; import '../tools/shader_compiler.dart'; import 'common.dart'; @@ -93,19 +97,29 @@ Future copyAssets( fileSystem: environment.fileSystem, artifacts: environment.artifacts, ); + final AssetTransformer assetTransformer = AssetTransformer( + processManager: environment.processManager, + fileSystem: environment.fileSystem, + dartBinaryPath: environment.artifacts.getArtifactPath(Artifact.engineDartBinary), + ); final Map assetEntries = { ...assetBundle.entries, ...additionalContent.map((String key, DevFSContent value) { return MapEntry( key, - AssetBundleEntry(value, kind: AssetKind.regular), + AssetBundleEntry( + value, + kind: AssetKind.regular, + transformers: const [], + ), ); }), if (skslBundle != null) kSkSLShaderBundlePath: AssetBundleEntry( skslBundle, kind: AssetKind.regular, + transformers: const [], ), }; @@ -128,7 +142,18 @@ Future copyAssets( bool doCopy = true; switch (entry.value.kind) { case AssetKind.regular: - break; + if (entry.value.transformers.isNotEmpty) { + final AssetTransformationFailure? failure = await assetTransformer.transformAsset( + asset: content.file as File, + outputPath: file.path, + workingDirectory: environment.projectDir.path, + transformerEntries: entry.value.transformers, + ); + doCopy = false; + if (failure != null) { + throwToolExit(failure.message); + } + } case AssetKind.font: doCopy = !await iconTreeShaker.subsetFont( input: content.file as File, diff --git a/packages/flutter_tools/lib/src/build_system/tools/asset_transformer.dart b/packages/flutter_tools/lib/src/build_system/tools/asset_transformer.dart new file mode 100644 index 00000000000..c09d5484b3a --- /dev/null +++ b/packages/flutter_tools/lib/src/build_system/tools/asset_transformer.dart @@ -0,0 +1,143 @@ +// Copyright 2014 The Flutter Authors. 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:process/process.dart'; + +import '../../base/error_handling_io.dart'; +import '../../base/file_system.dart'; +import '../../base/io.dart'; +import '../../flutter_manifest.dart'; +import '../build_system.dart'; + +/// Applies a series of user-specified asset-transforming packages to an asset file. +final class AssetTransformer { + AssetTransformer({ + required ProcessManager processManager, + required FileSystem fileSystem, + required String dartBinaryPath, + }) : _processManager = processManager, + _fileSystem = fileSystem, + _dartBinaryPath = dartBinaryPath; + + final ProcessManager _processManager; + final FileSystem _fileSystem; + final String _dartBinaryPath; + + /// The [Source] inputs that targets using this should depend on. + /// + /// See [Target.inputs]. + static const List inputs = [ + Source.pattern( + '{FLUTTER_ROOT}/packages/flutter_tools/lib/src/build_system/targets/asset_transformer.dart', + ), + ]; + + /// Applies, in sequence, a list of transformers to an [asset] and then copies + /// the output to [outputPath]. + Future transformAsset({ + required File asset, + required String outputPath, + required String workingDirectory, + required List transformerEntries, + }) async { + + String getTempFilePath(int transformStep) { + final String basename = _fileSystem.path.basename(asset.path); + final String ext = _fileSystem.path.extension(asset.path); + return '$basename-transformOutput$transformStep$ext'; + } + + File tempInputFile = _fileSystem.systemTempDirectory.childFile(getTempFilePath(0)); + await asset.copy(tempInputFile.path); + File tempOutputFile = _fileSystem.systemTempDirectory.childFile(getTempFilePath(1)); + + try { + for (final (int i, AssetTransformerEntry transformer) in transformerEntries.indexed) { + final AssetTransformationFailure? transformerFailure = await _applyTransformer( + asset: tempInputFile, + output: tempOutputFile, + transformer: transformer, + workingDirectory: workingDirectory, + ); + + if (transformerFailure != null) { + return AssetTransformationFailure( + 'User-defined transformation of asset "${asset.path}" failed.\n' + '${transformerFailure.message}', + ); + } + + ErrorHandlingFileSystem.deleteIfExists(tempInputFile); + if (i == transformerEntries.length - 1) { + await tempOutputFile.copy(outputPath); + } else { + tempInputFile = tempOutputFile; + tempOutputFile = _fileSystem.systemTempDirectory.childFile(getTempFilePath(i+2)); + } + } + } finally { + ErrorHandlingFileSystem.deleteIfExists(tempInputFile); + ErrorHandlingFileSystem.deleteIfExists(tempOutputFile); + } + + return null; + } + + Future _applyTransformer({ + required File asset, + required File output, + required AssetTransformerEntry transformer, + required String workingDirectory, + }) async { + final List transformerArguments = [ + '--input=${asset.absolute.path}', + '--output=${output.absolute.path}', + ...?transformer.args, + ]; + + final List command = [ + _dartBinaryPath, + 'run', + transformer.package, + ...transformerArguments, + ]; + + final ProcessResult result = await _processManager.run( + command, + workingDirectory: workingDirectory, + ); + final String stdout = result.stdout as String; + final String stderr = result.stderr as String; + + if (result.exitCode != 0) { + return AssetTransformationFailure( + 'Transformer process terminated with non-zero exit code: ${result.exitCode}\n' + 'Transformer package: ${transformer.package}\n' + 'Full command: ${command.join(' ')}\n' + 'stdout:\n$stdout\n' + 'stderr:\n$stderr' + ); + } + + if (!_fileSystem.file(output).existsSync()) { + return AssetTransformationFailure( + 'Asset transformer ${transformer.package} did not produce an output file.\n' + 'Input file provided to transformer: "${asset.path}"\n' + 'Expected output file at: "${output.absolute.path}"\n' + 'Full command: ${command.join(' ')}\n' + 'stdout:\n$stdout\n' + 'stderr:\n$stderr', + ); + } + + return null; + } +} + +final class AssetTransformationFailure { + const AssetTransformationFailure(this.message); + + final String message; +} diff --git a/packages/flutter_tools/lib/src/flutter_manifest.dart b/packages/flutter_tools/lib/src/flutter_manifest.dart index d28b38349c1..736e00ba02e 100644 --- a/packages/flutter_tools/lib/src/flutter_manifest.dart +++ b/packages/flutter_tools/lib/src/flutter_manifest.dart @@ -835,10 +835,11 @@ class AssetsEntry { int get hashCode => Object.hashAll([ uri.hashCode, Object.hashAllUnordered(flavors), + Object.hashAll(transformers), ]); @override - String toString() => 'AssetsEntry(uri: $uri, flavors: $flavors)'; + String toString() => 'AssetsEntry(uri: $uri, flavors: $flavors, transformers: $transformers)'; } diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/asset_transformer_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/asset_transformer_test.dart new file mode 100644 index 00000000000..3308a13767a --- /dev/null +++ b/packages/flutter_tools/test/general.shard/build_system/targets/asset_transformer_test.dart @@ -0,0 +1,373 @@ +// Copyright 2014 The Flutter Authors. 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:args/args.dart'; +import 'package:file/memory.dart'; +import 'package:file_testing/file_testing.dart'; +import 'package:flutter_tools/src/artifacts.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/build_system/tools/asset_transformer.dart'; +import 'package:flutter_tools/src/flutter_manifest.dart'; + +import '../../../src/common.dart'; +import '../../../src/fake_process_manager.dart'; + +void main() { + testWithoutContext('Invokes dart properly', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final BufferLogger logger = BufferLogger.test(); + final Artifacts artifacts = Artifacts.test(); + + final File asset = fileSystem.file('asset.txt')..createSync()..writeAsStringSync('hello world'); + const String outputPath = 'output.txt'; + + final FakeProcessManager processManager = + FakeProcessManager.list([ + FakeCommand( + command: [ + artifacts.getArtifactPath(Artifact.engineDartBinary), + 'run', + 'my_copy_transformer', + '--input=/.tmp_rand0/asset.txt-transformOutput0.txt', + '--output=/.tmp_rand0/asset.txt-transformOutput1.txt', + '-f', + '--my_option', + 'my_option_value', + ], + onRun: (List args) { + final ArgResults parsedArgs = (ArgParser() + ..addOption('input') + ..addOption('output') + ..addFlag('foo', abbr: 'f') + ..addOption('my_option')) + .parse(args); + + fileSystem.file(parsedArgs['input']).copySync(parsedArgs['output'] as String); + }, + ), + ]); + + final AssetTransformer transformer = AssetTransformer( + processManager: processManager, + fileSystem: fileSystem, + dartBinaryPath: artifacts.getArtifactPath(Artifact.engineDartBinary), + ); + + final AssetTransformationFailure? transformationFailure = await transformer.transformAsset( + asset: asset, + outputPath: outputPath, + workingDirectory: fileSystem.currentDirectory.path, + transformerEntries: [ + const AssetTransformerEntry( + package: 'my_copy_transformer', + args: [ + '-f', + '--my_option', + 'my_option_value', + ], + ) + ], + ); + + expect(transformationFailure, isNull, reason: logger.errorText); + expect(processManager, hasNoRemainingExpectations); + expect(fileSystem.file(outputPath).readAsStringSync(), 'hello world'); + }); + + testWithoutContext('logs useful error information when transformation process returns a nonzero exit code', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Artifacts artifacts = Artifacts.test(); + + final File asset = fileSystem.file('asset.txt')..createSync(); + const String outputPath = 'output.txt'; + + final String dartBinaryPath = artifacts.getArtifactPath(Artifact.engineDartBinary); + final FakeProcessManager processManager = + FakeProcessManager.list([ + FakeCommand( + command: [ + dartBinaryPath, + 'run', + 'my_copy_transformer', + '--input=/.tmp_rand0/asset.txt-transformOutput0.txt', + '--output=/.tmp_rand0/asset.txt-transformOutput1.txt', + ], + onRun: (List args) { + final ArgResults parsedArgs = (ArgParser() + ..addOption('input') + ..addOption('output')) + .parse(args); + fileSystem.file(parsedArgs['input']).copySync(parsedArgs['output'] as String); + }, + exitCode: 1, + stdout: 'Beginning transformation', + stderr: 'Something went wrong', + ), + ]); + + final AssetTransformer transformer = AssetTransformer( + processManager: processManager, + fileSystem: fileSystem, + dartBinaryPath: dartBinaryPath, + ); + + final AssetTransformationFailure? failure = await transformer.transformAsset( + asset: asset, + outputPath: outputPath, + workingDirectory: fileSystem.currentDirectory.path, + transformerEntries: [ + const AssetTransformerEntry( + package: 'my_copy_transformer', + args: [], + ) + ], + ); + + expect(asset, exists); + expect(processManager, hasNoRemainingExpectations); + expect(failure, isNotNull); + expect(failure!.message, +''' +User-defined transformation of asset "asset.txt" failed. +Transformer process terminated with non-zero exit code: 1 +Transformer package: my_copy_transformer +Full command: $dartBinaryPath run my_copy_transformer --input=/.tmp_rand0/asset.txt-transformOutput0.txt --output=/.tmp_rand0/asset.txt-transformOutput1.txt +stdout: +Beginning transformation +stderr: +Something went wrong'''); + }); + + testWithoutContext('prints error message when the transformer does not produce an output file', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Artifacts artifacts = Artifacts.test(); + + final File asset = fileSystem.file('asset.txt')..createSync(); + const String outputPath = 'output.txt'; + + final String dartBinaryPath = artifacts.getArtifactPath(Artifact.engineDartBinary); + final FakeProcessManager processManager = + FakeProcessManager.list([ + FakeCommand( + command: [ + dartBinaryPath, + 'run', + 'my_transformer', + '--input=/.tmp_rand0/asset.txt-transformOutput0.txt', + '--output=/.tmp_rand0/asset.txt-transformOutput1.txt', + ], + onRun: (_) { + // Do nothing. + }, + stderr: 'Transformation failed, but I forgot to exit with a non-zero code.' + ), + ]); + + final AssetTransformer transformer = AssetTransformer( + processManager: processManager, + fileSystem: fileSystem, + dartBinaryPath: dartBinaryPath, + ); + + final AssetTransformationFailure? failure = await transformer.transformAsset( + asset: asset, + outputPath: outputPath, + workingDirectory: fileSystem.currentDirectory.path, + transformerEntries: [ + const AssetTransformerEntry( + package: 'my_transformer', + args: [], + ) + ], + ); + + expect(processManager, hasNoRemainingExpectations); + expect(failure, isNotNull); + expect(failure!.message, +''' +User-defined transformation of asset "asset.txt" failed. +Asset transformer my_transformer did not produce an output file. +Input file provided to transformer: "/.tmp_rand0/asset.txt-transformOutput0.txt" +Expected output file at: "/.tmp_rand0/asset.txt-transformOutput1.txt" +Full command: $dartBinaryPath run my_transformer --input=/.tmp_rand0/asset.txt-transformOutput0.txt --output=/.tmp_rand0/asset.txt-transformOutput1.txt +stdout: + +stderr: +Transformation failed, but I forgot to exit with a non-zero code.''' + ); + }); + + testWithoutContext('correctly chains transformations when there are multiple of them', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Artifacts artifacts = Artifacts.test(); + + final File asset = fileSystem.file('asset.txt') + ..createSync() + ..writeAsStringSync('ABC'); + const String outputPath = 'output.txt'; + + final String dartBinaryPath = artifacts.getArtifactPath(Artifact.engineDartBinary); + final FakeProcessManager processManager = FakeProcessManager.list([ + FakeCommand( + command: [ + dartBinaryPath, + 'run', + 'my_lowercase_transformer', + '--input=/.tmp_rand0/asset.txt-transformOutput0.txt', + '--output=/.tmp_rand0/asset.txt-transformOutput1.txt', + ], + onRun: (List args) { + final ArgResults parsedArgs = (ArgParser() + ..addOption('input') + ..addOption('output')) + .parse(args); + + final String inputFileContents = fileSystem.file(parsedArgs['input']).readAsStringSync(); + fileSystem.file(parsedArgs['output']) + ..createSync() + ..writeAsStringSync(inputFileContents.toLowerCase()); + }, + ), + FakeCommand( + command: [ + dartBinaryPath, + 'run', + 'my_distance_from_ascii_a_transformer', + '--input=/.tmp_rand0/asset.txt-transformOutput1.txt', + '--output=/.tmp_rand0/asset.txt-transformOutput2.txt', + ], + onRun: (List args) { + final ArgResults parsedArgs = (ArgParser() + ..addOption('input') + ..addOption('output')) + .parse(args); + + final String inputFileContents = fileSystem.file(parsedArgs['input']).readAsStringSync(); + final StringBuffer outputContents = StringBuffer(); + + for (int i = 0; i < inputFileContents.length; i++) { + outputContents.write(inputFileContents.codeUnitAt(i) - 'a'.codeUnits.first); + } + + fileSystem.file(parsedArgs['output']) + ..createSync() + ..writeAsStringSync(outputContents.toString()); + }, + ), + ]); + + final AssetTransformer transformer = AssetTransformer( + processManager: processManager, + fileSystem: fileSystem, + dartBinaryPath: dartBinaryPath, + ); + + final AssetTransformationFailure? failure = await transformer.transformAsset( + asset: asset, + outputPath: outputPath, + workingDirectory: fileSystem.currentDirectory.path, + transformerEntries: [ + const AssetTransformerEntry( + package: 'my_lowercase_transformer', + args: [], + ), + const AssetTransformerEntry( + package: 'my_distance_from_ascii_a_transformer', + args: [], + ), + ], + ); + + expect(processManager, hasNoRemainingExpectations); + expect(failure, isNull); + expect(fileSystem.file(outputPath).readAsStringSync(), '012'); + }); + + testWithoutContext('prints an error when a transformer in a chain (thats not the first) does not produce an output', () async { + final FileSystem fileSystem = MemoryFileSystem(); + final Artifacts artifacts = Artifacts.test(); + + final File asset = fileSystem.file('asset.txt') + ..createSync() + ..writeAsStringSync('ABC'); + const String outputPath = 'output.txt'; + + final String dartBinaryPath = artifacts.getArtifactPath(Artifact.engineDartBinary); + final FakeProcessManager processManager = FakeProcessManager.list([ + FakeCommand( + command: [ + dartBinaryPath, + 'run', + 'my_lowercase_transformer', + '--input=/.tmp_rand0/asset.txt-transformOutput0.txt', + '--output=/.tmp_rand0/asset.txt-transformOutput1.txt', + ], + onRun: (List args) { + final ArgResults parsedArgs = (ArgParser() + ..addOption('input') + ..addOption('output')) + .parse(args); + + final String inputFileContents = fileSystem.file(parsedArgs['input']).readAsStringSync(); + fileSystem.file(parsedArgs['output']) + ..createSync() + ..writeAsStringSync(inputFileContents.toLowerCase()); + }, + ), + FakeCommand( + command: [ + dartBinaryPath, + 'run', + 'my_distance_from_ascii_a_transformer', + '--input=/.tmp_rand0/asset.txt-transformOutput1.txt', + '--output=/.tmp_rand0/asset.txt-transformOutput2.txt', + ], + onRun: (List args) { + // Do nothing. + }, + stderr: 'Transformation failed, but I forgot to exit with a non-zero code.' + ), + ]); + + final AssetTransformer transformer = AssetTransformer( + processManager: processManager, + fileSystem: fileSystem, + dartBinaryPath: dartBinaryPath, + ); + + final AssetTransformationFailure? failure = await transformer.transformAsset( + asset: asset, + outputPath: outputPath, + workingDirectory: fileSystem.currentDirectory.path, + transformerEntries: [ + const AssetTransformerEntry( + package: 'my_lowercase_transformer', + args: [], + ), + const AssetTransformerEntry( + package: 'my_distance_from_ascii_a_transformer', + args: [], + ), + ], + ); + + expect(failure, isNotNull); + expect(failure!.message, +''' +User-defined transformation of asset "asset.txt" failed. +Asset transformer my_distance_from_ascii_a_transformer did not produce an output file. +Input file provided to transformer: "/.tmp_rand0/asset.txt-transformOutput1.txt" +Expected output file at: "/.tmp_rand0/asset.txt-transformOutput2.txt" +Full command: Artifact.engineDartBinary run my_distance_from_ascii_a_transformer --input=/.tmp_rand0/asset.txt-transformOutput1.txt --output=/.tmp_rand0/asset.txt-transformOutput2.txt +stdout: + +stderr: +Transformation failed, but I forgot to exit with a non-zero code.''' + ); + expect(processManager, hasNoRemainingExpectations); + expect(fileSystem.file(outputPath), isNot(exists)); + expect(fileSystem.systemTempDirectory.listSync(), isEmpty); + }); +} diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/assets_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/assets_test.dart index 0a06c7d6035..cd9fc31799c 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/assets_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/assets_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:args/args.dart'; import 'package:collection/collection.dart' show IterableExtension; import 'package:file/memory.dart'; import 'package:file_testing/file_testing.dart'; @@ -9,19 +10,24 @@ import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/user_messages.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/build_system/build_system.dart'; import 'package:flutter_tools/src/build_system/depfile.dart'; import 'package:flutter_tools/src/build_system/targets/assets.dart'; +import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/convert.dart'; import 'package:flutter_tools/src/devfs.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; import '../../../src/common.dart'; import '../../../src/context.dart'; +import '../../../src/fake_process_manager.dart'; void main() { late Environment environment; late FileSystem fileSystem; + late BufferLogger logger; setUp(() { fileSystem = MemoryFileSystem.test(); @@ -53,6 +59,7 @@ flutter: - assets/foo/bar.png - assets/wildcard/ '''); + logger = BufferLogger.test(); }); testUsingContext('includes LICENSE file inputs in dependencies', () async { @@ -157,6 +164,153 @@ flutter: }); }); + testUsingContext('transforms assets declared with transformers', () async { + Cache.flutterRoot = Cache.defaultFlutterRoot( + platform: globals.platform, + fileSystem: fileSystem, + userMessages: UserMessages(), + ); + + final Environment environment = Environment.test( + fileSystem.currentDirectory, + processManager: globals.processManager, + artifacts: Artifacts.test(), + fileSystem: fileSystem, + logger: logger, + platform: globals.platform, + defines: {}, + ); + + await fileSystem.file('.packages').create(); + + fileSystem.file('pubspec.yaml') + ..createSync() + ..writeAsStringSync(''' +name: example +flutter: + assets: + - path: input.txt + transformers: + - package: my_capitalizer_transformer + args: ["-a", "-b", "--color", "green"] +'''); + + fileSystem.file('input.txt') + ..createSync(recursive: true) + ..writeAsStringSync('abc'); + + await const CopyAssets().build(environment); + + expect(logger.errorText, isEmpty); + expect(globals.processManager, hasNoRemainingExpectations); + expect(fileSystem.file('${environment.buildDir.path}/flutter_assets/input.txt'), exists); + }, overrides: { + Logger: () => logger, + FileSystem: () => fileSystem, + Platform: () => FakePlatform(), + ProcessManager: () => FakeProcessManager.list( + [ + FakeCommand( + command: [ + Artifacts.test().getArtifactPath(Artifact.engineDartBinary), + 'run', + 'my_capitalizer_transformer', + RegExp('--input=.*'), + RegExp('--output=.*'), + '-a', + '-b', + '--color', + 'green', + ], + onRun: (List args) { + final ArgResults parsedArgs = (ArgParser() + ..addOption('input') + ..addOption('output') + ..addOption('color') + ..addFlag('aaa', abbr: 'a') + ..addFlag('bbb', abbr: 'b')) + .parse(args); + + expect(parsedArgs['aaa'], true); + expect(parsedArgs['bbb'], true); + expect(parsedArgs['color'], 'green'); + + final File input = fileSystem.file(parsedArgs['input'] as String); + expect(input, exists); + final String inputContents = input.readAsStringSync(); + expect(inputContents, 'abc'); + fileSystem.file(parsedArgs['output']) + ..createSync() + ..writeAsStringSync(inputContents.toUpperCase()); + }, + ), + ], + ), + }); + + testUsingContext('exits tool if an asset transformation fails', () async { + Cache.flutterRoot = Cache.defaultFlutterRoot( + platform: globals.platform, + fileSystem: fileSystem, + userMessages: UserMessages(), + ); + + final Environment environment = Environment.test( + fileSystem.currentDirectory, + processManager: globals.processManager, + artifacts: Artifacts.test(), + fileSystem: fileSystem, + logger: logger, + platform: globals.platform, + defines: {}, + ); + + await fileSystem.file('.packages').create(); + + fileSystem.file('pubspec.yaml') + ..createSync() + ..writeAsStringSync(''' +name: example +flutter: + assets: + - path: input.txt + transformers: + - package: my_transformer + args: ["-a", "-b", "--color", "green"] +'''); + + await fileSystem.file('input.txt').create(recursive: true); + + await expectToolExitLater( + const CopyAssets().build(environment), + startsWith('User-defined transformation of asset "/input.txt" failed.\n'), + ); + expect(globals.processManager, hasNoRemainingExpectations); + }, overrides: { + Logger: () => logger, + FileSystem: () => fileSystem, + Platform: () => FakePlatform(), + ProcessManager: () => FakeProcessManager.list( + [ + FakeCommand( + command: [ + Artifacts.test().getArtifactPath(Artifact.engineDartBinary), + 'run', + 'my_transformer', + RegExp('--input=.*'), + RegExp('--output=.*'), + '-a', + '-b', + '--color', + 'green', + ], + exitCode: 1, + ), + ], + ), + }); + + testUsingContext('Throws exception if pubspec contains missing files', () async { fileSystem.file('pubspec.yaml') ..createSync() @@ -182,7 +336,7 @@ flutter: null, targetPlatform: TargetPlatform.android, fileSystem: MemoryFileSystem.test(), - logger: BufferLogger.test(), + logger: logger, ), isNull); }); @@ -193,7 +347,7 @@ flutter: 'does_not_exist.sksl', targetPlatform: TargetPlatform.android, fileSystem: MemoryFileSystem.test(), - logger: BufferLogger.test(), + logger: logger, ), throwsException); }); diff --git a/packages/flutter_tools/test/general.shard/devfs_test.dart b/packages/flutter_tools/test/general.shard/devfs_test.dart index e4078c0f296..a6a9b431cd4 100644 --- a/packages/flutter_tools/test/general.shard/devfs_test.dart +++ b/packages/flutter_tools/test/general.shard/devfs_test.dart @@ -20,6 +20,7 @@ import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/build_system/tools/shader_compiler.dart'; import 'package:flutter_tools/src/compile.dart'; import 'package:flutter_tools/src/devfs.dart'; +import 'package:flutter_tools/src/flutter_manifest.dart'; import 'package:flutter_tools/src/vmservice.dart'; import 'package:package_config/package_config.dart'; import 'package:test/fake.dart'; @@ -623,10 +624,12 @@ void main() { ..entries['foo.frag'] = AssetBundleEntry( DevFSByteContent([1, 2, 3, 4]), kind: AssetKind.shader, + transformers: const [], ) ..entries['not.frag'] = AssetBundleEntry( DevFSByteContent([1, 2, 3, 4]), kind: AssetKind.regular, + transformers: const [], ); final UpdateFSReport report = await devFS.update( @@ -680,6 +683,7 @@ void main() { ..entries['FontManifest.json'] = AssetBundleEntry( DevFSByteContent([1, 2, 3, 4]), kind: AssetKind.regular, + transformers: const [], ); final UpdateFSReport report = await devFS.update(