未验证 提交 a59f0bda 编写于 作者: E Emmanuel Garcia 提交者: GitHub

Add support for screenshot testing in the scenario app on Android (#18115)

上级 e9f1efa2
......@@ -74,6 +74,25 @@ Studio and running it, or by running `./gradlew assemble` in the `android/`
folder and installing the APK from the correct folder in
`android/app/build/outputs/apk`.
### Generating Golden Images on Android
In the `android` directory, run:
```bash
./gradlew app:recordDebugAndroidTestScreenshotTest
```
The screenshots are recorded into `android/reports/screenshots`.
### Verifying Golden Images on Android
In the `android` directory, run:
```bash
./gradlew app:verifyDebugAndroidTestScreenshotTest
```
## Changing dart:ui code
If you change the dart:ui interface, remember to point the sky_engine and
......
apply plugin: 'com.android.application'
apply plugin: 'com.facebook.testing.screenshot'
screenshots {
failureDir = "${rootProject.buildDir}/reports/diff_failures"
recordDir = "${rootProject.projectDir}/reports/screenshots"
}
android {
compileSdkVersion 28
......@@ -8,11 +14,11 @@ android {
}
defaultConfig {
applicationId "dev.flutter.scenarios"
minSdkVersion 16
minSdkVersion 18
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner "dev.flutter.TestRunner"
}
buildTypes {
release {
......@@ -23,6 +29,7 @@ android {
}
dependencies {
compile 'com.facebook.testing.screenshot:layout-hierarchy-common:0.12.0'
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
......
// 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.
package dev.flutter;
import android.os.Bundle;
import androidx.test.runner.AndroidJUnitRunner;
import dev.flutter.scenariosui.ScreenshotUtil;
public class TestRunner extends AndroidJUnitRunner {
@Override
public void onCreate(Bundle arguments) {
ScreenshotUtil.onCreate(this, arguments);
super.onCreate(arguments);
}
@Override
public void finish(int resultCode, Bundle results) {
ScreenshotUtil.onDestroy();
super.finish(resultCode, results);
}
}
// 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.
package dev.flutter.scenarios;
import static org.junit.Assert.*;
......
// 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.
package dev.flutter.scenariosui;
import android.content.Intent;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
import androidx.test.runner.AndroidJUnit4;
import dev.flutter.scenarios.TextPlatformViewActivity;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
@LargeTest
public class PlatformTextureUiTests {
Intent intent;
@Rule
public ActivityTestRule<TextPlatformViewActivity> activityRule =
new ActivityTestRule<>(
TextPlatformViewActivity.class, /*initialTouchMode=*/ false, /*launchActivity=*/ false);
@Before
public void setUp() {
intent = new Intent(Intent.ACTION_MAIN);
}
@Test
public void testPlatformView() throws Exception {
intent.putExtra("scenario", "platform_view");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
@Test
public void testPlatformViewMultiple() throws Exception {
intent.putExtra("scenario", "platform_view_multiple");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
@Test
public void testPlatformViewMultipleBackgroundForeground() throws Exception {
intent.putExtra("scenario", "platform_view_multiple_background_foreground");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
@Test
public void testPlatformViewCliprect() throws Exception {
intent.putExtra("scenario", "platform_view_cliprect");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
@Test
public void testPlatformViewCliprrect() throws Exception {
intent.putExtra("scenario", "platform_view_cliprrect");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
@Test
public void testPlatformViewClippath() throws Exception {
intent.putExtra("scenario", "platform_view_clippath");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
@Test
public void testPlatformViewTransform() throws Exception {
intent.putExtra("scenario", "platform_view_transform");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
@Test
public void testPlatformViewOpacity() throws Exception {
intent.putExtra("scenario", "platform_view_opacity");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
@Test
public void testPlatformViewRotate() throws Exception {
intent.putExtra("scenario", "platform_view_rotate");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
@Test
public void testPlatformViewMultipleWithoutOverlays() throws Exception {
intent.putExtra("scenario", "platform_view_multiple_without_overlays");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
@Test
public void testPlatformViewTwoIntersectingOverlays() throws Exception {
intent.putExtra("scenario", "platform_view_two_intersecting_overlays");
ScreenshotUtil.capture(activityRule.launchActivity(intent));
}
}
// 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.
package dev.flutter.scenariosui;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Xml;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnitRunner;
import androidx.test.runner.screenshot.Screenshot;
import com.facebook.testing.screenshot.ScreenshotRunner;
import com.facebook.testing.screenshot.internal.AlbumImpl;
import com.facebook.testing.screenshot.internal.Registry;
import com.facebook.testing.screenshot.internal.TestNameDetector;
import dev.flutter.scenarios.TestableFlutterActivity;
import java.io.BufferedOutputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import org.xmlpull.v1.XmlSerializer;
/**
* Adapter for {@code com.facebook.testing.screenshot.Screenshot} that supports Flutter apps.
*
* <p>{@code com.facebook.testing.screenshot.Screenshot} relies on {@code View#draw(canvas)}, which
* doesn't draw Flutter's Surface or SurfaceTexture.
*
* <p>The workaround takes a full screenshot of the device and removes the status and action bars.
*/
public class ScreenshotUtil {
private XmlSerializer serializer;
private AlbumImpl album;
private OutputStream streamOutput;
private static ScreenshotUtil instance;
private static int BUFFER_SIZE = 1 << 16; // 64K
private static ScreenshotUtil getInstance() {
synchronized (ScreenshotUtil.class) {
if (instance == null) {
instance = new ScreenshotUtil();
}
return instance;
}
}
/** Starts the album, which contains the screenshots in a zip file, and a metadata.xml file. */
void init() {
if (serializer != null) {
return;
}
album = AlbumImpl.create(Registry.getRegistry().instrumentation.getContext(), "default");
// Delete all screenshots in the device associated with this album.
album.cleanup();
serializer = Xml.newSerializer();
try {
streamOutput =
new BufferedOutputStream(new FileOutputStream(album.getMetadataFile()), BUFFER_SIZE);
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
try {
serializer.setOutput(streamOutput, "utf-8");
serializer.startDocument("utf-8", null);
// Start tag <screenshots>.
serializer.startTag(null, "screenshots");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
void writeText(String tagName, String value) throws IOException {
if (serializer == null) {
throw new RuntimeException("ScreenshotUtil must be initialized. Call init().");
}
serializer.startTag(null, tagName);
serializer.text(value);
serializer.endTag(null, tagName);
}
void writeBitmap(Bitmap bitmap, String name, String testClass, String testName)
throws IOException {
if (serializer == null) {
throw new RuntimeException("ScreenshotUtil must be initialized. Call init().");
}
album.writeBitmap(name, 0, 0, bitmap);
serializer.startTag(null, "screenshot");
writeText("name", name);
writeText("test_class", testClass);
writeText("test_name", testName);
writeText("tile_width", "1");
writeText("tile_height", "1");
serializer.endTag(null, "screenshot");
}
/** Finishes metadata.xml. */
void flush() {
if (serializer == null) {
throw new RuntimeException("ScreenshotUtil must be initialized. Call init().");
}
try {
// End tag </screenshots>
serializer.endTag(null, "screenshots");
serializer.endDocument();
serializer.flush();
} catch (IOException e) {
throw new RuntimeException(e);
}
try {
streamOutput.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
album.flush();
serializer = null;
streamOutput = null;
album = null;
}
private static int getStatusBarHeight() {
final Context context = InstrumentationRegistry.getTargetContext();
// Resource name defined in
// https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/dimens.xml#34
final int resourceId =
context.getResources().getIdentifier("status_bar_height", "dimen", "android");
int statusBarHeight = 0;
if (resourceId > 0) {
statusBarHeight = context.getResources().getDimensionPixelSize(resourceId);
}
return statusBarHeight;
}
private static int getActionBarHeight(Activity activity) {
int actionBarHeight = 0;
final android.content.res.TypedArray styledAttributes =
activity.getTheme().obtainStyledAttributes(new int[] {android.R.attr.actionBarSize});
actionBarHeight = (int) styledAttributes.getDimension(0, 0);
styledAttributes.recycle();
return actionBarHeight;
}
/**
* Captures a screenshot of {@code TestableFlutterActivity}.
*
* <p>The activity must be already launched.
*/
public static void capture(TestableFlutterActivity activity)
throws InterruptedException, ExecutionException, IOException {
// Yield and wait for the engine to render the first Flutter frame.
activity.waitUntilFlutterRendered();
// This method is called from the runner thread,
// so block the UI thread while taking the screenshot.
// UiThreadLocker locker = new UiThreadLocker();
// locker.lock();
// Screenshot.capture(view or activity) does not capture the Flutter UI.
// Unfortunately, it doesn't work with Android's `Surface` or `TextureSurface`.
//
// As a result, capture a screenshot of the entire device and then clip
// the status and action bars.
//
// Under the hood, this call is similar to `adb screencap`, which is used
// to capture screenshots.
final String testClass = TestNameDetector.getTestClass();
final String testName = TestNameDetector.getTestName();
runCallableOnUiThread(
new Callable<Void>() {
@Override
public Void call() {
Bitmap bitmap = Screenshot.capture().getBitmap();
// Remove the status and action bars from the screenshot capture.
bitmap =
Bitmap.createBitmap(
bitmap,
0,
getStatusBarHeight(),
bitmap.getWidth(),
bitmap.getHeight() - getStatusBarHeight() - getActionBarHeight(activity));
final String screenshotName = String.format("%s__%s", testClass, testName);
// Write bitmap to the album.
try {
ScreenshotUtil.getInstance().writeBitmap(bitmap, screenshotName, testClass, testName);
} catch (IOException e) {
throw new RuntimeException(e);
}
return null;
}
});
}
/**
* Initializes the {@code com.facebook.testing.screenshot.internal.Album}.
*
* <p>Call this method from {@code AndroidJUnitRunner#onCreate}.
*/
public static void onCreate(AndroidJUnitRunner runner, Bundle arguments) {
ScreenshotRunner.onCreate(runner, arguments);
ScreenshotUtil.getInstance().init();
}
/**
* Flushes the {@code com.facebook.testing.screenshot.internal.Album}.
*
* <p>Call this method from {@code AndroidJUnitRunner#onDestroy}.
*/
public static void onDestroy() {
ScreenshotRunner.onDestroy();
ScreenshotUtil.getInstance().flush();
}
private static void runCallableOnUiThread(final Callable<Void> callable) {
if (Looper.getMainLooper().getThread() == Thread.currentThread()) {
try {
callable.call();
} catch (Exception e) {
e.printStackTrace();
}
return;
}
Handler handler = new Handler(Looper.getMainLooper());
final Object lock = new Object();
synchronized (lock) {
handler.post(
new Runnable() {
@Override
public void run() {
try {
callable.call();
} catch (Exception e) {
e.printStackTrace();
}
synchronized (lock) {
lock.notifyAll();
}
}
});
try {
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
......@@ -23,18 +23,11 @@
<data android:mimeType="application/javascript" />
</intent-filter>
</activity>
<activity android:name=".BlankActivity"
android:launchMode="singleTop"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
<uses-permission android:name="android.permission.INTERNET" />
......
// 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.
package dev.flutter.scenarios;
import android.os.Bundle;
......
// 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.
package dev.flutter.scenarios;
import android.os.Bundle;
import io.flutter.embedding.android.FlutterActivity;
public class TestableFlutterActivity extends FlutterActivity {
private Object flutterUiRenderedLock;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Reset the lock.
flutterUiRenderedLock = new Object();
}
protected void notifyFlutterRendered() {
synchronized (flutterUiRenderedLock) {
flutterUiRenderedLock.notifyAll();
}
}
public void waitUntilFlutterRendered() {
try {
synchronized (flutterUiRenderedLock) {
flutterUiRenderedLock.wait();
}
// Reset the lock.
flutterUiRenderedLock = new Object();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// 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.
......
// 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.
package dev.flutter.scenarios;
import android.Manifest;
......@@ -8,21 +12,22 @@ import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.Choreographer;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.android.FlutterActivity;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.embedding.engine.FlutterShellArgs;
import io.flutter.embedding.engine.loader.FlutterLoader;
import io.flutter.plugin.common.BasicMessageChannel;
import io.flutter.plugin.common.BinaryCodec;
import io.flutter.plugin.common.StringCodec;
import java.io.FileDescriptor;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.concurrent.atomic.AtomicBoolean;
public class TextPlatformViewActivity extends FlutterActivity {
public class TextPlatformViewActivity extends TestableFlutterActivity {
static final String TAG = "Scenarios";
@Override
......@@ -58,7 +63,6 @@ public class TextPlatformViewActivity extends FlutterActivity {
args.add(FlutterShellArgs.ARG_TRACE_STARTUP);
args.add(FlutterShellArgs.ARG_ENABLE_DART_PROFILING);
args.add(FlutterShellArgs.ARG_VERBOSE_LOGGING);
return args;
}
......@@ -70,6 +74,33 @@ public class TextPlatformViewActivity extends FlutterActivity {
.registerViewFactory("scenarios/textPlatformView", new TextPlatformViewFactory());
}
@Override
public void onFlutterUiDisplayed() {
final Intent launchIntent = getIntent();
if (!launchIntent.hasExtra("scenario")) {
return;
}
BasicMessageChannel<String> channel =
new BasicMessageChannel<>(
getFlutterEngine().getDartExecutor(), "set_scenario", StringCodec.INSTANCE);
channel.send(launchIntent.getStringExtra("scenario"));
notifyFlutterRenderedAfterVsync();
}
private void notifyFlutterRenderedAfterVsync() {
// Wait 1s after the next frame, so the Android texture are rendered.
Choreographer.getInstance()
.postFrameCallbackDelayed(
new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
reportFullyDrawn();
notifyFlutterRendered();
}
},
1000L);
}
private void writeTimelineData(Uri logFile) {
if (logFile == null) {
throw new IllegalArgumentException();
......
// Copyright 2018 The Chromium Authors. All rights reserved.
// 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.
......
......@@ -7,7 +7,8 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.0'
classpath 'com.android.tools.build:gradle:3.6.0'
classpath 'com.facebook.testing.screenshot:plugin:0.12.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
......@@ -18,10 +19,17 @@ allprojects {
repositories {
google()
jcenter()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
delete rootProject.buildDir
}
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
org.gradle.jvmargs=-Xmx1536M
android.enableR8=true
android.useAndroidX=true
android.enableJetifier=true
......@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip
......@@ -115,17 +115,17 @@ Future<Map<String, dynamic>> _getJson(Uri uri) async {
}
void _onBeginFrame(Duration duration) {
_currentScenario.onBeginFrame(duration);
_currentScenario?.onBeginFrame(duration);
}
void _onDrawFrame() {
_currentScenario.onDrawFrame();
_currentScenario?.onDrawFrame();
}
void _onMetricsChanged() {
_currentScenario.onMetricsChanged();
_currentScenario?.onMetricsChanged();
}
void _onPointerDataPacket(PointerDataPacket packet) {
_currentScenario.onPointerDataPacket(packet);
_currentScenario?.onPointerDataPacket(packet);
}
......@@ -624,8 +624,9 @@ mixin _BasePlatformViewScenarioMixin on Scenario {
'flutter/platform_views',
message.buffer.asByteData(),
(ByteData response) {
if (Platform.isAndroid) {
_textureId = response.getInt64(2);
if (response != null && Platform.isAndroid) {
// Envelope.
_textureId = response.getUint8(0);
}
},
);
......
......@@ -19,6 +19,6 @@ cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd
pushd android
set -o pipefail && ./gradlew assembleAndroidTest && ./gradlew connectedAndroidTest
set -o pipefail && ./gradlew app:verifyDebugAndroidTestScreenshotTest
popd
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册