chromium/android_webview/javatests/src/org/chromium/android_webview/test/devui/FlagsFragmentTest.java

// Copyright 2020 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.android_webview.test.devui;

import static androidx.test.espresso.Espresso.onData;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.replaceText;
import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.RootMatchers.isDialog;
import static androidx.test.espresso.matcher.ViewMatchers.hasSibling;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withSpinnerText;
import static androidx.test.espresso.matcher.ViewMatchers.withText;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.anything;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.lessThan;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.collection.IsMapContaining.hasEntry;

import static org.chromium.android_webview.test.devui.DeveloperUiTestUtils.withCount;

import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.os.SystemClock;
import android.text.SpannableString;
import android.text.style.BackgroundColorSpan;
import android.view.MotionEvent;
import android.view.View;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.TextView;

import androidx.annotation.IntDef;
import androidx.test.espresso.DataInteraction;
import androidx.test.espresso.Espresso;
import androidx.test.espresso.NoMatchingRootException;
import androidx.test.filters.MediumTest;
import androidx.test.filters.SmallTest;

import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.android_webview.common.AwSwitches;
import org.chromium.android_webview.common.DeveloperModeUtils;
import org.chromium.android_webview.common.Flag;
import org.chromium.android_webview.devui.FlagsFragment;
import org.chromium.android_webview.devui.MainActivity;
import org.chromium.android_webview.devui.R;
import org.chromium.android_webview.nonembedded_util.WebViewPackageHelper;
import org.chromium.android_webview.services.DeveloperUiService;
import org.chromium.android_webview.test.AwJUnit4ClassRunner;
import org.chromium.base.ContextUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.BaseActivityTestRule;
import org.chromium.base.test.util.ApplicationTestUtils;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.CriteriaNotSatisfiedException;
import org.chromium.base.test.util.DoNotBatch;
import org.chromium.base.test.util.Feature;
import org.chromium.ui.test.util.ViewUtils;

import java.util.Arrays;
import java.util.Map;

/**
 * UI tests for {@link FlagsFragment}.
 *
 * <p>These tests should not be batched to make sure that the DeveloperUiService is killed after
 * each test, leaving a clean state.
 */
@RunWith(AwJUnit4ClassRunner.class)
@DoNotBatch(reason = "Clean up DeveloperUiService after each test")
public class FlagsFragmentTest {
    @Rule
    public BaseActivityTestRule<MainActivity> mRule =
            new BaseActivityTestRule<>(MainActivity.class);

    private static final Flag[] sMockFlagList = {
        Flag.commandLine(
                "first-switch-for-testing",
                "Fake switch for testing. This is at the start of the mock flag list."),
        Flag.commandLine(
                AwSwitches.HIGHLIGHT_ALL_WEBVIEWS,
                "Highlight the contents (including web contents) of all WebViews with a yellow "
                        + "tint. This is useful for identifying WebViews in an Android "
                        + "application."),
        Flag.commandLine(
                AwSwitches.WEBVIEW_VERBOSE_LOGGING,
                "WebView will log additional debugging information to logcat, such as "
                        + "variations and commandline state."),
        // Validity check: make sure omitting the description doesn't cause any crashes
        Flag.baseFeature("FeatureWithoutDescription"),
        Flag.baseFeature("FakeWebViewFeatureForTesting", "Fake base::Feature for testing."),
        Flag.commandLine(
                "last-switch-for-testing",
                "Fake switch for testing. This is at the end of the mock flag list."),
        // Add new commandline switches and features above. The final entry should have a
        // trailing comma for cleaner diffs.
    };

