feat: Add --no-uninstall flag to flutter test for integration tests (#182714)
## Description
Add `--no-uninstall` flag to `flutter test` to skip app uninstallation
after integration tests complete.
When running integration tests on Android (`flutter test
integration_test/`), the `IntegrationTestTestDevice.kill()` method
always calls `device.uninstallApp()` after tests finish. For Device
Policy Manager (DPM) apps, Android prevents uninstallation, causing the
error:
```bash
adb uninstall failed: Failure [DELETE_FAILED_DEVICE_POLICY_MANAGER]
```
This PR adds a `--uninstall` flag (defaulting to true) to `flutter
test`, so users can pass `--no-uninstall` to skip the post-test
uninstallation step:
```bash
flutter test integration_test/ --no-uninstall
```
Fixes #166709
## Pre-launch Checklist
- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
diff --git a/packages/flutter_tools/lib/src/commands/test.dart b/packages/flutter_tools/lib/src/commands/test.dart
index eaac1ad..cbb40ea 100644
--- a/packages/flutter_tools/lib/src/commands/test.dart
+++ b/packages/flutter_tools/lib/src/commands/test.dart
@@ -295,6 +295,13 @@
'and this flag can be used to override the default. To disable this for the '
'skwasm renderer, use "--no-cross-origin-isolation".',
hide: !verboseHelp,
+ )
+ ..addFlag(
+ 'uninstall',
+ defaultsTo: true,
+ help:
+ 'Whether to uninstall the app after running integration tests. '
+ 'Set "--no-uninstall" to keep the app installed on the device.',
);
addDdsOptions(verboseHelp: verboseHelp);
@@ -477,6 +484,7 @@
: null,
printDtd: boolArg(FlutterGlobalOptions.kPrintDtd, global: true),
webUseWasm: useWasm,
+ uninstallApp: boolArg('uninstall'),
);
final Uri? nativeAssetsJson = _isIntegrationTest
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart
index f67e69b..bd93029 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -982,6 +982,7 @@
this.enableFlutterGpu = false,
this.enableVulkanValidation = false,
this.uninstallFirst = false,
+ this.uninstallApp = true,
this.enableDartProfiling = true,
this.profileStartup = false,
this.enableEmbedderApi = false,
@@ -1016,6 +1017,7 @@
this.enableFlutterGpu = false,
this.enableVulkanValidation = false,
this.uninstallFirst = false,
+ this.uninstallApp = true,
this.enableDartProfiling = true,
this.profileStartup = false,
this.enableEmbedderApi = false,
@@ -1099,6 +1101,7 @@
required this.enableFlutterGpu,
required this.enableVulkanValidation,
required this.uninstallFirst,
+ required this.uninstallApp,
required this.enableDartProfiling,
required this.profileStartup,
required this.enableEmbedderApi,
@@ -1162,6 +1165,12 @@
/// This is not implemented for every platform.
final bool uninstallFirst;
+ /// Whether the tool should uninstall the app after running.
+ ///
+ /// This is currently only implemented for integration tests.
+ /// Defaults to true.
+ final bool uninstallApp;
+
/// Whether to run the browser in headless mode.
///
/// Some CI environments do not provide a display and fail to launch the
@@ -1295,6 +1304,7 @@
'enableImpeller': enableImpeller.asBool,
'enableFlutterGpu': enableFlutterGpu,
'enableVulkanValidation': enableVulkanValidation,
+ 'uninstallApp': uninstallApp,
'enableDartProfiling': enableDartProfiling,
'profileStartup': profileStartup,
'enableEmbedderApi': enableEmbedderApi,
@@ -1364,6 +1374,7 @@
enableFlutterGpu: json['enableFlutterGpu']! as bool,
enableVulkanValidation: (json['enableVulkanValidation'] as bool?) ?? false,
uninstallFirst: (json['uninstallFirst'] as bool?) ?? false,
+ uninstallApp: (json['uninstallApp'] as bool?) ?? true,
enableDartProfiling: (json['enableDartProfiling'] as bool?) ?? true,
profileStartup: (json['profileStartup'] as bool?) ?? false,
enableEmbedderApi: (json['enableEmbedderApi'] as bool?) ?? false,
diff --git a/packages/flutter_tools/lib/src/test/integration_test_device.dart b/packages/flutter_tools/lib/src/test/integration_test_device.dart
index 0c6d0b6..d4e6ba2 100644
--- a/packages/flutter_tools/lib/src/test/integration_test_device.dart
+++ b/packages/flutter_tools/lib/src/test/integration_test_device.dart
@@ -141,8 +141,10 @@
if (!await device.stopApp(applicationPackage, userIdentifier: userIdentifier)) {
globals.printTrace('Could not stop the Integration Test app.');
}
- if (!await device.uninstallApp(applicationPackage, userIdentifier: userIdentifier)) {
- globals.printTrace('Could not uninstall the Integration Test app.');
+ if (debuggingOptions.uninstallApp) {
+ if (!await device.uninstallApp(applicationPackage, userIdentifier: userIdentifier)) {
+ globals.printTrace('Could not uninstall the Integration Test app.');
+ }
}
}
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart
index b9a77ed..fd347f5 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/test_test.dart
@@ -1523,6 +1523,40 @@
);
testUsingContext(
+ 'uninstallApp defaults to true',
+ () async {
+ final testRunner = FakeFlutterTestRunner(0);
+
+ final testCommand = TestCommand(testRunner: testRunner);
+ final CommandRunner<void> commandRunner = createTestCommandRunner(testCommand);
+
+ await commandRunner.run(const <String>['test', '--no-pub']);
+ expect(testRunner.lastDebuggingOptionsValue.uninstallApp, true);
+ },
+ overrides: <Type, Generator>{
+ FileSystem: () => fs,
+ ProcessManager: () => FakeProcessManager.any(),
+ },
+ );
+
+ testUsingContext(
+ '--no-uninstall sets uninstallApp to false',
+ () async {
+ final testRunner = FakeFlutterTestRunner(0);
+
+ final testCommand = TestCommand(testRunner: testRunner);
+ final CommandRunner<void> commandRunner = createTestCommandRunner(testCommand);
+
+ await commandRunner.run(const <String>['test', '--no-pub', '--no-uninstall']);
+ expect(testRunner.lastDebuggingOptionsValue.uninstallApp, false);
+ },
+ overrides: <Type, Generator>{
+ FileSystem: () => fs,
+ ProcessManager: () => FakeProcessManager.any(),
+ },
+ );
+
+ testUsingContext(
'Passes web renderer into debugging options',
() async {
final testRunner = FakeFlutterTestRunner(0);
diff --git a/packages/flutter_tools/test/general.shard/integration_test_device_test.dart b/packages/flutter_tools/test/general.shard/integration_test_device_test.dart
index 422d1e6..86ed356 100644
--- a/packages/flutter_tools/test/general.shard/integration_test_device_test.dart
+++ b/packages/flutter_tools/test/general.shard/integration_test_device_test.dart
@@ -240,6 +240,76 @@
);
testUsingContext(
+ 'kill() calls uninstallApp when uninstallApp is true',
+ () async {
+ final trackingDevice = FakeDeviceTrackingUninstall();
+ final testDeviceWithUninstall = IntegrationTestTestDevice(
+ id: 1,
+ device: trackingDevice,
+ debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug),
+ userIdentifier: '',
+ compileExpression: null,
+ );
+
+ await testDeviceWithUninstall.start('entrypointPath');
+ await testDeviceWithUninstall.kill();
+
+ expect(trackingDevice.uninstallAppCalled, isTrue);
+ expect(testDeviceWithUninstall.finished, completes);
+ },
+ overrides: <Type, Generator>{
+ ApplicationPackageFactory: () => FakeApplicationPackageFactory(),
+ VMServiceConnector: () =>
+ (
+ Uri httpUri, {
+ ReloadSources? reloadSources,
+ Restart? restart,
+ CompileExpression? compileExpression,
+ FlutterProject? flutterProject,
+ PrintStructuredErrorLogMethod? printStructuredErrorLogMethod,
+ io.CompressionOptions? compression,
+ Device? device,
+ Logger? logger,
+ }) async => fakeVmServiceHost.vmService,
+ },
+ );
+
+ testUsingContext(
+ 'kill() does not call uninstallApp when uninstallApp is false',
+ () async {
+ final trackingDevice = FakeDeviceTrackingUninstall();
+ final testDeviceWithoutUninstall = IntegrationTestTestDevice(
+ id: 1,
+ device: trackingDevice,
+ debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug, uninstallApp: false),
+ userIdentifier: '',
+ compileExpression: null,
+ );
+
+ await testDeviceWithoutUninstall.start('entrypointPath');
+ await testDeviceWithoutUninstall.kill();
+
+ expect(trackingDevice.uninstallAppCalled, isFalse);
+ expect(testDeviceWithoutUninstall.finished, completes);
+ },
+ overrides: <Type, Generator>{
+ ApplicationPackageFactory: () => FakeApplicationPackageFactory(),
+ VMServiceConnector: () =>
+ (
+ Uri httpUri, {
+ ReloadSources? reloadSources,
+ Restart? restart,
+ CompileExpression? compileExpression,
+ FlutterProject? flutterProject,
+ PrintStructuredErrorLogMethod? printStructuredErrorLogMethod,
+ io.CompressionOptions? compression,
+ Device? device,
+ Logger? logger,
+ }) async => fakeVmServiceHost.vmService,
+ },
+ );
+
+ testUsingContext(
'Can handle closing of the VM service',
() async {
final StreamChannel<String> channel = await testDevice.start('entrypointPath');
@@ -274,3 +344,21 @@
}
class FakeApplicationPackage extends Fake implements ApplicationPackage {}
+
+class FakeDeviceTrackingUninstall extends FakeDevice {
+ FakeDeviceTrackingUninstall()
+ : super(
+ 'ephemeral',
+ 'ephemeral',
+ type: PlatformType.android,
+ launchResult: LaunchResult.succeeded(vmServiceUri: vmServiceUri),
+ );
+
+ bool uninstallAppCalled = false;
+
+ @override
+ Future<bool> uninstallApp(ApplicationPackage app, {String? userIdentifier}) async {
+ uninstallAppCalled = true;
+ return true;
+ }
+}