chromium/base/test/android/javatests/src/org/chromium/base/test/BaseChromiumAndroidJUnitRunner.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.base.test;

import android.app.Activity;
import android.app.Application;
import android.app.Instrumentation;
import android.content.Context;
import android.content.ContextWrapper;
import android.os.Build.VERSION;
import android.os.Bundle;
import android.os.Looper;

import androidx.core.content.ContextCompat;
import androidx.test.InstrumentationRegistry;
import androidx.test.espresso.IdlingPolicies;
import androidx.test.internal.runner.ClassPathScanner;
import androidx.test.internal.runner.RunnerArgs;
import androidx.test.internal.runner.TestExecutor;
import androidx.test.internal.runner.TestRequestBuilder;
import androidx.test.runner.AndroidJUnitRunner;

import dalvik.system.DexFile;

import org.junit.runner.Request;
import org.junit.runner.RunWith;

import org.chromium.base.CommandLineInitUtil;
import org.chromium.base.ContextUtils;
import org.chromium.base.FileUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.ThreadUtils;
import org.chromium.base.library_loader.LibraryLoader;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.InMemorySharedPreferencesContext;
import org.chromium.base.test.util.MinAndroidSdkLevel;
import org.chromium.base.test.util.ScalableTimeout;
import org.chromium.base.test.util.TestAnimations;
import org.chromium.build.BuildConfig;
import org.chromium.testing.TestListInstrumentationRunListener;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

/**
 *
 *
 * <pre>
 * An Instrumentation subclass that:
 *    * Supports incremental install.
 *    * Installs an InMemorySharedPreferences, and a few other try-to-make-things-less-flaky things.
 * </pre>
 */
public class BaseChromiumAndroidJUnitRunner extends AndroidJUnitRunner {
    private static final String IS_UNIT_TEST_FLAG = "BaseChromiumAndroidJUnitRunner.IsUnitTest";
    private static final String EXTRA_TIMEOUT_SCALE = "BaseChromiumAndroidJUnitRunner.TimeoutScale";
    private static final String EXTRA_TRACE_FILE = "BaseChromiumAndroidJUnitRunner.TraceFile";

    private static final String ARGUMENT_LOG_ONLY = "log";

    private static final String TAG = "BaseJUnitRunner";

    private static final long WAIT_FOR_IDLE_TIMEOUT_MS = 10000L;

    static BaseChromiumAndroidJUnitRunner sInstance;
    static Application sApplication;
    static InMemorySharedPreferencesContext sInMemorySharedPreferencesContext;
    private static boolean sTestListMode;

    public BaseChromiumAndroidJUnitRunner() {
        sInstance = this;
    }

    @Override
    public Application newApplication(ClassLoader cl, String className, Context context)
            throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        // Must come before super.newApplication(), because Chrome's Application.attachBaseContext()
        // initializes command-line.
        CommandLineInitUtil.setFilenameOverrideForTesting(CommandLineFlags.getTestCmdLineFile());

        // Wrap |context| here so that calls to getSharedPreferences() from within
        // attachBaseContext() will hit our InMemorySharedPreferencesContext.
        sInMemorySharedPreferencesContext = new InMemorySharedPreferencesContext(context);
        Application ret = super.newApplication(cl, className, sInMemorySharedPreferencesContext);
        sApplication = ret;
        try {
            // There is framework code that assumes Application.getBaseContext() can be casted to
            // ContextImpl (on KitKat for broadcast receivers, refer to ActivityThread.java), so
            // invert the wrapping relationship.
            Field baseField = ContextWrapper.class.getDeclaredField("mBase");
            baseField.setAccessible(true);
            baseField.set(ret, context);
            baseField.set(sInMemorySharedPreferencesContext, ret);
        } catch (NoSuchFieldException e) {
            throw new RuntimeException(e);
        }

