From 18d9b20ffbf051f94da243de3292dbaf080c29a3 Mon Sep 17 00:00:00 2001 From: Yegor Date: Mon, 10 Jul 2017 17:20:49 -0700 Subject: [PATCH] add Android instrumentation test (#11063) * add Android instrumentation test * add devicelab test * add to manifest.yaml * rename _smoke_test.dart to _smoketest.dart to prevent flutter test from picking it up * volatile fields; style fixes * use ConditionVariable; fix sh script --- .../flutter_gallery_instrumentation_test.dart | 26 +++ dev/devicelab/manifest.yaml | 9 ++ .../flutter_gallery/android/app/build.gradle | 2 + .../FlutterGalleryInstrumentationTest.java | 38 +++++ .../FlutterGalleryInstrumentation.java | 39 +++++ .../examples/gallery/MainActivity.java | 12 ++ .../flutter_gallery/test/live_smoketest.dart | 150 ++++++++++++++++++ .../tool/run_instrumentation_test.sh | 15 ++ 8 files changed, 291 insertions(+) create mode 100644 dev/devicelab/bin/tasks/flutter_gallery_instrumentation_test.dart create mode 100644 examples/flutter_gallery/android/app/src/androidTest/java/io/flutter/examples/gallery/FlutterGalleryInstrumentationTest.java create mode 100644 examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/FlutterGalleryInstrumentation.java create mode 100644 examples/flutter_gallery/test/live_smoketest.dart create mode 100755 examples/flutter_gallery/tool/run_instrumentation_test.sh diff --git a/dev/devicelab/bin/tasks/flutter_gallery_instrumentation_test.dart b/dev/devicelab/bin/tasks/flutter_gallery_instrumentation_test.dart new file mode 100644 index 0000000000..4ae7cfa499 --- /dev/null +++ b/dev/devicelab/bin/tasks/flutter_gallery_instrumentation_test.dart @@ -0,0 +1,26 @@ +// Copyright 2017 The Chromium 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:async'; +import 'dart:io'; + +import 'package:flutter_devicelab/framework/adb.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/framework/utils.dart'; + +Future main() async { + deviceOperatingSystem = DeviceOperatingSystem.android; + + await task(() async { + final Directory galleryDirectory = + dir('${flutterDirectory.path}/examples/flutter_gallery'); + await inDirectory(galleryDirectory, () async { + await flutter('packages', options: ['get']); + await flutter('build', options: ['clean']); // to reset the Dart entry point + await exec('tool/run_instrumentation_test.sh', []); + }); + + return new TaskResult.success(null); + }); +} diff --git a/dev/devicelab/manifest.yaml b/dev/devicelab/manifest.yaml index f63a5e481a..c1432fae68 100644 --- a/dev/devicelab/manifest.yaml +++ b/dev/devicelab/manifest.yaml @@ -133,6 +133,15 @@ tasks: required_agent_capabilities: ["linux/android"] flaky: true + flutter_gallery_instrumentation_test: + description: > + Same as flutter_gallery__transition_perf but uses Android instrumentation + framework, and therefore does not require a host computer to run. This + test can run on off-the-shelf infrastructures, such as Firebase Test Lab. + stage: devicelab + required_agent_capabilities: ["linux/android"] + flaky: true + # iOS on-device tests channels_integration_test_ios: diff --git a/examples/flutter_gallery/android/app/build.gradle b/examples/flutter_gallery/android/app/build.gradle index 893cbbd629..2b4d1ee17b 100644 --- a/examples/flutter_gallery/android/app/build.gradle +++ b/examples/flutter_gallery/android/app/build.gradle @@ -56,4 +56,6 @@ dependencies { androidTestCompile 'com.android.support:support-annotations:25.4.0' androidTestCompile 'com.android.support.test:runner:0.5' androidTestCompile 'com.android.support.test:rules:0.5' + androidTestCompile 'org.hamcrest:hamcrest-library:1.3' + androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.2' } diff --git a/examples/flutter_gallery/android/app/src/androidTest/java/io/flutter/examples/gallery/FlutterGalleryInstrumentationTest.java b/examples/flutter_gallery/android/app/src/androidTest/java/io/flutter/examples/gallery/FlutterGalleryInstrumentationTest.java new file mode 100644 index 0000000000..a2d19b5ecc --- /dev/null +++ b/examples/flutter_gallery/android/app/src/androidTest/java/io/flutter/examples/gallery/FlutterGalleryInstrumentationTest.java @@ -0,0 +1,38 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.examples.gallery; + +import android.support.test.filters.LargeTest; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class FlutterGalleryInstrumentationTest { + @Rule + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(MainActivity.class); + + private MainActivity activity; + + @Before + public void setUp() { + activity = mActivityRule.getActivity(); + } + + @Test + public void activityLoaded() throws Exception { + FlutterGalleryInstrumentation instrumentation = activity.getInstrumentation(); + instrumentation.waitForTestToFinish(); + assertThat(instrumentation.isTestSuccessful(), is(true)); + } +} diff --git a/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/FlutterGalleryInstrumentation.java b/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/FlutterGalleryInstrumentation.java new file mode 100644 index 0000000000..57c703586b --- /dev/null +++ b/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/FlutterGalleryInstrumentation.java @@ -0,0 +1,39 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.examples.gallery; + +import android.os.ConditionVariable; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.view.FlutterView; + +/** Instrumentation for testing using Android Espresso framework. */ +public class FlutterGalleryInstrumentation implements MethodCallHandler { + + private final ConditionVariable testFinished = new ConditionVariable(); + private volatile boolean testSuccessful; + + FlutterGalleryInstrumentation(FlutterView view) { + new MethodChannel(view, "io.flutter.examples.gallery/TestLifecycleListener") + .setMethodCallHandler(this); + } + + @Override + public void onMethodCall(MethodCall call, Result result) { + testSuccessful = call.method.equals("success"); + testFinished.open(); + result.success(null); + } + + public boolean isTestSuccessful() { + return testSuccessful; + } + + public void waitForTestToFinish() throws Exception { + testFinished.block(); + } +} diff --git a/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/MainActivity.java b/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/MainActivity.java index f0128fd89f..4e980b286d 100644 --- a/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/MainActivity.java +++ b/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/MainActivity.java @@ -1,3 +1,7 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + package io.flutter.examples.gallery; import android.os.Bundle; @@ -7,9 +11,17 @@ import io.flutter.plugins.GeneratedPluginRegistrant; public class MainActivity extends FlutterActivity { + private FlutterGalleryInstrumentation instrumentation; + + /** Instrumentation for testing. */ + public FlutterGalleryInstrumentation getInstrumentation() { + return instrumentation; + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); GeneratedPluginRegistrant.registerWith(this); + instrumentation = new FlutterGalleryInstrumentation(this.getFlutterView()); } } diff --git a/examples/flutter_gallery/test/live_smoketest.dart b/examples/flutter_gallery/test/live_smoketest.dart new file mode 100644 index 0000000000..0bb1e346b7 --- /dev/null +++ b/examples/flutter_gallery/test/live_smoketest.dart @@ -0,0 +1,150 @@ +// Copyright 2017 The Chromium 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:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flutter_gallery/gallery/app.dart'; + +/// Reports success or failure to the native code. +const MethodChannel _kTestChannel = const MethodChannel('io.flutter.examples.gallery/TestLifecycleListener'); + +Future main() async { + try { + runApp(const GalleryApp()); + + const Duration kWaitBetweenActions = const Duration(milliseconds: 250); + final _LiveWidgetController controller = new _LiveWidgetController(); + + for (Demo demo in demos) { + print('Testing "${demo.title}" demo'); + final Finder menuItem = find.text(demo.title); + await controller.scrollIntoView(menuItem, alignment: 0.5); + await new Future.delayed(kWaitBetweenActions); + + for (int i = 0; i < 2; i += 1) { + await controller.tap(menuItem); // Launch the demo + await new Future.delayed(kWaitBetweenActions); + controller.frameSync = demo.synchronized; + await controller.tap(find.byTooltip('Back')); + controller.frameSync = true; + await new Future.delayed(kWaitBetweenActions); + } + print('Success'); + } + + _kTestChannel.invokeMethod('success'); + } catch (error) { + _kTestChannel.invokeMethod('failure'); + } +} + +class Demo { + const Demo(this.title, {this.synchronized = true}); + + /// The title of the demo. + final String title; + + /// True if frameSync should be enabled for this test. + final bool synchronized; +} + +// Warning: this list must be kept in sync with the value of +// kAllGalleryItems.map((GalleryItem item) => item.title).toList(); +const List demos = const [ + // Demos + const Demo('Shrine'), + const Demo('Contact profile'), + const Demo('Animation'), + + // Material Components + const Demo('Bottom navigation'), + const Demo('Buttons'), + const Demo('Cards'), + const Demo('Chips'), + const Demo('Date and time pickers'), + const Demo('Dialog'), + const Demo('Drawer'), + const Demo('Expand/collapse list control'), + const Demo('Expansion panels'), + const Demo('Floating action button'), + const Demo('Grid'), + const Demo('Icons'), + const Demo('Leave-behind list items'), + const Demo('List'), + const Demo('Menus'), + const Demo('Modal bottom sheet'), + const Demo('Page selector'), + const Demo('Persistent bottom sheet'), + const Demo('Progress indicators', synchronized: false), + const Demo('Pull to refresh'), + const Demo('Scrollable tabs'), + const Demo('Selection controls'), + const Demo('Sliders'), + const Demo('Snackbar'), + const Demo('Tabs'), + const Demo('Text fields'), + const Demo('Tooltips'), + + // Cupertino Components + const Demo('Activity Indicator', synchronized: false), + const Demo('Buttons'), + const Demo('Dialogs'), + const Demo('Sliders'), + const Demo('Switches'), + + // Style + const Demo('Colors'), + const Demo('Typography'), +]; + + +class _LiveWidgetController { + + final WidgetController _controller = new WidgetController(WidgetsBinding.instance); + + /// With [frameSync] enabled, Flutter Driver will wait to perform an action + /// until there are no pending frames in the app under test. + bool frameSync = true; + + /// Waits until at the end of a frame the provided [condition] is [true]. + Future _waitUntilFrame(bool condition(), [Completer completer]) { + completer ??= new Completer(); + if (!condition()) { + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + _waitUntilFrame(condition, completer); + }); + } else { + completer.complete(); + } + return completer.future; + } + + /// Runs `finder` repeatedly until it finds one or more [Element]s. + Future _waitForElement(Finder finder) async { + if (frameSync) + await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0); + + await _waitUntilFrame(() => finder.precache()); + + if (frameSync) + await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0); + + return finder; + } + + Future tap(Finder finder) async { + await _controller.tap(await _waitForElement(finder)); + } + + Future scrollIntoView(Finder finder, {double alignment}) async { + final Finder target = await _waitForElement(finder); + await Scrollable.ensureVisible(target.evaluate().single, duration: const Duration(milliseconds: 100), alignment: alignment); + } +} diff --git a/examples/flutter_gallery/tool/run_instrumentation_test.sh b/examples/flutter_gallery/tool/run_instrumentation_test.sh new file mode 100755 index 0000000000..3c9a0c13f6 --- /dev/null +++ b/examples/flutter_gallery/tool/run_instrumentation_test.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +if [ ! -f "./pubspec.yaml" ]; then + echo "ERROR: current directory must be the root of flutter_gallery package" + exit 1 +fi + +cd android + +# Currently there's no non-hacky way to pass a device ID to gradlew, but it's +# OK as in the devicelab we have one device per host. +# +# See also: https://goo.gl/oe5aUW +./gradlew connectedAndroidTest -Ptarget=test/live_smoketest.dart