chromium/base/test/android/javatests/src/org/chromium/base/test/util/CommandLineFlags.java

// Copyright 2015 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.util;

import android.text.TextUtils;

import androidx.annotation.Nullable;

import org.chromium.base.CommandLine;
import org.chromium.base.CommandLineInitUtil;
import org.chromium.base.Log;
import org.chromium.base.test.util.Features.DisableFeatures;
import org.chromium.base.test.util.Features.EnableFeatures;

import java.lang.annotation.Annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Provides annotations for setting command-line flags. Enabled by default for Robolectric and
 * on-device tests.
 */
public final class CommandLineFlags {
    private static final String TAG = "CommandLineFlags";
    private static final String DISABLE_FEATURES = "disable-features";
    private static final String ENABLE_FEATURES = "enable-features";
    // Features set by original command-line --enable-features / --disable-features.
    private static Map<String, Boolean> sOrigFeatures = Collections.emptyMap();
    private static final Map<String, String> sActiveFlagPrevValues = new HashMap<>();

    /** Adds command-line flags to the {@link org.chromium.base.CommandLine} for this test. */
    @Inherited
    @Retention(RetentionPolicy.RUNTIME)
    @Target({ElementType.METHOD, ElementType.TYPE})
    public @interface Add {
        String[] value();
    }

    /**
     * Removes command-line flags from the {@link org.chromium.base.CommandLine} from this test.
     *
     * Note that this can only be applied to test methods. This restriction is due to complexities
     * in resolving the order that annotations are applied, and given how rare it is to need to
     * remove command line flags, this annotation must be applied directly to each test method
     * wishing to remove a flag.
     */
    @Inherited
    @Retention(RetentionPolicy.RUNTIME)
    @Target({ElementType.METHOD})
    public @interface Remove {
        String[] value();
    }

    public static void ensureInitialized() {
        if (!CommandLine.isInitialized()) {
            // Override in a persistent way so that if command-line is re-initialized by code
            // under-test, it will still use the test flags file.
            CommandLineInitUtil.setFilenameOverrideForTesting(getTestCmdLineFile());
            CommandLineInitUtil.initCommandLine(null, () -> true);
            // Store features from initial command-line for proper merging later.
            CommandLine commandLine = CommandLine.getInstance();
            String origEnabledFeatures = commandLine.getSwitchValue(ENABLE_FEATURES, "");
            String origDisabledFeatures = commandLine.getSwitchValue(ENABLE_FEATURES, "");
            sOrigFeatures =
                    collectFeaturesFromFlags(
                            List.of(
                                    ENABLE_FEATURES + "=" + origEnabledFeatures,
                                    DISABLE_FEATURES + "=" + origDisabledFeatures));
        }
    }

    private static void processAnnotations(Annotation[] annotations, List<String> flags) {
        for (Annotation annotation : annotations) {
            if (annotation instanceof CommandLineFlags.Add addAnnotation) {
                Collections.addAll(flags, addAnnotation.value());
            } else if (annotation instanceof CommandLineFlags.Remove removeAnnotation) {
                flags.removeAll(Arrays.asList(removeAnnotation.value()));
            } else if (annotation instanceof EnableFeatures) {
                for (String featureName : ((EnableFeatures) annotation).value()) {
                    flags.add(ENABLE_FEATURES + "=" + featureName);
                }
            } else if (annotation instanceof DisableFeatures) {
                for (String featureName : ((DisableFeatures) annotation).value()) {
                    flags.add(DISABLE_FEATURES + "=" + featureName);
                }
            }
        }
    }

    public static void reset(
            Annotation[] classAnnotations, @Nullable Annotation[] methodAnnotations) {
        Features.resetCachedFlags();
        List<String> newFlags = new ArrayList<>();
        processAnnotations(classAnnotations, newFlags);
        if (methodAnnotations != null) {
            processAnnotations(methodAnnotations, newFlags);
        }
        Map<String, Boolean> flagStates = collectFeaturesFromFlags(newFlags);
        newFlags = updateFeatureFlags(newFlags, flagStates);
        boolean anyChanges = applyChanges(newFlags);
        // If flags did not change, and no feature-related flags are present, then do not clobber
        // flag values so that a test can use FeatureList.setTestValues() in @BeforeClass.
        if (anyChanges || !flagStates.isEmpty()) {
            Features.reset(flagStates);
        }
    }

