Files
packages/script/tool/test/create_all_packages_app_command_test.dart
stuartmorgan 30ebcf38c9 [ci] Add a legacy Android build-all test (#4005)
Adds the ability to replace portions of the `flutter create`d app with saved copies, and adds a second build-all phase for Android that uses a Flutter 2.0.6-created `android/` directory (AGP 4.1, Gradle 6.7) to catch issues like https://github.com/flutter/flutter/issues/125621 and https://github.com/flutter/flutter/issues/125482 prior to release.

Includes some incidental cleanup:
- Extracts a helper method for adjusting files, so that this doesn't add even more copies of basically identical code.
    - (This was motivated by an earlier version of the PR that added modifications to several more files, which I ended up undoing, but the cleanup seemed worth keeping.)
- Adds missing unit test coverage.
- Reworks the unit tests to use a mock process manager and dummy files, instead of each test actually calling `flutter create`, which made each new test add several seconds to the unit test suite.
    - While this reduces the integration-style coverage, in practice the integration tests of the repo tooling is the CI itself, so the unit tests should be true unit tests.
- Changes the non-legacy test to Kotlin; we were still testing with Java even though Kotlin has been the default for quite a while, so we weren't testing what most new users would actually be running. Since we now have a legacy test, I used Java there to cover both.
- Removes some dead code for modifying the AndroidManifest.xml; when trying to set up unit tests for it I discovered that it no longer matches anything in an actual project. It dates back to the original command, and seems to have been a camera-related hack, which we clearly no longer need since it wasn't working and camera still works in build-all.

This is captured somewhat in the README in the legacy project directory, but to document the approach here: originally I was going to add flags to change individual items (AGP version, Gradle version), but quickly ran into the fact that selective downgrading is extremely fragile. E.g.,:
- The Kotlin version set in current projects doesn't work when downgrading AGP and Gradle.
- The app template can unconditionally use anything (e.g., `namespace`) that the AGP version it uses supports, so arbitrary future breakage is possible.

It's also less useful as a real test of what a plugin client's project likely looks like. Starting with a complete platform directory, and doing whatever the minimal changes are to keep it working, will likely reflect a common real-world scenario. On the flip side, the reason this doesn't use a complete 2.0.6 project, but instead is based on specific platform directories, is that we don't want to waste time manually maintaining, e.g., old Dart code that is irrelevant to the goal of the test. For now this only uses Android because that's where we've seen problems in practice, but we can alway add other legacy platform tests later if we find a need.

Fixes https://github.com/flutter/flutter/issues/125689
2023-05-22 20:10:38 +00:00

543 lines
18 KiB
Dart

// Copyright 2013 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 'dart:io' as io;
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:flutter_plugin_tools/src/create_all_packages_app_command.dart';
import 'package:platform/platform.dart';
import 'package:test/test.dart';
import 'mocks.dart';
import 'util.dart';
void main() {
late CommandRunner<void> runner;
late CreateAllPackagesAppCommand command;
late Platform mockPlatform;
late FileSystem fileSystem;
late Directory testRoot;
late Directory packagesDir;
late RecordingProcessRunner processRunner;
setUp(() {
mockPlatform = MockPlatform(isMacOS: true);
fileSystem = MemoryFileSystem();
testRoot = fileSystem.systemTempDirectory.createTempSync();
packagesDir = testRoot.childDirectory('packages');
processRunner = RecordingProcessRunner();
command = CreateAllPackagesAppCommand(
packagesDir,
processRunner: processRunner,
platform: mockPlatform,
);
runner = CommandRunner<void>(
'create_all_test', 'Test for $CreateAllPackagesAppCommand');
runner.addCommand(command);
});
/// Simulates enough of `flutter create`s output to allow the modifications
/// made by the command to work.
void writeFakeFlutterCreateOutput(
Directory outputDirectory, {
String dartSdkConstraint = '>=3.0.0 <4.0.0',
String? appBuildGradleDependencies,
bool androidOnly = false,
}) {
final RepositoryPackage package = RepositoryPackage(
outputDirectory.childDirectory(allPackagesProjectName));
// Android
final String dependencies = appBuildGradleDependencies ??
r'''
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
''';
package
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle')
..createSync(recursive: true)
..writeAsStringSync('''
android {
namespace 'dev.flutter.packages.foo.example'
compileSdkVersion flutter.compileSdkVersion
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "dev.flutter.packages.foo.example"
minSdkVersion flutter.minSdkVersion
targetSdkVersion 32
}
}
$dependencies
''');
if (androidOnly) {
return;
}
// Non-platform-specific
package.pubspecFile
..createSync(recursive: true)
..writeAsStringSync('''
name: $allPackagesProjectName
description: Flutter app containing all 1st party plugins.
publish_to: none
version: 1.0.0
environment:
sdk: '$dartSdkConstraint'
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
###
''');
// macOS
final Directory macOS = package.platformDirectory(FlutterPlatform.macos);
macOS.childDirectory('Runner.xcodeproj').childFile('project.pbxproj')
..createSync(recursive: true)
..writeAsStringSync('''
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
};
name = Release;
};
''');
macOS.childFile('Podfile')
..createSync(recursive: true)
..writeAsStringSync('''
# platform :osx, '10.14'
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
''');
}
group('non-macOS host', () {
setUp(() {
mockPlatform = MockPlatform(isLinux: true);
command = CreateAllPackagesAppCommand(
packagesDir,
processRunner: processRunner,
platform: mockPlatform,
);
runner = CommandRunner<void>(
'create_all_test', 'Test for $CreateAllPackagesAppCommand');
runner.addCommand(command);
});
test('calls "flutter create"', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
expect(
processRunner.recordedCalls,
contains(ProcessCall(
getFlutterCommand(mockPlatform),
<String>[
'create',
'--template=app',
'--project-name=$allPackagesProjectName',
testRoot.childDirectory(allPackagesProjectName).path,
],
null)));
});
test('pubspec includes all plugins', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
createFakePlugin('pluginb', packagesDir);
createFakePlugin('pluginc', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final List<String> pubspec = command.app.pubspecFile.readAsLinesSync();
expect(
pubspec,
containsAll(<Matcher>[
contains(RegExp('path: .*/packages/plugina')),
contains(RegExp('path: .*/packages/pluginb')),
contains(RegExp('path: .*/packages/pluginc')),
]));
});
test('pubspec has overrides for all plugins', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
createFakePlugin('pluginb', packagesDir);
createFakePlugin('pluginc', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final List<String> pubspec = command.app.pubspecFile.readAsLinesSync();
expect(
pubspec,
containsAllInOrder(<Matcher>[
contains('dependency_overrides:'),
contains(RegExp('path: .*/packages/plugina')),
contains(RegExp('path: .*/packages/pluginb')),
contains(RegExp('path: .*/packages/pluginc')),
]));
});
test('legacy files are copied when requested', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
// Make a fake legacy source with all the necessary files, replacing one
// of them.
final Directory legacyDir = testRoot.childDirectory('legacy');
final RepositoryPackage legacySource =
RepositoryPackage(legacyDir.childDirectory(allPackagesProjectName));
writeFakeFlutterCreateOutput(legacyDir, androidOnly: true);
const String legacyAppBuildGradleContents = 'Fake legacy content';
final File legacyGradleFile = legacySource
.platformDirectory(FlutterPlatform.android)
.childFile('build.gradle');
legacyGradleFile.writeAsStringSync(legacyAppBuildGradleContents);
await runCapturingPrint(runner, <String>[
'create-all-packages-app',
'--legacy-source=${legacySource.path}',
]);
final File buildGradle = command.app
.platformDirectory(FlutterPlatform.android)
.childFile('build.gradle');
expect(buildGradle.readAsStringSync(), legacyAppBuildGradleContents);
});
test('legacy directory replaces, rather than overlaying', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
final File extraFile =
RepositoryPackage(testRoot.childDirectory(allPackagesProjectName))
.platformDirectory(FlutterPlatform.android)
.childFile('extra_file');
extraFile.createSync(recursive: true);
// Make a fake legacy source with all the necessary files, but not
// including the extra file.
final Directory legacyDir = testRoot.childDirectory('legacy');
final RepositoryPackage legacySource =
RepositoryPackage(legacyDir.childDirectory(allPackagesProjectName));
writeFakeFlutterCreateOutput(legacyDir, androidOnly: true);
await runCapturingPrint(runner, <String>[
'create-all-packages-app',
'--legacy-source=${legacySource.path}',
]);
expect(extraFile.existsSync(), false);
});
test('legacy files are modified as needed by the tool', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
// Make a fake legacy source with all the necessary files, replacing one
// of them.
final Directory legacyDir = testRoot.childDirectory('legacy');
final RepositoryPackage legacySource =
RepositoryPackage(legacyDir.childDirectory(allPackagesProjectName));
writeFakeFlutterCreateOutput(legacyDir, androidOnly: true);
const String legacyAppBuildGradleContents = '''
# This is the legacy file
android {
compileSdkVersion flutter.compileSdkVersion
defaultConfig {
minSdkVersion flutter.minSdkVersion
}
}
''';
final File legacyGradleFile = legacySource
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle');
legacyGradleFile.writeAsStringSync(legacyAppBuildGradleContents);
await runCapturingPrint(runner, <String>[
'create-all-packages-app',
'--legacy-source=${legacySource.path}',
]);
final List<String> buildGradle = command.app
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle')
.readAsLinesSync();
expect(
buildGradle,
containsAll(<Matcher>[
contains('This is the legacy file'),
contains('minSdkVersion 21'),
contains('compileSdkVersion 33'),
]));
});
test('pubspec preserves existing Dart SDK version', () async {
const String existingSdkConstraint = '>=1.0.0 <99.0.0';
writeFakeFlutterCreateOutput(testRoot,
dartSdkConstraint: existingSdkConstraint);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final Pubspec generatedPubspec = command.app.parsePubspec();
const String dartSdkKey = 'sdk';
expect(generatedPubspec.environment?[dartSdkKey].toString(),
existingSdkConstraint);
});
test('Android app gradle is modified as expected', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final List<String> buildGradle = command.app
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle')
.readAsLinesSync();
expect(
buildGradle,
containsAll(<Matcher>[
contains('minSdkVersion 21'),
contains('compileSdkVersion 33'),
contains('multiDexEnabled true'),
contains('androidx.lifecycle:lifecycle-runtime'),
]));
});
// The template's app/build.gradle does not always have a dependencies
// section; ensure that the dependency is added if there is not one.
test('Android lifecyle dependency is added with no dependencies', () async {
writeFakeFlutterCreateOutput(testRoot, appBuildGradleDependencies: '');
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final List<String> buildGradle = command.app
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle')
.readAsLinesSync();
expect(
buildGradle,
containsAllInOrder(<Matcher>[
equals('dependencies {'),
contains('androidx.lifecycle:lifecycle-runtime'),
equals('}'),
]));
});
// Some versions of the template's app/build.gradle has an empty
// dependencies section; ensure that the dependency is added in that case.
test('Android lifecyle dependency is added with empty dependencies',
() async {
writeFakeFlutterCreateOutput(testRoot,
appBuildGradleDependencies: 'dependencies {}');
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final List<String> buildGradle = command.app
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle')
.readAsLinesSync();
expect(
buildGradle,
containsAllInOrder(<Matcher>[
equals('dependencies {'),
contains('androidx.lifecycle:lifecycle-runtime'),
equals('}'),
]));
});
test('macOS deployment target is modified in pbxproj', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final List<String> pbxproj = command.app
.platformDirectory(FlutterPlatform.macos)
.childDirectory('Runner.xcodeproj')
.childFile('project.pbxproj')
.readAsLinesSync();
expect(
pbxproj,
everyElement((String line) =>
!line.contains('MACOSX_DEPLOYMENT_TARGET') ||
line.contains('10.15')));
});
test('calls flutter pub get', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
expect(
processRunner.recordedCalls,
contains(ProcessCall(
getFlutterCommand(mockPlatform),
const <String>['pub', 'get'],
testRoot.childDirectory(allPackagesProjectName).path)));
});
test('fails if flutter create fails', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
processRunner
.mockProcessesForExecutable[getFlutterCommand(mockPlatform)] =
<FakeProcessInfo>[
FakeProcessInfo(MockProcess(exitCode: 1), <String>['create'])
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['create-all-packages-app'], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('Failed to `flutter create`'),
]));
});
test('fails if flutter pub get fails', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
processRunner
.mockProcessesForExecutable[getFlutterCommand(mockPlatform)] =
<FakeProcessInfo>[
FakeProcessInfo(MockProcess(), <String>['create']),
FakeProcessInfo(MockProcess(exitCode: 1), <String>['pub', 'get'])
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['create-all-packages-app'], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains(
"Failed to generate native build files via 'flutter pub get'"),
]));
},
// See comment about Windows in create_all_packages_app_command.dart
skip: io.Platform.isWindows);
test('handles --output-dir', () async {
final Directory customOutputDir =
fileSystem.systemTempDirectory.createTempSync();
writeFakeFlutterCreateOutput(customOutputDir);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>[
'create-all-packages-app',
'--output-dir=${customOutputDir.path}'
]);
expect(command.app.path,
customOutputDir.childDirectory(allPackagesProjectName).path);
});
test('logs exclusions', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
createFakePlugin('pluginb', packagesDir);
createFakePlugin('pluginc', packagesDir);
final List<String> output = await runCapturingPrint(runner,
<String>['create-all-packages-app', '--exclude=pluginb,pluginc']);
expect(
output,
containsAllInOrder(<String>[
'Exluding the following plugins from the combined build:',
' pluginb',
' pluginc',
]));
});
});
group('macOS host', () {
setUp(() {
command = CreateAllPackagesAppCommand(
packagesDir,
processRunner: processRunner,
platform: MockPlatform(isMacOS: true),
);
runner = CommandRunner<void>(
'create_all_test', 'Test for $CreateAllPackagesAppCommand');
runner.addCommand(command);
});
test('macOS deployment target is modified in Podfile', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
final File podfileFile = RepositoryPackage(
command.packagesDir.parent.childDirectory(allPackagesProjectName))
.platformDirectory(FlutterPlatform.macos)
.childFile('Podfile');
podfileFile.createSync(recursive: true);
podfileFile.writeAsStringSync("""
platform :osx, '10.11'
# some other line
""");
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final List<String> podfile = command.app
.platformDirectory(FlutterPlatform.macos)
.childFile('Podfile')
.readAsLinesSync();
expect(
podfile,
everyElement((String line) =>
!line.contains('platform :osx') || line.contains("'10.15'")));
},
// Podfile is only generated (and thus only edited) on macOS.
skip: !io.Platform.isMacOS);
});
}