chromium/ui/android/javatests/src/org/chromium/ui/test/util/RenderTestRule.java

// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.ui.test.util;

import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.ImageView;

import androidx.annotation.StringDef;
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat;

import com.google.android.material.textfield.TextInputLayout;

import org.json.JSONException;
import org.json.JSONObject;
import org.junit.Assert;
import org.junit.rules.TestWatcher;
import org.junit.runner.Description;

import org.chromium.base.CommandLine;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Feature;
import org.chromium.base.test.util.TestThreadUtils;
import org.chromium.ui.UiUtils;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Arrays;
import java.util.concurrent.Callable;

/**
 * A TestRule for creating Render Tests. The comparison is performed using the Skia Gold image
 * diffing service on the host.
 *
 * General usage:
 *
 * <pre>
 * {@code
 *
 * @RunWith(BaseJUnit4ClassRunner.class)
 * public class MyTest extends BlankUiTestActivityTestCase {
 *     @Rule
 *     public RenderTestRule mRenderTestRule = new RenderTestRule.Builder()
 *             // Required. If using ANDROID_RENDER_TESTS_PUBLIC, the Builder can be created with
 *             // the shorthand RenderTestRule.Builder.withPublicCorpus().
 *             .setCorpus(RenderTestRule.Corpus.ANDROID_RENDER_TESTS_PUBLIC)
 *             // Required. If adding a test for the first time for a component, add the string
 *             // value to the Component @StringDef and @interface.
 *             .setBugComponent(RenderTestRule.Component.BLINK_FORMS_COLOR)
 *             // Optional, only necessary once a CL lands that should invalidate previous golden
 *             // images, e.g. a UI rework.
 *             .setRevision(2)
 *             // Optional, only necessary if you want a message to be associated with these
 *             // golden images and shown in the Gold web UI, e.g. the reason why the revision was
 *             // incremented.
 *             .setDescription("Material design rework")
 *             .build();
 *
 *     @Test
 *     // "RenderTest" feature required.
 *     @Feature({"RenderTest"})
 *     public void testViewAppearance() {
 *         // Setup the UI.
 *         ...
 *
 *         // Render the UI Elements.
 *         mRenderTestRule.render(bigWidgetView, "big_widget");
 *         mRenderTestRule.render(smallWidgetView, "small_widget");
 *     }
 * }
 *
 * }
 * </pre>
 *
 */
public class RenderTestRule extends TestWatcher {
    private static final String TAG = "RenderTest";

    private static final String SKIA_GOLD_FOLDER_RELATIVE = "/skia_gold";

    // State for a test class.
    private final String mOutputFolder;

    // State for a test method.
    private String mTestClassName;
    private String mFullTestName;
    private boolean mHasRenderTestFeature;

    /** Parameterized tests have a prefix inserted at the front of the test description. */
    private String mVariantPrefix;

    /** Prefix on the render test images that describes light/dark mode. */
    private String mNightModePrefix;

    private String mSkiaGoldCorpus;
    private int mSkiaGoldRevision;
    private String mSkiaGoldRevisionDescription;
    private boolean mFailOnUnsupportedConfigs;
    private String mBugComponent;

