chromium/testing/android/junit/java/src/org/chromium/testing/local/TestListComputer.java

// Copyright 2022 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.testing.local;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.junit.runner.Computer;
import org.junit.runner.Description;
import org.junit.runner.Runner;
import org.junit.runner.manipulation.Filter;
import org.junit.runner.manipulation.Filterable;
import org.junit.runner.manipulation.NoTestsRemainException;
import org.junit.runner.notification.RunNotifier;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.RunnerBuilder;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.LooperMode;
import org.robolectric.annotation.LooperMode.Mode;
import org.robolectric.internal.bytecode.SandboxConfig;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

class TestListComputer extends Computer {
    private final List<Description> mDescriptions = new ArrayList<>();
    private final Allowlist mShadowsAllowlist;

    TestListComputer(Allowlist shadowsAllowlist) {
        mShadowsAllowlist = shadowsAllowlist;
    }

    private String computeConfig(
            Description description,
            Set<String> instrumentedPackages,
            Set<String> instrumentedClasses) {
        // Cache key for the ClassLoaders is in SandboxFactory.getSdkEnvironment:
        // https://cs.android.com/android/platform/superproject/main/+/main:external/robolectric-shadows/robolectric/src/main/java/org/robolectric/internal/SandboxFactory.java?q=symbol%3A%5Cborg.robolectric.internal.SandboxFactory.getSdkEnvironment%5Cb%20case%3Ayes
        List<Class<?>> shadows = new ArrayList<>();
        LooperMode.Mode looperMode = Mode.PAUSED;
        ArrayList allAnnotations = new ArrayList();
        allAnnotations.addAll(Arrays.asList(description.getTestClass().getAnnotations()));
        allAnnotations.addAll(description.getAnnotations());
        for (var annotation : allAnnotations) {
            if (annotation instanceof SandboxConfig) {
                shadows.addAll(Arrays.asList(((SandboxConfig) annotation).shadows()));
            } else if (annotation instanceof Config) {
                shadows.addAll(Arrays.asList(((Config) annotation).shadows()));
                instrumentedPackages.addAll(
                        Arrays.asList(((Config) annotation).instrumentedPackages()));
            } else if (annotation instanceof LooperMode) {
                looperMode = ((LooperMode) annotation).value();
            }
        }
        for (var clazz : shadows) {
            Implements annotation = clazz.getAnnotation(Implements.class);
            if (annotation != null) {
                String className = annotation.className();
                if (className.isEmpty()) {
                    className = annotation.value().getName();
                }
                if (!mShadowsAllowlist.allow(className)) {
                    throwShadowException(clazz.getName(), className);
                }
                instrumentedClasses.add(className);
            }
        }
        String methodName = description.getMethodName();
        String sdkSuffix = "";
        // Note: parameterized tests can look like: "FooTest.testFoo[28][6]", where the second [] is
        // the parameterization.
        int startIdx = methodName.indexOf('[');
        if (startIdx != -1) {
            int endIdx = methodName.indexOf(']', startIdx);
            if (endIdx != -1) {
                sdkSuffix = methodName.substring(startIdx, endIdx + 1);
            }
        }
        return looperMode + sdkSuffix;
    }

    private void throwShadowException(String shadowClass, String shadowingClass) {
        String msg =
                """

            Found non-allowlisted Robolectric shadow: %s (shadowing %s).
            Please limit usage of shadows to non-application code by adding explicit test stubbing \
            logic via set*ForTesting() methods.

            See: https://chromium.googlesource.com/chromium/src/+/main/styleguide/java/java.md#testing
            Used allowlist: %s
            """
                        .formatted(shadowClass, shadowingClass, mShadowsAllowlist.getFilename());
        throw new RuntimeException(msg);
    }

    private class TestListRunner extends Runner implements Filterable {
        private final Runner mRunner;

        public TestListRunner(Runner contained) {
            mRunner = contained;
        }

        @Override
        public Description getDescription() {
            return mRunner.getDescription();
        }

        private void collectDescriptions(Description description) {
            if (description.getMethodName() != null) {
                mDescriptions.add(description);
            }
            for (Description child : description.getChildren()) {
                collectDescriptions(child);
            }
        }

        @Override
        public void run(RunNotifier notifier) {
            collectDescriptions(mRunner.getDescription());
        }

        @Override
        public void filter(Filter filter) throws NoTestsRemainException {
            if (mRunner instanceof Filterable) {
                ((Filterable) mRunner).filter(filter);
            }
        }
    }

    @Override
    public Runner getSuite(final RunnerBuilder builder, Class<?>[] classes)
            throws InitializationError {
        return super.getSuite(
                new RunnerBuilder() {
                    @Override
                    public Runner runnerForClass(Class<?> testClass) throws Throwable {
                        return new TestListRunner(builder.runnerForClass(testClass));
                    }
                },
                classes);
    }

    private static JSONObject getOrNewObject(JSONObject parent, String key) throws JSONException {
        JSONObject ret = parent.optJSONObject(key);
        if (ret == null) {
            ret = new JSONObject();
            parent.put(key, ret);
        }
        return ret;
    }

    private static JSONArray getOrNewArray(JSONObject parent, String key) throws JSONException {
        JSONArray ret = parent.optJSONArray(key);
        if (ret == null) {
            ret = new JSONArray();
            parent.put(key, ret);
        }
        return ret;
    }

    public void writeJson(File outputFile) throws FileNotFoundException, JSONException {
        try (PrintStream stream = new PrintStream(new FileOutputStream(outputFile))) {
            stream.print(createJson());
        }
    }

    JSONObject createJson() throws JSONException {
        var instrumentedPackages = new TreeSet<String>();
        var instrumentedClasses = new TreeSet<String>();
        JSONObject root = new JSONObject();

        JSONObject configsObj = new JSONObject();
        root.put("configs", configsObj);
        for (Description d : mDescriptions) {
            String config = computeConfig(d, instrumentedPackages, instrumentedClasses);
            JSONObject configObj = getOrNewObject(configsObj, config);
            JSONArray methodsArr = getOrNewArray(configObj, d.getClassName());
            methodsArr.put(d.getMethodName());
        }

        JSONArray arr = new JSONArray();
        root.put("instrumentedPackages", arr);
        for (String s : instrumentedPackages) {
            arr.put(s);
        }

        arr = new JSONArray();
        root.put("instrumentedClasses", arr);
        for (String s : instrumentedClasses) {
            arr.put(s);
        }
        return root;
    }
}