    @Before
    public void setUp() throws Exception {
        Context context = ContextUtils.getApplicationContext();
        Intent intent = new Intent(context, MainActivity.class);
        MainActivity.markPopupPermissionRequestedInPrefsForTesting();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    FlagsFragment.setFlagListForTesting(sMockFlagList);
                    DeveloperUiService.setFlagListForTesting(sMockFlagList);
                });
        WebViewPackageHelper.setCurrentWebViewPackageForTesting(
                WebViewPackageHelper.getContextPackageInfo(context));

        intent.putExtra(MainActivity.FRAGMENT_ID_INTENT_EXTRA, MainActivity.FRAGMENT_ID_FLAGS);
        mRule.launchActivity(intent);

        waitForInflatedFlagFragment();
    }

    @After
    public void tearDown() {
        // Make sure to clear shared preferences between tests to avoid any saved state.
        DeveloperUiService.clearSharedPrefsForTesting(ContextUtils.getApplicationContext());
    }

    private void waitForInflatedFlagFragment() {
        // Espresso is normally configured to automatically wait for the main thread to go idle, but
        // BaseActivityTestRule turns that behavior off so we must explicitly wait for the View
        // hierarchy to inflate.
        ViewUtils.waitForVisibleView(withId(R.id.navigation_flags_ui));
        ViewUtils.waitForVisibleView(withId(R.id.navigation_home));
        ViewUtils.waitForVisibleView(withId(R.id.flag_search_bar));
        ViewUtils.waitForVisibleView(withId(R.id.flags_list));
        ViewUtils.waitForVisibleView(withId(R.id.reset_flags_button));

        // For some reasons, the blinking Text Cursor can make the UI thread very busy.
        // This leads to AppNotIdleException and flaky tests, because Espresso could not find a 15ms
        // gap between calls to update UI thread. To fix this, we should just hide the edit text
        // cursor. It does not change the test functionality, but will eliminate one source of
        // flakiness.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    EditText searchBar = mRule.getActivity().findViewById(R.id.flag_search_bar);
                    searchBar.setCursorVisible(false);
                });

        // Always close the soft keyboard when the activity is launched which is sometimes shown
        // because flags search TextView has input focus by default. The keyboard may cover up some
        // Views causing test flakiness/failures.
        Espresso.closeSoftKeyboard();
    }

    private CallbackHelper getFlagUiSearchBarListener() {
        final CallbackHelper helper = new CallbackHelper();
        FlagsFragment.setFilterListenerForTesting(
                () -> {
                    helper.notifyCalled();
                });
        return helper;
    }

    private static Matcher<View> withHintText(final Matcher<String> stringMatcher) {
        return new TypeSafeMatcher<View>() {
            @Override
            public boolean matchesSafely(View view) {
                if (!(view instanceof EditText)) {
                    return false;
                }
                String hint = ((EditText) view).getHint().toString();
                return stringMatcher.matches(hint);
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("with hint text: ");
                stringMatcher.describeTo(description);
            }
        };
    }

    private static Matcher<View> withHintText(final String expectedHint) {
        return withHintText(is(expectedHint));
    }

    private static Matcher<View> containingHighlightSpan() {
        return new TypeSafeMatcher<View>() {
            @Override
            public boolean matchesSafely(View view) {
                if (!(view instanceof TextView)) {
                    return false;
                }
                CharSequence text = ((TextView) view).getText();
                if (!(text instanceof SpannableString)) {
                    return false;
                }
                BackgroundColorSpan[] spans =
                        ((SpannableString) text)
                                .getSpans(0, text.length(), BackgroundColorSpan.class);
                return Arrays.stream(spans)
                        .anyMatch(span -> span.getBackgroundColor() == Color.YELLOW);
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("containing highlight span");
            }
        };
    }

    @IntDef({
        CompoundDrawable.START,
        CompoundDrawable.TOP,
        CompoundDrawable.END,
        CompoundDrawable.BOTTOM
    })
    private @interface CompoundDrawable {
        int START = 0;
        int TOP = 1;
        int END = 2;
        int BOTTOM = 3;
    }

    private static Matcher<View> compoundDrawableVisible(@CompoundDrawable int position) {
        return new TypeSafeMatcher<View>() {
            @Override
            public boolean matchesSafely(View view) {
                if (!(view instanceof TextView)) {
                    return false;
                }
                Drawable[] compoundDrawables = ((TextView) view).getCompoundDrawablesRelative();
                Drawable endDrawable = compoundDrawables[position];
                return endDrawable != null;
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("with drawable in position " + position);
            }
        };
    }

    // Click a TextView at the start/end/top/bottom. Does not check if any CompoundDrawable drawable
    // is in that position, it just sends a touch event for those coordinates.
    private static void tapCompoundDrawableOnUiThread(
            TextView view, @CompoundDrawable int position) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    long downTime = SystemClock.uptimeMillis();
                    long eventTime = downTime + 50;
                    float x = view.getWidth() / 2.0f;
                    float y = view.getHeight() / 2.0f;
                    if (position == CompoundDrawable.START) {
                        x = 0.0f;
                    } else if (position == CompoundDrawable.END) {
                        x = view.getWidth();
                    } else if (position == CompoundDrawable.TOP) {
                        y = 0.0f;
                    } else if (position == CompoundDrawable.BOTTOM) {
                        y = view.getHeight();
                    }

                    int metaState =
                            0; // no modifier keys (ex. alt/control), this is just a touch event
                    view.dispatchTouchEvent(
                            MotionEvent.obtain(
                                    downTime, eventTime, MotionEvent.ACTION_UP, x, y, metaState));
                });
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testHasPublicNoArgsConstructor() throws Throwable {
        FlagsFragment fragment = new FlagsFragment();
        Assert.assertNotNull(fragment);
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testSearchEmptyByDefault() throws Throwable {
        onView(withId(R.id.flag_search_bar)).check(matches(withText("")));
        onView(withId(R.id.flag_search_bar)).check(matches(withHintText("Search flags")));

        // Magnifier should always be visible, "clear text" icon should be hidden
        onView(withId(R.id.flag_search_bar))
                .check(matches(compoundDrawableVisible(CompoundDrawable.START)));
        onView(withId(R.id.flag_search_bar))
                .check(matches(not(compoundDrawableVisible(CompoundDrawable.END))));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testSearchByName() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();

        int searchBarChangeCount = helper.getCallCount();
        onView(withId(R.id.flag_search_bar)).perform(replaceText("verbose-logging"));
        helper.waitForCallback(searchBarChangeCount, 1);
        onView(allOf(withId(R.id.flag_name), withText(AwSwitches.WEBVIEW_VERBOSE_LOGGING)))
                .check(matches(isDisplayed()));
        onView(withId(R.id.flags_list)).check(matches(withCount(1)));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testSearchByDescription() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();

        int searchBarChangeCount = helper.getCallCount();
        onView(withId(R.id.flag_search_bar)).perform(replaceText("highlight the contents"));
        helper.waitForCallback(searchBarChangeCount, 1);
        onView(allOf(withId(R.id.flag_name), withText(AwSwitches.HIGHLIGHT_ALL_WEBVIEWS)))
                .check(matches(isDisplayed()));
        onView(withId(R.id.flags_list)).check(matches(withCount(1)));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testSearchMatchingNameAndDescriptionWithIndividualWordsInQuery() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();

        int searchBarChangeCount = helper.getCallCount();
        // The "verbose" part matches the name, and the "logcat" part matches the description.
        onView(withId(R.id.flag_search_bar)).perform(replaceText("verbose logcat"));
        helper.waitForCallback(searchBarChangeCount, 1);
        onView(allOf(withId(R.id.flag_name), withText(AwSwitches.WEBVIEW_VERBOSE_LOGGING)))
                .check(matches(isDisplayed()));
        onView(withId(R.id.flags_list)).check(matches(withCount(1)));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testSearchHighlightingQueryWordsInFlagName() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();
        int searchBarChangeCount = helper.getCallCount();
        // "verbose" appears in the flag name, but not the description
        onView(withId(R.id.flag_search_bar)).perform(replaceText("verbose"));
        helper.waitForCallback(searchBarChangeCount, 1);

        Matcher<View> flagNameMatcher =
                allOf(withId(R.id.flag_name), withText(AwSwitches.WEBVIEW_VERBOSE_LOGGING));
        onView(flagNameMatcher).check(matches(containingHighlightSpan()));
        onView(allOf(withId(R.id.flag_description), hasSibling(flagNameMatcher)))
                .check(matches(not(containingHighlightSpan())));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testSearchHighlightingQueryWordsInFlagDescription() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();
        int searchBarChangeCount = helper.getCallCount();
        // "logcat" appears in the flag description, but not the name
        onView(withId(R.id.flag_search_bar)).perform(replaceText("logcat"));
        helper.waitForCallback(searchBarChangeCount, 1);

        Matcher<View> flagNameMatcher =
                allOf(withId(R.id.flag_name), withText(AwSwitches.WEBVIEW_VERBOSE_LOGGING));
        onView(flagNameMatcher).check(matches(not(containingHighlightSpan())));
        onView(allOf(withId(R.id.flag_description), hasSibling(flagNameMatcher)))
                .check(matches(containingHighlightSpan()));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testSearchHighlightingQueryWordsInFlagNameAndDescription() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();
        int searchBarChangeCount = helper.getCallCount();
        // "log" appears in both the flag name and the description
        onView(withId(R.id.flag_search_bar)).perform(replaceText("log"));
        helper.waitForCallback(searchBarChangeCount, 1);

        Matcher<View> flagNameMatcher =
                allOf(withId(R.id.flag_name), withText(AwSwitches.WEBVIEW_VERBOSE_LOGGING));
        onView(flagNameMatcher).check(matches(containingHighlightSpan()));
        onView(allOf(withId(R.id.flag_description), hasSibling(flagNameMatcher)))
                .check(matches(containingHighlightSpan()));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testCaseInsensitive() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();

        int searchBarChangeCount = helper.getCallCount();
        onView(withId(R.id.flag_search_bar)).perform(replaceText("VERBOSE-LOGGING"));
        helper.waitForCallback(searchBarChangeCount, 1);
        onView(allOf(withId(R.id.flag_name), withText(AwSwitches.WEBVIEW_VERBOSE_LOGGING)))
                .check(matches(isDisplayed()));
        onView(withId(R.id.flags_list)).check(matches(withCount(1)));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testMultipleResults() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();

        ListView flagsList = mRule.getActivity().findViewById(R.id.flags_list);
        int totalNumFlags = flagsList.getCount();

        // This assumes:
        //  * There will always be > 1 flag which mentions WebView explicitly (ex.
        //    HIGHLIGHT_ALL_WEBVIEWS and WEBVIEW_VERBOSE_LOGGING)
        //  * There will always be >= 1 flag which does not mention WebView in its description (ex.
        //    --show-composited-layer-borders).
        int searchBarChangeCount = helper.getCallCount();
        onView(withId(R.id.flag_search_bar)).perform(replaceText("webview"));
        helper.waitForCallback(searchBarChangeCount, 1);
        onView(withId(R.id.flags_list))
                .check(matches(withCount(allOf(greaterThan(1), lessThan(totalNumFlags)))));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testClearingSearchShowsAllFlags() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();

        ListView flagsList = mRule.getActivity().findViewById(R.id.flags_list);
        int totalNumFlags = flagsList.getCount();

        int searchBarChangeCount = helper.getCallCount();
        onView(withId(R.id.flag_search_bar)).perform(replaceText("verbose-logging"));
        helper.waitForCallback(searchBarChangeCount, 1);
        onView(withId(R.id.flags_list)).check(matches(withCount(1)));

        onView(withId(R.id.flag_search_bar)).perform(replaceText(""));
        helper.waitForCallback(searchBarChangeCount, 2);
        onView(withId(R.id.flags_list)).check(matches(withCount(totalNumFlags)));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testTappingClearButtonClearsText() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();

        int searchBarChangeCount = helper.getCallCount();
        onView(withId(R.id.flag_search_bar)).perform(replaceText("verbose-logging"));
        helper.waitForCallback(searchBarChangeCount, 1);

        // "x" icon should visible if there's some text
        onView(withId(R.id.flag_search_bar))
                .check(matches(compoundDrawableVisible(CompoundDrawable.END)));

        EditText searchBar = mRule.getActivity().findViewById(R.id.flag_search_bar);
        tapCompoundDrawableOnUiThread(searchBar, CompoundDrawable.END);

        // "x" icon disappears when text is cleared
        onView(withId(R.id.flag_search_bar)).check(matches(withText("")));
        onView(withId(R.id.flag_search_bar))
                .check(matches(not(compoundDrawableVisible(CompoundDrawable.END))));

        // Magnifier icon should still be visible
        onView(withId(R.id.flag_search_bar))
                .check(matches(compoundDrawableVisible(CompoundDrawable.START)));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testDeletingTextHidesClearTextButton() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();

        int searchBarChangeCount = helper.getCallCount();
        onView(withId(R.id.flag_search_bar)).perform(replaceText("verbose-logging"));
        helper.waitForCallback(searchBarChangeCount, 1);

        // "x" icon should visible if there's some text
        onView(withId(R.id.flag_search_bar))
                .check(matches(compoundDrawableVisible(CompoundDrawable.END)));

        onView(withId(R.id.flag_search_bar)).perform(replaceText(""));

        // "x" icon disappears when text is cleared
        onView(withId(R.id.flag_search_bar)).check(matches(withText("")));
        onView(withId(R.id.flag_search_bar))
                .check(matches(not(compoundDrawableVisible(CompoundDrawable.END))));

        // Magnifier icon should still be visible
        onView(withId(R.id.flag_search_bar))
                .check(matches(compoundDrawableVisible(CompoundDrawable.START)));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testElsewhereOnSearchBarDoesNotClearText() throws Throwable {
        CallbackHelper helper = getFlagUiSearchBarListener();

        int searchBarChangeCount = helper.getCallCount();
        onView(withId(R.id.flag_search_bar)).perform(replaceText("verbose-logging"));
        helper.waitForCallback(searchBarChangeCount, 1);

        EditText searchBar = mRule.getActivity().findViewById(R.id.flag_search_bar);
        tapCompoundDrawableOnUiThread(searchBar, CompoundDrawable.TOP);

        // EditText should not be cleared
        onView(withId(R.id.flag_search_bar)).check(matches(withText("verbose-logging")));

        // "x" icon is still visible
        onView(withId(R.id.flag_search_bar))
                .check(matches(compoundDrawableVisible(CompoundDrawable.END)));
    }

    /**
     * Toggle a flag's spinner with the given state value, and check that text changes to the
     * correct value.
     *
     * @param flagInteraction the {@link DataInteraction} object representing a flag View item via
     *         {@code onData()}.
     * @param state {@code true} for "enabled", {@code false} for "disabled", {@code null} for
     *         Default.
     * @return the same {@code flagInteraction} passed param for the ease of chaining.
     */
    private DataInteraction toggleFlag(DataInteraction flagInteraction, Boolean state) {
        String stateText = state == null ? "Default" : state ? "Enabled" : "Disabled";
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        // We first select the spinner on the list of flags.
                        // That will make a dialog appear witch the option we wish to select.
                        flagInteraction.onChildView(withId(R.id.flag_toggle)).perform(click());
                        // We then select the state we want from the dialog.
                        onView(withText(stateText)).inRoot(isDialog()).perform(click());
                    } catch (NoMatchingRootException noMatchException) {
                        // Espresso is flaky with dialogs from Spinners.
                        // It seems to rarely not open the dialog.
                        // To avoid this happening, the tests will re-attempt
                        // to select a flag if the root (ie the dialog), was not
                        // found.
                        // This is safe to do because the first click is explicitly on
                        // a flag toggle, and the second click is explicitly in a dialog.
                        // See crbug.com/1400515 for more details.
                        throw new CriteriaNotSatisfiedException(noMatchException);
                    }
                });
        // Finally we confirm that the original spinner was updated after the dialog option has
        // been selected.
        flagInteraction
                .onChildView(withId(R.id.flag_toggle))
                .check(matches(withSpinnerText(containsString(stateText))));

        return flagInteraction;
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    /** Verify if the baseFeature flag contains only "Default", "Enabled" , "Disabled" states. */
    public void testFlagStates_baseFeature() throws Throwable {
        ListView flagsList = mRule.getActivity().findViewById(R.id.flags_list);

        int firstBaseFeaturePosition = -1;
        for (int i = 1; i < flagsList.getCount(); i++) {
            if (((Flag) flagsList.getAdapter().getItem(i)).isBaseFeature()) {
                firstBaseFeaturePosition = i;
                break;
            }
        }
        Assert.assertNotEquals(
                "Flags list should have at least one base feature flag",
                -1,
                firstBaseFeaturePosition);

        testFlagStatesHelper(firstBaseFeaturePosition);
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    /** Verify if the commandline flag contains only "Default", "Enabled" states. */
    public void testFlagStates_commandLineFlag() throws Throwable {
        ListView flagsList = mRule.getActivity().findViewById(R.id.flags_list);

        int firstCommandLineFlagPosition = -1;
        for (int i = 1; i < flagsList.getCount(); i++) {
            if (!((Flag) flagsList.getAdapter().getItem(i)).isBaseFeature()) {
                firstCommandLineFlagPosition = i;
                break;
            }
        }
        Assert.assertNotEquals(
                "Flags list should have at least one commandline flag",
                -1,
                firstCommandLineFlagPosition);

        testFlagStatesHelper(firstCommandLineFlagPosition);
    }

    /** Helper method to verify that flag only contains the appropriate states */
    private void testFlagStatesHelper(int flagPosition) {
        ListView flagsList = mRule.getActivity().findViewById(R.id.flags_list);
        Flag flag = (Flag) flagsList.getAdapter().getItem(flagPosition);
        DataInteraction flagInteraction =
                onData(anything()).inAdapterView(withId(R.id.flags_list)).atPosition(flagPosition);

        // click open the spinner containing the states of the commandline flag
        flagInteraction.onChildView(withId(R.id.flag_toggle)).perform(click());

        // assert that the flag contains only the two states
        onView(withText("Default")).check(matches(isDisplayed()));
        onView(withText("Enabled")).check(matches(isDisplayed()));

        if (flag.isBaseFeature()) {
            onView(withText("Disabled")).check(matches(isDisplayed()));
        } else {
            onView(withText("Disabled")).check(doesNotExist());
        }
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testTogglingFlagShowsBlueDot_baseFeature() throws Throwable {
        ListView flagsList = mRule.getActivity().findViewById(R.id.flags_list);

        int firstBaseFeaturePosition = -1;
        for (int i = 1; i < flagsList.getCount(); i++) {
            if (((Flag) flagsList.getAdapter().getItem(i)).isBaseFeature()) {
                firstBaseFeaturePosition = i;
                break;
            }
        }
        Assert.assertNotEquals(
                "Flags list should have at least one baseFeature Flag",
                -1,
                firstBaseFeaturePosition);

        testTogglingFlagShowsBlueDotHelper(firstBaseFeaturePosition);
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testTogglingFlagShowsBlueDot_commandLineFlag() throws Throwable {
        ListView flagsList = mRule.getActivity().findViewById(R.id.flags_list);

        int firstCommandLineFlagPosition = -1;
        for (int i = 1; i < flagsList.getCount(); i++) {
            if (!((Flag) flagsList.getAdapter().getItem(i)).isBaseFeature()) {
                firstCommandLineFlagPosition = i;
                break;
            }
        }
        Assert.assertNotEquals(
                "Flags list should have at least one commandline flag",
                -1,
                firstCommandLineFlagPosition);

        testTogglingFlagShowsBlueDotHelper(firstCommandLineFlagPosition);
    }

    private void testTogglingFlagShowsBlueDotHelper(int flagPosition) {
        ListView flagsList = mRule.getActivity().findViewById(R.id.flags_list);
        Flag flag = (Flag) flagsList.getAdapter().getItem(flagPosition);

        DataInteraction flagInteraction =
                onData(anything()).inAdapterView(withId(R.id.flags_list)).atPosition(flagPosition);

        // blue dot should be hidden by default
        flagInteraction
                .onChildView(withId(R.id.flag_name))
                .check(matches(not(compoundDrawableVisible(CompoundDrawable.START))));

        // Test enabling flags shows a bluedot next to flag name
        toggleFlag(flagInteraction, true);
        flagInteraction
                .onChildView(withId(R.id.flag_name))
                .check(matches(compoundDrawableVisible(CompoundDrawable.START)));

        // Test setting to default hide the blue dot
        toggleFlag(flagInteraction, null);
        flagInteraction
                .onChildView(withId(R.id.flag_name))
                .check(matches(not(compoundDrawableVisible(CompoundDrawable.START))));

        // Test disabling flags shows a bluedot next to flag name, only applies to BaseFeatures
        if (flag.isBaseFeature()) {
            toggleFlag(flagInteraction, false);
            flagInteraction
                    .onChildView(withId(R.id.flag_name))
                    .check(matches(compoundDrawableVisible(CompoundDrawable.START)));
        }
        // Test setting to default again hide the blue dot
        toggleFlag(flagInteraction, null);
        flagInteraction
                .onChildView(withId(R.id.flag_name))
                .check(matches(not(compoundDrawableVisible(CompoundDrawable.START))));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testToggledFlagsFloatToTop() throws Throwable {
        ListView flagsList = mRule.getActivity().findViewById(R.id.flags_list);
        int totalNumFlags = flagsList.getCount();
        String lastFlagName = ((Flag) flagsList.getAdapter().getItem(totalNumFlags - 1)).getName();

        // Toggle the last flag in the list.
        toggleFlag(
                onData(anything())
                        .inAdapterView(withId(R.id.flags_list))
                        .atPosition(totalNumFlags - 1),
                true);
        // Navigate from the flags UI then back to it to trigger list sorting.
        onView(withId(R.id.navigation_home)).perform(click());
        onView(withId(R.id.navigation_flags_ui)).perform(click());

        // Check that the toggled flag is now at the top of the list. This assumes that the flags
        // list has > 2 items.
        onData(anything())
                .inAdapterView(withId(R.id.flags_list))
                .atPosition(1)
                .onChildView(withId(R.id.flag_name))
                .check(matches(withText(lastFlagName)));

        // Reset to default.
        toggleFlag(onData(anything()).inAdapterView(withId(R.id.flags_list)).atPosition(1), null);
        // Navigate from the flags UI then back to it to trigger list sorting.
        onView(withId(R.id.navigation_home)).perform(click());
        onView(withId(R.id.navigation_flags_ui)).perform(click());
        // Check that flags goes back to the end of the list when untoggled.
        onData(anything())
                .inAdapterView(withId(R.id.flags_list))
                .atPosition(totalNumFlags - 1)
                .onChildView(withId(R.id.flag_name))
                .check(matches(withText(lastFlagName)));
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testResetFlags() throws Throwable {
        ListView flagsList = mRule.getActivity().findViewById(R.id.flags_list);
        String firstFlagName = ((Flag) flagsList.getAdapter().getItem(1)).getName();

        toggleFlag(onData(anything()).inAdapterView(withId(R.id.flags_list)).atPosition(1), true);
        Map<String, Boolean> flagOverrides =
                DeveloperModeUtils.getFlagOverrides(DeveloperUiTest.TEST_WEBVIEW_PACKAGE_NAME);
        assertThat(
                "flagOverrides map should contain exactly one entry", flagOverrides.size(), is(1));
        assertThat(flagOverrides, hasEntry(firstFlagName, true));

        onView(withId(R.id.reset_flags_button)).perform(click());

        DataInteraction flagInteraction =
                onData(anything()).inAdapterView(withId(R.id.flags_list)).atPosition(1);
        flagInteraction
                .onChildView(withId(R.id.flag_name))
                .check(matches(not(compoundDrawableVisible(CompoundDrawable.START))));
        flagInteraction
                .onChildView(withId(R.id.flag_toggle))
                .check(matches(withSpinnerText(containsString("Default"))));
        Assert.assertTrue(
                DeveloperModeUtils.getFlagOverrides(DeveloperUiTest.TEST_WEBVIEW_PACKAGE_NAME)
                        .isEmpty());
    }

    @Test
    @MediumTest
    @Feature({"AndroidWebView"})
    public void testResetFlagsByIntent() throws Throwable {
        // 1. First test that the intent resets the flags
        // Given one flag is set
        toggleFlag(onData(anything()).inAdapterView(withId(R.id.flags_list)).atPosition(1), true);
        Assert.assertFalse(
                DeveloperModeUtils.getFlagOverrides(DeveloperUiTest.TEST_WEBVIEW_PACKAGE_NAME)
                        .isEmpty());

        // Close the activity
        ApplicationTestUtils.finishActivity(mRule.getActivity());

        // And when the activity is relaunched with the reset flag
        // which should clear the flags
        Intent intent = new Intent(ContextUtils.getApplicationContext(), MainActivity.class);
        intent.putExtra(MainActivity.FRAGMENT_ID_INTENT_EXTRA, MainActivity.FRAGMENT_ID_FLAGS);
        intent.putExtra(MainActivity.RESET_FLAGS_INTENT_EXTRA, true);
        mRule.launchActivity(intent);
        waitForInflatedFlagFragment();

        // Then the flags will be empty
        DataInteraction flagInteraction =
                onData(anything()).inAdapterView(withId(R.id.flags_list)).atPosition(1);
        flagInteraction
                .onChildView(withId(R.id.flag_name))
                .check(matches(not(compoundDrawableVisible(CompoundDrawable.START))));
        flagInteraction
                .onChildView(withId(R.id.flag_toggle))
                .check(matches(withSpinnerText(containsString("Default"))));
        Assert.assertTrue(
                DeveloperModeUtils.getFlagOverrides(DeveloperUiTest.TEST_WEBVIEW_PACKAGE_NAME)
                        .isEmpty());

        // 2. Then test that the intent will not keep causing resets
        // Given a flag is set again
        toggleFlag(onData(anything()).inAdapterView(withId(R.id.flags_list)).atPosition(1), true);
        Assert.assertFalse(
                DeveloperModeUtils.getFlagOverrides(DeveloperUiTest.TEST_WEBVIEW_PACKAGE_NAME)
                        .isEmpty());

        // And navigate away from flags
        onView(withId(R.id.navigation_home)).perform(click());
        onView(withId(R.id.fragment_home)).check(matches(isDisplayed()));

        // When navigating back to the flags fragment
        onView(withId(R.id.navigation_flags_ui)).perform(click());
        waitForInflatedFlagFragment();

        // Then the flags should still be there
        Assert.assertFalse(
                DeveloperModeUtils.getFlagOverrides(DeveloperUiTest.TEST_WEBVIEW_PACKAGE_NAME)
                        .isEmpty());
    }
}