    @StringDef({
        Corpus.ANDROID_RENDER_TESTS_PUBLIC,
        Corpus.ANDROID_RENDER_TESTS_INTERNAL,
        Corpus.ANDROID_VR_RENDER_TESTS
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface Corpus {
        // Corpus for general use and public results.
        String ANDROID_RENDER_TESTS_PUBLIC = "android-render-tests";
        // Corpus for general use and internal results.
        String ANDROID_RENDER_TESTS_INTERNAL = "android-render-tests-internal";
        // Corpus for VR (virtual reality) features.
        String ANDROID_VR_RENDER_TESTS = "android-vr-render-tests";
    }

    @StringDef({
        Component.BLINK_CONTACTS,
        Component.BLINK_FORMS_COLOR,
        Component.BLINK_PAYMENTS,
        Component.FREEZE_DRIED_TABS,
        Component.PRIVACY,
        Component.PRIVACY_INCOGNITO,
        Component.SERVICES_SIGN_IN,
        Component.SERVICES_SYNC,
        Component.UI_BROWSER_AUTOFILL,
        Component.UI_BROWSER_BOOKMARKS,
        Component.UI_BROWSER_BUBBLES_PAGE_INFO,
        Component.UI_BROWSER_CONTENT_SUGGESTIONS,
        Component.UI_BROWSER_CONTENT_SUGGESTIONS_FEED,
        Component.UI_BROWSER_CONTENT_SUGGESTIONS_HISTORY,
        Component.UI_BROWSER_FIRST_RUN,
        Component.UI_BROWSER_INCOGNITO,
        Component.UI_BROWSER_INFOBARS,
        Component.UI_BROWSER_MEDIA_PICKER,
        Component.UI_BROWSER_MOBILE,
        Component.UI_BROWSER_MOBILE_APP_MENU,
        Component.UI_BROWSER_MOBILE_CONTEXT_MENU,
        Component.UI_BROWSER_MOBILE_CUSTOM_TABS,
        Component.UI_BROWSER_MOBILE_HUB,
        Component.UI_BROWSER_MOBILE_MESSAGES,
        Component.UI_BROWSER_MOBILE_RECENT_TABS,
        Component.UI_BROWSER_MOBILE_SETTINGS,
        Component.UI_BROWSER_MOBILE_START,
        Component.UI_BROWSER_MOBILE_TAB_GROUPS,
        Component.UI_BROWSER_MOBILE_TAB_SWITCHER,
        Component.UI_BROWSER_MOBILE_TAB_SWITCHER_GRID,
        Component.UI_BROWSER_NAVIGATION_GESTURENAV,
        Component.UI_BROWSER_NEW_TAB_PAGE,
        Component.UI_BROWSER_OMNIBOX,
        Component.UI_BROWSER_PASSWORDS,
        Component.UI_BROWSER_SEARCH_VOICE,
        Component.UI_BROWSER_SHARING,
        Component.UI_BROWSER_SHOPPING,
        Component.UI_BROWSER_SHOPPING_MERCHANT_TRUST,
        Component.UI_BROWSER_SHOPPING_PRICE_TRACKING,
        Component.UI_BROWSER_TOOLBAR,
        Component.UI_BROWSER_THUMBNAIL,
        Component.UI_BROWSER_WEB_APP_INSTALLS,
        Component.UI_SETTINGS_PRIVACY
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface Component {
        String BLINK_CONTACTS = "Blink>Contacts";
        String BLINK_FORMS_COLOR = "Blink>Forms>Color";
        String BLINK_PAYMENTS = "Blink>Payments";
        String BLINK_VIEW_TRANSITIONS = "Blink>ViewTransitions";
        String FREEZE_DRIED_TABS = "Internals>FreezeDriedTabs";
        String PRIVACY = "Privacy";
        String PRIVACY_INCOGNITO = "Privacy>Incognito";
        String SERVICES_SIGN_IN = "Services>SignIn";
        String SERVICES_SYNC = "Services>Sync";
        String UI_BROWSER_AUTOFILL = "UI>Browser>Autofill";
        String UI_BROWSER_BOOKMARKS = "UI>Browser>Bookmarks";
        String UI_BROWSER_BUBBLES_PAGE_INFO = "UI>Browser>Bubbles>PageInfo";
        String UI_BROWSER_CONTENT_SUGGESTIONS = "UI>Browser>ContentSuggestions";
        String UI_BROWSER_CONTENT_SUGGESTIONS_FEED = "UI>Browser>ContentSuggestions>Feed";
        String UI_BROWSER_CONTENT_SUGGESTIONS_HISTORY = "UI>Browser>ContentSuggestions>History";
        String UI_BROWSER_FIRST_RUN = "UI>Browser>FirstRun";
        String UI_BROWSER_INCOGNITO = "UI>Browser>Incognito";
        String UI_BROWSER_INFOBARS = "UI>Browser>Infobars";
        String UI_BROWSER_MEDIA_PICKER = "UI>Browser>MediaPicker";
        String UI_BROWSER_MOBILE = "UI>Browser>Mobile";
        String UI_BROWSER_MOBILE_APP_MENU = "UI>Browser>Mobile>AppMenu";
        String UI_BROWSER_MOBILE_CONTEXT_MENU = "UI>Browser>Mobile>ContextMenu";
        String UI_BROWSER_MOBILE_CUSTOM_TABS = "UI>Browser>Mobile>CustomTabs";
        String UI_BROWSER_MOBILE_HUB = "UI>Browser>Mobile>Hub";
        String UI_BROWSER_MOBILE_MESSAGES = "UI>Browser>Mobile>Messages";
        String UI_BROWSER_MOBILE_RECENT_TABS = "UI>Browser>Mobile>RecentTabs";
        String UI_BROWSER_MOBILE_SETTINGS = "UI>Browser>Mobile>Settings";
        String UI_BROWSER_MOBILE_START = "UI>Browser>Mobile>Start";
        String UI_BROWSER_MOBILE_TAB_GROUPS = "UI>Browser>Mobile>TabGroups";
        String UI_BROWSER_MOBILE_TAB_SWITCHER = "UI>Browser>Mobile>TabSwitcher";
        String UI_BROWSER_MOBILE_TAB_SWITCHER_GRID = "UI>Browser>Mobile>TabSwitcher>Grid";
        String UI_BROWSER_NAVIGATION_GESTURENAV = "UI>Browser>Navigation>GestureNav";
        String UI_BROWSER_NEW_TAB_PAGE = "UI>Browser>NewTabPage";
        String UI_BROWSER_OMNIBOX = "UI>Browser>Omnibox";
        String UI_BROWSER_PASSWORDS = "UI>Browser>Passwords";
        String UI_BROWSER_SEARCH_VOICE = "UI>Browser>Search>Voice";
        String UI_BROWSER_SHARING = "UI>Browser>Sharing";
        String UI_BROWSER_SHOPPING = "UI>Browser>Shopping";
        String UI_BROWSER_SHOPPING_MERCHANT_TRUST = "UI>Browser>Shopping>MerchantTrust";
        String UI_BROWSER_SHOPPING_PRICE_TRACKING = "UI>Browser>Shopping>PriceTracking";
        String UI_BROWSER_THUMBNAIL = "UI>Browser>Thumbnail";
        String UI_BROWSER_TOOLBAR = "UI>Browser>Toolbar";
        String UI_BROWSER_WEB_APP_INSTALLS = "UI>Browser>WebAppInstalls";
        String UI_SETTINGS_PRIVACY = "UI>Settings>Privacy";
    }

    // Skia Gold-specific constructor used by the builder.
    // Note that each corpus/description combination results in some additional initialization
    // on the host (~250 ms), so consider whether adding unique descriptions is necessary before
    // adding them to a bunch of test classes.
    protected RenderTestRule(
            int revision,
            @Corpus String corpus,
            String description,
            boolean failOnUnsupportedConfigs,
            @Component String component) {
        assert revision >= 0;
        // Don't have a default corpus so that users explicitly specify whether
        // they want their test results to be public or not.
        assert corpus != null;
        assert component != null;

        mSkiaGoldCorpus = corpus;
        mSkiaGoldRevisionDescription = description;
        mSkiaGoldRevision = revision;
        mFailOnUnsupportedConfigs = failOnUnsupportedConfigs;
        mBugComponent = component;

        // The output folder can be overridden with the --render-test-output-dir command.
        mOutputFolder = CommandLine.getInstance().getSwitchValue("render-test-output-dir");
    }

    @Override
    protected void starting(Description desc) {
        // desc.getClassName() gets the fully qualified name.
        mTestClassName = desc.getTestClass().getSimpleName();
        mFullTestName = desc.getClassName() + "#" + desc.getMethodName();

        Feature feature = desc.getAnnotation(Feature.class);
        mHasRenderTestFeature =
                (feature != null && Arrays.asList(feature.value()).contains("RenderTest"));
    }

    /**
     * Renders the |view| and compares it to the golden view with the |id|. Image comparison is
     * performed on the host after the test has finished running. Comparison will fail if the given
     * image does not exactly match one of the images in Gold and the image came from a device that
     * should have baselines maintained (see the RENDER_TEST_MODEL_SDK_CONFIGS constant in the
     * Python test runner code at
     * //build/android/pylib/local/device/local_device_instrumentation_test_run.py).
     *
     * @throws IOException if the rendered image cannot be saved to the device.
     */
    public void render(final View view, String id) throws IOException {
        Assert.assertNotNull(view);
        Assert.assertTrue("Render Tests must have the RenderTest feature.", mHasRenderTestFeature);

        // De-flake by flushing the tasks that are already queued on the Looper's Handler.
        // TODO(crbug.com/40260566): Remove this and properly fix flaky tests.
        TestThreadUtils.flushNonDelayedLooperTasks();
        Bitmap testBitmap =
                ThreadUtils.runOnUiThreadBlocking(
                        new Callable<Bitmap>() {
                            @Override
                            public Bitmap call() {
                                int height = view.getMeasuredHeight();
                                int width = view.getMeasuredWidth();
                                if (height <= 0 || width <= 0) {
                                    throw new IllegalStateException(
                                            "Invalid view dimensions: " + width + "x" + height);
                                }

                                return UiUtils.generateScaledScreenshot(
                                        view, 0, Bitmap.Config.ARGB_8888);
                            }
                        });

        compareForResult(testBitmap, id);
    }

    /**
     * Compares the given |testBitmap| to the images in Gold for |id|. Image comparison is performed
     * on the host after the test has finished running. Comparison will fail if the given image
     * does not exactly match one of the images in Gold and the image came from a device that should
     * have baselines maintained (see the RENDER_TEST_MODEL_SDK_CONFIGS constant in the Python test
     * runner code at //build/android/pylib/local/device/local_device_instrumentation_test_run.py).
     *
     * Tests should prefer {@link RenderTestRule#render(View, String) render} to this if possible.
     *
     * @throws IOException if the rendered image cannot be saved to the device.
     */
    public void compareForResult(Bitmap testBitmap, String id) throws IOException {
        Assert.assertTrue("Render Tests must have the RenderTest feature.", mHasRenderTestFeature);

        // Save the image and its metadata to a location where it can be pulled by the test runner
        // for comparison after the test finishes.
        String imageName = getImageName(mTestClassName, mVariantPrefix, id);
        String jsonName = getJsonName(mTestClassName, mVariantPrefix, id);

        saveBitmap(testBitmap, createOutputPath(SKIA_GOLD_FOLDER_RELATIVE, imageName));
        JSONObject goldKeys = new JSONObject();
        JSONObject optionalKeys = new JSONObject();
        try {
            goldKeys.put("source_type", mSkiaGoldCorpus);
            goldKeys.put("model", Build.MODEL);
            goldKeys.put("sdk_version", String.valueOf(Build.VERSION.SDK_INT));
            if (!TextUtils.isEmpty(mSkiaGoldRevisionDescription)) {
                optionalKeys.put("revision_description", mSkiaGoldRevisionDescription);
            }
            optionalKeys.put(
                    "fail_on_unsupported_configs", String.valueOf(mFailOnUnsupportedConfigs));
            optionalKeys.put("bug_component", mBugComponent);
            // This key will be deleted by the test runner before uploading to Gold. It is used to
            // differentiate results from different tests if the test runner has batched multiple
            // tests together in a single run.
            goldKeys.put("full_test_name", mFullTestName);
            // This key will be deleted by the test runner and its contents passed into Gold as
            // optional key/value pairs. These are purely informational as opposed to indicating
            // something that might have an effect on test output such as device model.
            goldKeys.put("optional_keys", optionalKeys);
        } catch (JSONException e) {
            Assert.fail("Failed to create Skia Gold JSON keys: " + e.toString());
        }
        saveString(goldKeys.toString(), createOutputPath(SKIA_GOLD_FOLDER_RELATIVE, jsonName));
    }

    /**
     * Searches the View hierarchy and modifies the Views to provide better stability in tests. For
     * example it will disable the blinking cursor in EditTexts.
     */
    public static void sanitize(View view) {
        // Add more sanitizations as we discover more flaky attributes.
        if (view instanceof ViewGroup) {
            ViewGroup viewGroup = (ViewGroup) view;
            for (int i = 0; i < viewGroup.getChildCount(); i++) {
                sanitize(viewGroup.getChildAt(i));
            }
        } else if (view instanceof EditText) {
            EditText editText = (EditText) view;
            editText.setCursorVisible(false);
        } else if (view instanceof ImageView) {
            Drawable drawable = ((ImageView) view).getDrawable();
            if (drawable instanceof AnimatedVectorDrawableCompat) {
                ((AnimatedVectorDrawableCompat) drawable).stop();
            }
        }
        if (view instanceof TextInputLayout) {
            TextInputLayout textInputLayout = (TextInputLayout) view;
            textInputLayout.setHintAnimationEnabled(false);
        }
        // Scrollbars fade slowly, making tests flaky due to differences in rendered images.
        view.setVerticalScrollBarEnabled(false);
    }

    /**
     * Sets a string that will be inserted at the start of the description in the golden image name.
     * This is used to create goldens for multiple different variants of the UI.
     */
    public void setVariantPrefix(String variantPrefix) {
        mVariantPrefix = variantPrefix;
    }

    /** Sets a string prefix that describes the light/dark mode in the golden image name. */
    public void setNightModeEnabled(boolean nightModeEnabled) {
        mNightModePrefix = nightModeEnabled ? "NightModeEnabled" : "NightModeDisabled";
    }

    /**
     * Creates an image name combining the image description with details about the device
     * (e.g. current orientation).
     */
    private String getImageName(String testClass, String variantPrefix, String desc) {
        return String.format("%s.png", getFileName(testClass, variantPrefix, desc));
    }

    /**
     * Creates a JSON name combining the description with details about the device (e.g. current
     * orientation).
     */
    private String getJsonName(String testClass, String variantPrefix, String desc) {
        return String.format("%s.json", getFileName(testClass, variantPrefix, desc));
    }

    /**
     * Creates a generic filename (without a file extension) combining the description with details
     * about the device (e.g. current orientation).
     */
    private String getFileName(String testClass, String variantPrefix, String desc) {
        if (!TextUtils.isEmpty(mNightModePrefix)) {
            desc = mNightModePrefix + "-" + desc;
        }

        if (!TextUtils.isEmpty(variantPrefix)) {
            desc = variantPrefix + "-" + desc;
        }

        return String.format("%s.%s.rev_%s", testClass, desc, mSkiaGoldRevision);
    }

    /**
     * Returns a string encoding the device model and sdk. It is used to identify device goldens.
     */
    private static String modelSdkIdentifier() {
        return Build.MODEL.replace(' ', '_') + "-" + Build.VERSION.SDK_INT;
    }

    /** Saves a the given |bitmap| to the |file|. */
    private static void saveBitmap(Bitmap bitmap, File file) throws IOException {
        FileOutputStream out = new FileOutputStream(file);
        try {
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
        } finally {
            out.close();
        }
    }

    /** Saves the given |string| to the |file|. */
    private static void saveString(String string, File file) throws IOException {
        try (PrintWriter out = new PrintWriter(file)) {
            out.println(string);
        }
    }

    /**
     * Convenience method to create a File pointing to |filename| in the |subfolder| in
     * |mOutputFolder|.
     */
    private File createOutputPath(String subfolder, String filename) throws IOException {
        return createPath(mOutputFolder + subfolder, filename);
    }

    private static File createPath(String folder, String filename) throws IOException {
        File path = new File(folder);
        if (!path.exists()) {
            if (!path.mkdirs()) {
                throw new IOException("Could not create " + path.getAbsolutePath());
            }
        }
        return new File(path + "/" + filename);
    }

    /** Base Builder class for creating RenderTestRules and its derivatives. */
    protected abstract static class BaseBuilder<B extends BaseBuilder<B>> {
        protected int mRevision;
        protected @Corpus String mCorpus;
        protected String mDescription;
        protected boolean mFailOnUnsupportedConfigs;
        protected @Component String mBugComponent;

        /**
         * Sets the revision that will be appended to the test name reported to Gold. This should
         * be incremented anytime output changes significantly enough that previous baselines
         * should be considered invalid.
         */
        public B setRevision(int revision) {
            mRevision = revision;
            return self();
        }

        /** Sets the corpus in the Gold instance that images belong to. */
        public B setCorpus(@Corpus String corpus) {
            mCorpus = corpus;
            return self();
        }

        /**
         * Sets the optional description that will be shown alongside the image in the Gold web UI.
         */
        public B setDescription(String description) {
            mDescription = description;
            return self();
        }

        /**
         * Sets whether failures should still be reported on unsupported hardware/software configs.
         * Supported configurations are listed in the Python test runner code in
         * //build/android/pylib/local/device/local_device_instrumentation_test_run.py under the
         * RENDER_TEST_MODEL_SDK_CONFIGS constant.
         */
        public B setFailOnUnsupportedConfigs(boolean fail) {
            mFailOnUnsupportedConfigs = fail;
            return self();
        }

        /** Sets the bug component that will be shown alongside the image in the Gold web UI. */
        public B setBugComponent(@Component String component) {
            mBugComponent = component;
            return self();
        }

        protected B self() {
            return (B) this;
        }

        public abstract RenderTestRule build();
    }

    /** Builder to create a RenderTestRule. */
    public static class Builder extends BaseBuilder<Builder> {
        @Override
        public RenderTestRule build() {
            return new RenderTestRule(
                    mRevision, mCorpus, mDescription, mFailOnUnsupportedConfigs, mBugComponent);
        }

        /** Creates a Builder with the default public corpus. */
        public static Builder withPublicCorpus() {
            return new Builder().setCorpus(Corpus.ANDROID_RENDER_TESTS_PUBLIC);
        }
    }
}