    private static boolean applyChanges(List<String> newFlags) {
        // Track and apply changes in flags (rather than clearing each time) because flags are added
        // as part of normal start-up (which need to be maintained).
        boolean anyChanges = false;
        CommandLine commandLine = CommandLine.getInstance();
        Set<String> newFlagNames = new HashSet<>();
        for (String flag : newFlags) {
            String[] keyValue = flag.split("=", 2);
            String flagName = keyValue[0];
            String flagValue = keyValue.length == 1 ? "" : keyValue[1];
            String prevValue =
                    commandLine.hasSwitch(flagName) ? commandLine.getSwitchValue(flagName) : null;
            newFlagNames.add(flagName);
            if (!flagValue.equals(prevValue)) {
                anyChanges = true;
                commandLine.appendSwitchWithValue(flagName, flagValue);
                if (!sActiveFlagPrevValues.containsKey(flagName)) {
                    sActiveFlagPrevValues.put(flagName, prevValue);
                }
            }
        }
        // Undo previously applied flags.
        for (var it = sActiveFlagPrevValues.entrySet().iterator(); it.hasNext(); ) {
            var entry = it.next();
            String flagName = entry.getKey();
            String flagValue = entry.getValue();
            if (!newFlagNames.contains(flagName)) {
                anyChanges = true;
                if (flagValue == null) {
                    commandLine.removeSwitch(flagName);
                } else {
                    commandLine.appendSwitchWithValue(flagName, flagValue);
                }
                it.remove();
            }
        }
        Log.i(
                TAG,
                "Java %scommand line set to: %s",
                CommandLine.isNativeImplementationForTesting() ? "(and native) " : "",
                serializeCommandLine());
        return anyChanges;
    }

    private static String serializeCommandLine() {
        Map<String, String> switches = CommandLine.getInstance().getSwitches();
        if (switches.isEmpty()) {
            return "";
        }
        StringBuilder sb = new StringBuilder();
        for (var entry : switches.entrySet()) {
            sb.append("--").append(entry.getKey());
            if (!TextUtils.isEmpty(entry.getValue())) {
                sb.append('=').append(entry.getValue());
            }
            sb.append(' ');
        }
        sb.setLength(sb.length() - 1);
        return sb.toString();
    }

    private static Map<String, Boolean> collectFeaturesFromFlags(List<String> flags) {
        // Collect via a Map rather than two lists to correctly handle the a feature being enabled
        // via class flags and disabled via method flags (or vice versa).
        Map<String, Boolean> flagStates = new HashMap<>(sOrigFeatures);
        for (String flag : flags) {
            String[] keyValue = flag.split("=", 2);
            boolean enable = ENABLE_FEATURES.equals(keyValue[0]);
            if (!enable && !DISABLE_FEATURES.equals(keyValue[0])) {
                continue;
            }
            if (keyValue.length == 1 || keyValue[1].isEmpty()) {
                continue;
            }
            for (String featureName : keyValue[1].split(",")) {
                flagStates.put(featureName, enable);
            }
        }
        return flagStates;
    }

    private static List<String> updateFeatureFlags(
            List<String> curFlags, Map<String, Boolean> flagStates) {
        List<String> newFlags = new ArrayList<>();
        for (String flag : curFlags) {
            String flagName = flag.split("=", 2)[0];
            if (!ENABLE_FEATURES.equals(flagName) && !DISABLE_FEATURES.equals(flagName)) {
                newFlags.add(flag);
            }
        }

        List<String> enabledFlags = new ArrayList<>();
        List<String> disabledFlags = new ArrayList<>();
        for (var entry : flagStates.entrySet()) {
            var target = entry.getValue() ? enabledFlags : disabledFlags;
            target.add(entry.getKey());
        }
        if (!enabledFlags.isEmpty()) {
            newFlags.add(
                    String.format("%s=%s", ENABLE_FEATURES, TextUtils.join(",", enabledFlags)));
        }
        if (!disabledFlags.isEmpty()) {
            newFlags.add(
                    String.format("%s=%s", DISABLE_FEATURES, TextUtils.join(",", disabledFlags)));
        }
        return newFlags;
    }

    private CommandLineFlags() {}

    public static String getTestCmdLineFile() {
        return "test-cmdline-file";
    }
}