// 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. // This test exercises the embedding of the native assets mapping in dill files. // An initial dill file is created by `flutter assemble` and used for running // the application. This dill must contain the mapping. // When doing hot reload, this mapping must stay in place. // When doing a hot restart, a new dill file is pushed. This dill file must also // contain the native assets mapping. // When doing a hot reload, this mapping must stay in place. import 'dart:io'; import 'dart:typed_data'; import 'package:file/file.dart'; import 'package:standard_message_codec/standard_message_codec.dart' show StandardMessageCodec; import 'package:yaml_edit/yaml_edit.dart' show YamlEditor; import '../../src/common.dart'; import '../test_utils.dart' show ProcessResultMatcher, fileSystem, flutterBin, platform; import '../transition_test_utils.dart'; import 'native_assets_test_utils.dart'; final String hostOs = platform.operatingSystem; const packageName = 'data_asset_example'; const packageNameDependency = 'data_asset_dependency'; void main() { if (!platform.isMacOS && !platform.isLinux && !platform.isWindows) { return; } setUpAll(() async { processManager.runSync([flutterBin, 'config', '--enable-native-assets']); processManager.runSync([flutterBin, 'config', '--enable-dart-data-assets']); }); late Directory tempDirectory; late Directory root; late Directory rootDependency; setUp(() async { // Do not reuse project structure to be able to make local changes tempDirectory = fileSystem.directory( fileSystem.systemTempDirectory.createTempSync().resolveSymbolicLinksSync(), ); root = createAppWithName(packageName, tempDirectory); await createDataAssetApp(packageName, root); rootDependency = createAppWithName(packageNameDependency, tempDirectory); }); tearDown(() { tryToDelete(tempDirectory); }); group('dart data assets', () { // NOTE: flutter-tester doesn't support profile/release mode. // NOTE: flutter web doesn't allow cpaturing print()s in profile/release // nOTE: flutter web doens't allow adding assets on hot-restart final devices = [hostOs, 'chrome', 'flutter-tester']; final modes = ['debug', 'release']; for (final mode in modes) { for (final device in devices) { final isFlutterTester = device == 'flutter-tester'; final isWeb = device == 'chrome'; final isDebug = mode == 'debug'; // This test relies on running the flutter app and capturing `print()`s // the app prints to determine if the test succeeded. // `flutter run --profile/release` on the web doesn't support capturing // prints // -> See https://github.com/flutter/flutter/issues/159668 if (isWeb && !isDebug) { continue; } // Flutter tester only supports debug mode. if (isFlutterTester && !isDebug) { continue; } testWithoutContext('flutter run on $device --$mode', () async { final performRestart = isDebug; final performReload = isDebug; final assets = {'id1': 'content1', 'id2': 'content2'}; writeAssets(assets, root); writeHookLibrary(root, assets, available: ['id1']); writeHelperLibrary(root, 'version1', assets.keys.toList()); final ProcessTestResult result = await runFlutter( [ 'run', '-v', '-d', device, '--$mode', if (device == 'chrome') ...[ '--no-web-resources-cdn', '--web-browser-flag=--no-sandbox', ], ], root.path, [ Barrier.contains('Launching lib${Platform.pathSeparator}main.dart on'), Multiple.contains( [ // The flutter tool will print it's ready to accept keys (e.g. // q=quit, ...) // (This can be racy with app already running and printing) 'Flutter run key command', // Once the app runs it will print whether it found assets. 'VERSION: version1', 'FOUND "packages/data_asset_example/id1": "content1".', 'NOT-FOUND "packages/data_asset_example/id2".', ], handler: (_) { if (!performRestart) { return 'q'; } // Now we trigger a hot-restart with new assets & new // application code, we make the build hook now emit also the // `id2` data asset. writeAssets(assets, root); writeHookLibrary(root, assets, available: ['id1', 'id2']); writeHelperLibrary(root, 'afterRestart', assets.keys.toList()); return 'R'; }, ), if (performRestart) Multiple.contains( [ // Once the app runs it will print whether it found assets. // We expect it to having found the new `id2` now. 'VERSION: afterRestart', 'FOUND "packages/data_asset_example/id1": "content1".', // Flutter web doesn't support new assets on hot-restart atm // -> See https://github.com/flutter/flutter/issues/137265 if (isWeb) 'NOT-FOUND "packages/data_asset_example/id2".' else 'FOUND "packages/data_asset_example/id2": "content2".', if (isWeb) 'Successful hot restart' else 'Hot restart performed', ], handler: (_) { if (!performReload) { return 'q'; } // Now we trigger a hot-reload with new assets & new // application code, we make the build hook now emit also the // `id3` data asset (but not `id4`). assets['id3'] = 'content3'; assets['id4'] = 'content4'; writeAssets(assets, root); writeHookLibrary(root, assets, available: ['id1', 'id2', 'id3']); writeHelperLibrary(root, 'afterReload', assets.keys.toList()); return 'r'; }, ), if (performReload) Multiple.contains( [ // Once the app runs it will print whether it found assets. 'VERSION: afterReload', 'FOUND "packages/data_asset_example/id1": "content1".', // Flutter web doesn't support new assets on hot-reload atm // -> See https://github.com/flutter/flutter/issues/137265 if (isWeb) ...[ 'NOT-FOUND "packages/data_asset_example/id2".', 'NOT-FOUND "packages/data_asset_example/id3".', ] else ...[ 'FOUND "packages/data_asset_example/id2": "content2".', 'FOUND "packages/data_asset_example/id3": "content3".', ], 'NOT-FOUND "packages/data_asset_example/id4".', if (isWeb) 'Successful hot reload' else 'Hot reload performed', ], handler: (_) { return 'q'; // quit }, ), Barrier.contains('Application finished.'), ], ); if (result.exitCode != 0) { throw Exception( 'flutter run failed: ${result.exitCode}\n${result.stderr}\n${result.stdout}', ); } }); } } for (final target in [hostOs, 'web']) { testWithoutContext('flutter build $target', () async { final assets = {'id1': 'content1', 'id2': 'content2'}; final available = ['id1']; writeAssets(assets, root); writeHookLibrary(root, assets, available: available); writeHelperLibrary(root, 'version1', assets.keys.toList()); final ProcessTestResult result = await runFlutter( ['build', '-v', target], root.path, [Barrier.contains('Built build${Platform.pathSeparator}$target')], ); if (result.exitCode != 0) { throw Exception( 'flutter build failed: ${result.exitCode}\n${result.stderr}\n${result.stdout}', ); } final Directory buildTargetDir = root.childDirectory('build').childDirectory(target); final List manifestFiles = buildTargetDir .listSync(recursive: true) .whereType() .where((File file) => file.path.endsWith('AssetManifest.bin')) .toList(); if (manifestFiles.isEmpty) { throw Exception('Expected a `AssetManifest.bin` to be avilable in the $buildTargetDir.'); } for (final manifestFile in manifestFiles) { final Uint8List manifestData = manifestFile.readAsBytesSync(); final manifest = const StandardMessageCodec().decodeMessage(ByteData.sublistView(manifestData)) as Map; for (final id in available) { final key = 'packages/$packageName/$id'; final entry = manifest[key]! as List; expect( entry, equals([ {'asset': key}, ]), ); final File file = manifestFile.parent.childFile(key); expect(file.readAsStringSync(), assets[id]); } } }); } for (final target in [hostOs, 'web']) { testWithoutContext('flutter build $target with conflicting assets', () async { final assets = {'id1.txt': 'content1', 'id2.txt': 'content2'}; final available = ['id1.txt']; writeAssets(assets, root); writeAssets(assets, rootDependency); writeHookLibrary(root, assets, available: available); writeHookLibrary(rootDependency, assets, available: available); writeHelperLibrary(root, 'version1', assets.keys.toList()); await modifyPubspec(root, (YamlEditor editor) { editor.update( ['dependencies', packageNameDependency], {'path': '../$packageNameDependency'}, ); }); await modifyPubspec(rootDependency, (YamlEditor editor) { editor ..update(['flutter', 'assets'], [assets.keys.first]) ..update(['dependencies'], {'native_assets_cli': '^0.17.0'}); }); final ProcessTestResult result = await runFlutter( ['build', '-v', target], root.path, [ Barrier.contains( 'Conflicting assets: The asset "asset: packages/data_asset_dependency/id1.txt" was declared in the pubspec and the hook', ), ], ); expect(result.exitCode, isNonZero); }); } }); } Future modifyPubspec(Directory dir, void Function(YamlEditor editor) modify) async { final File pubspecFile = dir.childFile('pubspec.yaml'); final String content = await pubspecFile.readAsString(); final yamlEditor = YamlEditor(content); modify(yamlEditor); pubspecFile.writeAsStringSync(yamlEditor.toString()); } Future createDataAssetApp(String packageName, Directory root) async { await modifyPubspec( root, (YamlEditor editor) => editor.update(['dependencies'], {'native_assets_cli': '^0.17.0'}), ); final File pubspecFile = root.childFile('pubspec.yaml'); await pinDependencies(pubspecFile); final File mainFile = root.childDirectory('lib').childFile('main.dart'); writeFile(mainFile, ''' import 'dart:async'; import 'package:flutter/material.dart'; import 'helper.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { bool first = true; Timer.periodic(const Duration(seconds: 1), (_) async { // Delay to give the `flutter run` command time to connect and // setup `print()` capturing logic (especially on web it won't be // able to intercept prints until it has connected to DevTools). if (first) { await Future.delayed(const Duration(seconds: 5)); } dumpAssets(); }); return MaterialApp( title: 'Flutter Demo', home: Scaffold( body: Text('Hello world'), ), ); } } '''); expect( await processManager.run([flutterBin, 'pub', 'get'], workingDirectory: root.path), const ProcessResultMatcher(), ); } Directory createAppWithName(String packageName, Directory tempDirectory) { final ProcessResult result = processManager.runSync([ flutterBin, 'create', '--no-pub', packageName, ], workingDirectory: tempDirectory.path); expect(result, const ProcessResultMatcher()); final Directory packageDirectory = tempDirectory.childDirectory(packageName); expect( processManager.runSync([ flutterBin, 'pub', 'get', ], workingDirectory: packageDirectory.path), const ProcessResultMatcher(), ); return packageDirectory; } void writeHookLibrary( Directory root, Map dataAssets, { required List available, }) { final File hookFile = root.childDirectory('hook').childFile('build.dart'); available = [for (final String id in available) '"$id"']; writeFile(hookFile, ''' import 'package:native_assets_cli/data_assets.dart'; void main(List args) async { await build(args, (BuildInput input, BuildOutputBuilder output) async { if (input.config.buildAssetTypes.contains('data_assets/data')) { for (final id in $available) { output.assets.data.add( DataAsset( package: input.packageName, name: id, file: input.packageRoot.resolve(id), ), ); } } }); } '''); } void writeAssets(Map dataAssets, Directory root) { dataAssets.forEach((String id, String content) { writeFile(root.childFile(id), content); }); } void writeHelperLibrary(Directory root, String version, List assetIds) { assetIds = [for (final String id in assetIds) '"packages/$packageName/$id"']; final File helperFile = root.childDirectory('lib').childFile('helper.dart'); writeFile(helperFile, ''' import 'package:flutter/services.dart' show rootBundle; // Only run the code once, but after hot-restart & hot-reload we want to // run it again. bool $version = false; void dumpAssets() async { if ($version) return; $version = true; final found = {}; final notFound = []; for (final String assetId in $assetIds) { try { found[assetId] = await rootBundle.loadString(assetId); } catch (e) { print('EXCEPTION \$e'); notFound.add(assetId); } } print('VERSION: $version'); for (final MapEntry(:key, :value) in found.entries) { print('FOUND "\$key": "\$value".'); } for (final id in notFound) { print('NOT-FOUND "\$id".'); } } '''); } void writeFile(File file, String content) => file ..createSync(recursive: true) ..writeAsStringSync(content);