        // Replace the application with our wrapper here for any code that runs between
        // Application.attachBaseContext() and our BaseJUnit4TestRule (e.g. Application.onCreate()).
        ContextUtils.initApplicationContextForTests(sInMemorySharedPreferencesContext);
        return ret;
    }

    @Override
    public Context getTargetContext() {
        // The target context by default points directly at the ContextImpl, which we can't wrap.
        // Make it instead point at the Application.
        return sInMemorySharedPreferencesContext;
    }

    private static boolean isDefaultProcess() {
        return !ContextUtils.getProcessName().contains(":");
    }

    @Override
    public void onCreate(Bundle arguments) {
        if (!isDefaultProcess()) {
            super.onCreate(arguments);
            return;
        }
        if (arguments == null) {
            arguments = new Bundle();
        }
        sTestListMode = "true".equals(arguments.getString(ARGUMENT_LOG_ONLY));
        // Do not finish activities between tests so that batched tests can start
        // an activity in @BeforeClass and have it live until @AfterClass.
        arguments.putString("waitForActivitiesToComplete", "false");
        super.onCreate(arguments);
        if (!sTestListMode) {
            // Initialize before Application.onCreate() to ensure settings take effect.
            initTestRunner(arguments);
        }
    }

    /**
     * Add TestListInstrumentationRunListener when argument ask the runner to list tests info.
     *
     * <p>The running mechanism when argument has "listAllTests" is equivalent to that of {@link
     * androidx.test.runner.AndroidJUnitRunner#onStart()} except it adds only
     * TestListInstrumentationRunListener to monitor the tests.
     */
    @Override
    public void onStart() {
        if (!isDefaultProcess()) {
            throw new IllegalStateException();
        }
        Bundle arguments = InstrumentationRegistry.getArguments();
        if (sTestListMode) {
            Log.w(
                    TAG,
                    String.format(
                            "Runner will list out tests info in JSON without running tests. "
                                    + "Arguments: %s",
                            arguments.toString()));
            listTests(); // Intentionally not calling super.onStart() to avoid additional work.
        } else {
            ThreadUtils.recordInstrumentationThreadForTesting();
            // Full name required because the super class has a nested class of the same name.
            org.chromium.base.test.ActivityFinisher.finishAll();
            super.onStart();
        }
    }

    // Called on the UI thread.
    private void initTestRunner(Bundle arguments) {
        String timeoutScale = arguments.getString(EXTRA_TIMEOUT_SCALE);
        if (timeoutScale != null) {
            ScalableTimeout.setScale(Float.valueOf(timeoutScale));
        }
        CommandLineFlags.ensureInitialized();
        BaseJUnit4ClassRunner.clearJobSchedulerJobs();
        clearDataDirectory(sInMemorySharedPreferencesContext);
        setInTouchMode(true);
        // //third_party/mockito is looking for android.support.test.InstrumentationRegistry.
        // Manually set target to override. We can remove this once we roll mockito to support
        // androidx.test.
        System.setProperty(
                "org.mockito.android.target",
                sInMemorySharedPreferencesContext.getCacheDir().getPath());
        // Reduce the time Espresso waits before failing to be less than the Python test timeout.
        IdlingPolicies.setMasterPolicyTimeout(20, TimeUnit.SECONDS);
        if (arguments.getString(IS_UNIT_TEST_FLAG) != null) {
            LibraryLoader.setBrowserProcessStartupBlockedForTesting();
        }
        ResettersForTesting.enable();

        String traceOutput = arguments.getString(EXTRA_TRACE_FILE);
        if (traceOutput != null) {
            File traceOutputFile = new File(traceOutput);
            File traceOutputDir = traceOutputFile.getParentFile();

            if (traceOutputDir != null) {
                if (traceOutputDir.exists() || traceOutputDir.mkdirs()) {
                    TestTraceEvent.enable(traceOutputFile);
                }
            }
        }
    }

    // The Instrumentation implementation of waitForIdleSync does not have a timeout and can wait
    // indefinitely in the case of animations, etc.
    //
    // You should never use this function in new code, as waitForIdleSync hides underlying issues.
    // There's almost always a better condition to wait on.
    @Override
    public void waitForIdleSync() {
        final CallbackHelper idleCallback = new CallbackHelper();
        runOnMainSync(
                () -> {
                    Looper.myQueue()
                            .addIdleHandler(
                                    () -> {
                                        idleCallback.notifyCalled();
                                        return false;
                                    });
                });

        try {
            idleCallback.waitForOnly((int) WAIT_FOR_IDLE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
        } catch (TimeoutException ex) {
            Log.w(TAG, "Timeout while waiting for idle main thread.");
        }
    }

    private void listTests() {
        Bundle results = new Bundle();
        try {
            TestExecutor.Builder executorBuilder = new TestExecutor.Builder(this);
            executorBuilder.addRunListener(new TestListInstrumentationRunListener(true));

            // Do not use androidx's AndroidLogOnlyBuilder.
            //
            // We require BaseJUnit4ClassRunner to implement our test skipping / restrictions logic,
            // but ARGUMENT_LOG_ONLY means that our runner will not be used.
            // Remove the argument, and have BaseJUnit4ClassRunner run in no-op mode.
            Bundle junit4Arguments = new Bundle(InstrumentationRegistry.getArguments());
            junit4Arguments.remove(ARGUMENT_LOG_ONLY);

            Request listJUnit4TestRequest = createListTestRequest(junit4Arguments);
            results.putAll(executorBuilder.build().execute(listJUnit4TestRequest));
            finish(Activity.RESULT_OK, results);
        } catch (IOException | RuntimeException e) {
            String msg = "Fatal exception when running tests";
            Log.e(TAG, msg, e);
            results.putString(
                    Instrumentation.REPORT_KEY_STREAMRESULT,
                    msg + "\n" + Log.getStackTraceString(e));
            finish(Activity.RESULT_CANCELED, results);
        }
    }

    private Request createListTestRequest(Bundle arguments) {
        TestRequestBuilder builder;
        if (BuildConfig.IS_INCREMENTAL_INSTALL) {
            try {
                Class<?> bootstrapClass =
                        Class.forName("org.chromium.incrementalinstall.BootstrapApplication");
                DexFile[] incrementalInstallDexes =
                        (DexFile[])
                                bootstrapClass.getDeclaredField("sIncrementalDexFiles").get(null);
                builder =
                        new DexFileTestRequestBuilder(
                                this, arguments, Arrays.asList(incrementalInstallDexes));
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        } else {
            builder = new TestRequestBuilder(this, arguments);
        }
        RunnerArgs runnerArgs =
                new RunnerArgs.Builder().fromManifest(this).fromBundle(this, arguments).build();
        builder.addFromRunnerArgs(runnerArgs);
        builder.addPathToScan(getContext().getPackageCodePath());

        // Ignore tests from framework / support library classes.
        builder.removeTestPackage("android");
        builder.setClassLoader(new ForgivingClassLoader());
        return builder.build();
    }

    static boolean shouldListTests() {
        return sTestListMode;
    }

    /**
     * Wraps TestRequestBuilder to make it work with incremental install.
     *
     * <p>TestRequestBuilder does not know to look through the incremental install dex files, and
     * has no api for telling it to do so. This class checks to see if the list of tests was given
     * by the runner (mHasClassList), and if not overrides the auto-detection logic in build() to
     * manually scan all .dex files.
     */
    private static class DexFileTestRequestBuilder extends TestRequestBuilder {
        final List<String> mExcludedPrefixes = new ArrayList<String>();
        final List<String> mIncludedPrefixes = new ArrayList<String>();
        final List<DexFile> mDexFiles;
        boolean mHasClassList;
        private ClassLoader mClassLoader = DexFileTestRequestBuilder.class.getClassLoader();

        DexFileTestRequestBuilder(Instrumentation instr, Bundle bundle, List<DexFile> dexFiles) {
            super(instr, bundle);
            mDexFiles = dexFiles;
            mExcludedPrefixes.addAll(ClassPathScanner.getDefaultExcludedPackages());
        }

        @Override
        public TestRequestBuilder removeTestPackage(String testPackage) {
            mExcludedPrefixes.add(testPackage);
            return this;
        }

        @Override
        public TestRequestBuilder addFromRunnerArgs(RunnerArgs runnerArgs) {
            mExcludedPrefixes.addAll(runnerArgs.notTestPackages);
            mIncludedPrefixes.addAll(runnerArgs.testPackages);
            // Without clearing, You get IllegalArgumentException:
            // Ambiguous arguments: cannot provide both test package and test class(es) to run
            runnerArgs.notTestPackages.clear();
            runnerArgs.testPackages.clear();
            return super.addFromRunnerArgs(runnerArgs);
        }

        @Override
        public TestRequestBuilder addTestClass(String className) {
            mHasClassList = true;
            return super.addTestClass(className);
        }

        @Override
        public TestRequestBuilder addTestMethod(String testClassName, String testMethodName) {
            mHasClassList = true;
            return super.addTestMethod(testClassName, testMethodName);
        }

        @Override
        public TestRequestBuilder setClassLoader(ClassLoader loader) {
            mClassLoader = loader;
            return super.setClassLoader(loader);
        }

        @Override
        public Request build() {
            // If a test class was requested, then no need to iterate class loader.
            if (!mHasClassList) {
                // builder.addApkToScan uses new DexFile(path) under the hood, which on Dalvik OS's
                // assumes that the optimized dex is in the default location (crashes).
                // Perform our own dex file scanning instead as a workaround.
                scanDexFilesForTestClasses();
            }
            return super.build();
        }

        private static boolean startsWithAny(String str, List<String> prefixes) {
            for (String prefix : prefixes) {
                if (str.startsWith(prefix)) {
                    return true;
                }
            }
            return false;
        }

        private void scanDexFilesForTestClasses() {
            Log.i(TAG, "Scanning loaded dex files for test classes.");
            // Mirror TestRequestBuilder.getClassNamesFromClassPath().
            for (DexFile dexFile : mDexFiles) {
                Enumeration<String> classNames = dexFile.entries();
                while (classNames.hasMoreElements()) {
                    String className = classNames.nextElement();
                    if (!mIncludedPrefixes.isEmpty()
                            && !startsWithAny(className, mIncludedPrefixes)) {
                        continue;
                    }
                    if (startsWithAny(className, mExcludedPrefixes)) {
                        continue;
                    }
                    if (!className.endsWith("Test")) {
                        // Speeds up test listing to filter by name before
                        // trying to load the class. We have an ErrorProne
                        // check that enforces this convention:
                        // //tools/android/errorprone_plugin/src/org/chromium/tools/errorprone/plugin/TestClassNameCheck.java
                        // As of Dec 2019, this speeds up test listing on
                        // android-kitkat-arm-rel from 41s -> 23s.
                        continue;
                    }
                    if (!className.contains("$") && checkIfTest(className, mClassLoader)) {
                        addTestClass(className);
                    }
                }
            }
        }
    }

    /**
     * ClassLoader that translates NoClassDefFoundError into ClassNotFoundException.
     *
     * Required because Android's TestLoader class tries to load all classes, but catches only
     * ClassNotFoundException.
     *
     * One way NoClassDefFoundError is triggered is on Android L when a class extends a non-existent
     * class. See https://crbug.com/912690.
     */
    private static class ForgivingClassLoader extends ClassLoader {
        private final ClassLoader mDelegateLoader = getClass().getClassLoader();

        @Override
        public Class<?> loadClass(String name) throws ClassNotFoundException {
            try {
                var ret = mDelegateLoader.loadClass(name);
                // Prevent loading classes that should be skipped due to @MinAndroidSdkLevelon.
                // Loading them can cause NoClassDefFoundError to be thrown by junit when listing
                // methods (if methods contain types from higher sdk version).
                // E.g.: https://chromium-review.googlesource.com/c/chromium/src/+/4738415/1
                MinAndroidSdkLevel annotation = ret.getAnnotation(MinAndroidSdkLevel.class);
                if (annotation != null && annotation.value() > VERSION.SDK_INT) {
                    throw new ClassNotFoundException();
                }
                return ret;
            } catch (NoClassDefFoundError e) {
                throw new ClassNotFoundException(name, e);
            }
        }
    }

    private static boolean checkIfTest(String className, ClassLoader classLoader) {
        Class<?> loadedClass = tryLoadClass(className, classLoader);
        if (loadedClass != null && isTestClass(loadedClass)) {
            return true;
        }
        return false;
    }

    private static Class<?> tryLoadClass(String className, ClassLoader classLoader) {
        try {
            return Class.forName(className, false, classLoader);
        } catch (NoClassDefFoundError | ClassNotFoundException e) {
            return null;
        }
    }

    // Copied from android.support.test.runner code.
    private static boolean isTestClass(Class<?> loadedClass) {
        try {
            if (Modifier.isAbstract(loadedClass.getModifiers())) {
                Log.d(
                        TAG,
                        String.format(
                                "Skipping abstract class %s: not a test", loadedClass.getName()));
                return false;
            }
            if (loadedClass.isAnnotationPresent(RunWith.class)) {
                return true;
            }
            for (Method testMethod : loadedClass.getMethods()) {
                if (testMethod.isAnnotationPresent(org.junit.Test.class)) {
                    return true;
                }
            }
            Log.d(TAG, String.format("Skipping class %s: not a test", loadedClass.getName()));
            return false;
        } catch (Exception e) {
            // Defensively catch exceptions - Will throw runtime exception if it cannot load
            // methods.
            Log.w(TAG, String.format("%s in isTestClass for %s", e, loadedClass.getName()));
            return false;
        } catch (Error e) {
            // defensively catch Errors too
            Log.w(TAG, String.format("%s in isTestClass for %s", e, loadedClass.getName()));
            return false;
        }
    }

    @Override
    public void finish(int resultCode, Bundle results) {
        if (sTestListMode) {
            super.finish(resultCode, results);
            return;
        }

        // Leave animations in the default state.
        TestAnimations.setEnabled(true);

        // This will end up force stopping the package, so code after this line will not run.
        super.finish(resultCode, results);
    }

    // This method clears the data directory for the test apk, but device_utils.py clears the data
    // for the apk under test via `pm clear`. Fake module smoke tests in particular requires some
    // data to be kept for the apk under test: /sdcard/Android/data/package/files/local_testing
    private static void clearDataDirectory(Context targetContext) {
        File dataDir = ContextCompat.getDataDir(targetContext);
        File[] files = dataDir.listFiles();
        if (files == null) return;
        for (File file : files) {
            // Symlink to app's native libraries.
            if (file.getName().equals("lib")) {
                continue;
            }
            if (file.getName().equals("incremental-install-files")) {
                continue;
            }
            if (file.getName().equals("code_cache")) {
                continue;
            }
            // SharedPreferences handled by checkOrDeleteOnDiskSharedPreferences().
            if (file.getName().equals("shared_prefs")) {
                continue;
            }
            if (file.isDirectory()
                    && (file.getName().startsWith("app_") || file.getName().equals("cache"))) {
                // Directories are lazily created by PathUtils only once, and so can be cleared but
                // not removed.
                for (File subFile : file.listFiles()) {
                    if (!FileUtils.recursivelyDeleteFile(subFile, FileUtils.DELETE_ALL)) {
                        throw new RuntimeException(
                                "Could not delete file: " + subFile.getAbsolutePath());
                    }
                }
            } else if (!FileUtils.recursivelyDeleteFile(file, FileUtils.DELETE_ALL)) {
                throw new RuntimeException("Could not delete file: " + file.getAbsolutePath());
            }
        }
    }
}