chromium/chrome/android/javatests/src/org/chromium/chrome/browser/ViewTransitionPixelTest.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.chrome.browser;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.text.TextUtils;

import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.MediumTest;

import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.Feature;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.util.ChromeRenderTestRule;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.test.util.DOMUtils;
import org.chromium.content_public.browser.test.util.JavaScriptUtils;
import org.chromium.net.test.EmbeddedTestServer;
import org.chromium.ui.mojom.VirtualKeyboardMode;
import org.chromium.ui.test.util.RenderTestRule;

import java.io.File;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

/**
 * Tests view-transitions API in relation to Android Chrome UI.
 *
 * <p>These test that view transitions result in correctly sized and positioned transitions in the
 * presence/absence of UI such as virtual-keyboard and moveable URL bar.
 *
 * <p>See https://www.w3.org/TR/css-view-transitions-1/
 */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({
    ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE,
    "enable-features=ViewTransitionOnNavigation",
    // Resampling can make scroll offsets non-deterministic so turn it off to ensure hiding browser
    // controls works reliably;
    // Disable edge to edge as part of the test is measuring the keyboard's height, which differs
    // depending on whether Chrome is drawn e2e.
    "disable-features=ResamplingScrollEvents,DrawCutoutEdgeToEdge,EdgeToEdgeBottomChin",
    "hide-scrollbars"
})
@Batch(Batch.PER_CLASS)
public class ViewTransitionPixelTest {
    @Rule
    public ChromeTabbedActivityTestRule mActivityTestRule = new ChromeTabbedActivityTestRule();

    @Rule
    public ChromeRenderTestRule mRenderTestRule =
            ChromeRenderTestRule.Builder.withPublicCorpus()
                    .setBugComponent(RenderTestRule.Component.BLINK_VIEW_TRANSITIONS)
                    .build();

    private static final String TEXTFIELD_DOM_ID = "inputElement";
    private static final int TEST_TIMEOUT = 10000;

    private EmbeddedTestServer mTestServer;

    private ViewportTestUtils mViewportTestUtils;

    private int mInitialPageHeight;
    private double mInitialVVHeight;

    @VirtualKeyboardMode.EnumType
    private int mVirtualKeyboardMode = VirtualKeyboardMode.RESIZES_VISUAL;

    @Before
    public void setUp() {
        mTestServer =
                EmbeddedTestServer.createAndStartServer(
                        ApplicationProvider.getApplicationContext());
        mViewportTestUtils = new ViewportTestUtils(mActivityTestRule);
        mViewportTestUtils.setUpForBrowserControls();
    }

    private void startKeyboardTest(@VirtualKeyboardMode.EnumType int vkMode) throws Throwable {
        mVirtualKeyboardMode = vkMode;
        String url = "/chrome/test/data/android/view_transition.html";

        if (mVirtualKeyboardMode == VirtualKeyboardMode.RESIZES_VISUAL) {
            url += "?resizes-visual";
        } else if (mVirtualKeyboardMode == VirtualKeyboardMode.RESIZES_CONTENT) {
            url += "?resizes-content";
        } else {
            Assert.fail("Unexpected virtual keyboard mode");
        }

        mActivityTestRule.startMainActivityWithURL(mTestServer.getURL(url));
        mActivityTestRule.waitForActivityNativeInitializationComplete();

        mInitialPageHeight = mViewportTestUtils.getPageInnerHeightPx();
        mInitialVVHeight = mViewportTestUtils.getVisualViewportHeightPx();
    }

    private void assertWaitForKeyboardStatus(final boolean show) {
        CriteriaHelper.pollUiThread(
                () -> {
                    boolean isKeyboardShowing =
                            mActivityTestRule
                                    .getKeyboardDelegate()
                                    .isKeyboardShowing(
                                            mActivityTestRule.getActivity(),
                                            mActivityTestRule.getActivity().getTabsView());
                    Criteria.checkThat(isKeyboardShowing, Matchers.is(show));
                },
                TEST_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    private WebContents getWebContents() {
        return mActivityTestRule.getActivity().getActivityTab().getWebContents();
    }

    private void showAndWaitForKeyboard() throws Throwable {
        DOMUtils.clickNode(getWebContents(), TEXTFIELD_DOM_ID);
        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        mActivityTestRule
                                .getActivity()
                                .getManualFillingComponent()
                                .forceShowForTesting());

        assertWaitForKeyboardStatus(true);

        double keyboardHeight = getKeyboardHeightDp();

        if (mVirtualKeyboardMode == VirtualKeyboardMode.RESIZES_VISUAL) {
            mViewportTestUtils.waitForExpectedVisualViewportHeight(
                    mInitialVVHeight - keyboardHeight);
        } else if (mVirtualKeyboardMode == VirtualKeyboardMode.RESIZES_CONTENT) {
            mViewportTestUtils.waitForExpectedPageHeight(mInitialPageHeight - keyboardHeight);
        } else {
            Assert.fail("Unimplemented keyboard mode");
        }
    }

    private void hideKeyboard() throws Throwable {
        ThreadUtils.runOnUiThreadBlocking(
                () -> mActivityTestRule.getActivity().getManualFillingComponent().hide());
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                getWebContents(), "document.activeElement.blur()");
    }

    private void waitForKeyboardHidden() {
        assertWaitForKeyboardStatus(false);
        if (mVirtualKeyboardMode == VirtualKeyboardMode.RESIZES_VISUAL) {
            mViewportTestUtils.waitForExpectedVisualViewportHeight(mInitialVVHeight);
        } else if (mVirtualKeyboardMode == VirtualKeyboardMode.RESIZES_CONTENT) {
            mViewportTestUtils.waitForExpectedPageHeight(mInitialPageHeight);
        } else {
            Assert.fail("Unimplemented keyboard mode");
        }
    }

    private double getKeyboardHeightDp() {
        double keyboardHeightPx =
                mActivityTestRule
                        .getKeyboardDelegate()
                        .calculateTotalKeyboardHeight(
                                mActivityTestRule
                                        .getActivity()
                                        .getWindow()
                                        .getDecorView()
                                        .getRootView());
        return keyboardHeightPx / mViewportTestUtils.getDeviceScaleFactor();
    }

    private void setLocationAndWaitForLoad(String url) {
        ChromeTabUtils.waitForTabPageLoaded(
                mActivityTestRule.getActivity().getActivityTab(),
                url,
                () -> {
                    try {
                        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                                getWebContents(), "location = '" + url + "'");
                    } catch (Throwable e) {
                    }
                },
                /* secondsToWait= */ 10);
    }

    private Bitmap takeScreenshot() throws Throwable {
        Context context = ApplicationProvider.getApplicationContext();

        final CallbackHelper ch = new CallbackHelper();
        final AtomicReference<String> screenshotOutputPath = new AtomicReference<>();
        String cacheDirPath = context.getCacheDir().getAbsolutePath();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    getWebContents()
                            .getRenderWidgetHostView()
                            .writeContentBitmapToDiskAsync(
                                    /* width= */ 0,
                                    /* height= */ 0,
                                    cacheDirPath,
                                    path -> {
                                        screenshotOutputPath.set(path);
                                        ch.notifyCalled();
                                    });
                });

        ch.waitForNext(TEST_TIMEOUT, TimeUnit.SECONDS);

        if (TextUtils.isEmpty(screenshotOutputPath.get())) {
            throw new Exception("Failed to take screenshot");
        }

        File outputFile = new File(screenshotOutputPath.get());
        outputFile.deleteOnExit();
        Bitmap screenshot = BitmapFactory.decodeFile(outputFile.getAbsolutePath());
        outputFile.delete();
        return screenshot;
    }

    // This will start the transition and wait until snapshots for the old page have been
    // taken. This will return only once the domUpdate callback has run (but not resolved). The
    // transition animation doesn't start until the test calls startTransitionAnimation.
    private void createTransitionAndWaitUntilDomUpdateDispatched() throws Throwable {
        JavaScriptUtils.executeJavaScriptAndWaitForResult(getWebContents(), "createTransition()");
        JavaScriptUtils.runJavascriptWithAsyncResult(
                getWebContents(),
                "readyToStartPromise.then(() => domAutomationController.send(true));");
    }

    private void waitForTransitionReady() throws Throwable {
        JavaScriptUtils.runJavascriptWithAsyncResult(
                getWebContents(),
                "transition.ready.then(() => domAutomationController.send(true));");
    }

    // After calling createTransitionAndWaitUntilDomUpdateDispatched to create a transition, this
    // will continue the transition and start animating. Animations are immediately paused in
    // the initial "outgoing" state.
    private void startTransitionAnimation() throws Throwable {
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                getWebContents(), "startTransitionAnimation();");
    }

    // Sets animation times to the final "incoming" state. Animations remain paused.
    private void animateToEndState() throws Throwable {
        JavaScriptUtils.executeJavaScriptAndWaitForResult(getWebContents(), "animateToEndState();");
    }

    // Finishes all animations which also finishes the view transition.
    private void finishAnimations() throws Throwable {
        JavaScriptUtils.executeJavaScriptAndWaitForResult(getWebContents(), "finishAnimations();");
    }

    private String getCurrentUrl() {
        return ChromeTabUtils.getUrlStringOnUiThread(
                mActivityTestRule.getActivity().getActivityTab());
    }

    /**
     * Test view transitions when going from a state with a virtual keyboard shown to the virtual
     * keyboard hidden.
     *
     * <p>This tests the default mode where the virtual-keyboard resizes only the visual viewport
     * and the author hasn't opted into a content-resizing virtual keyboard.
     */
    @Test
    @MediumTest
    @Feature({"RenderTest"})
    public void testVirtualKeyboardResizesVisual() throws Throwable {
        startKeyboardTest(VirtualKeyboardMode.RESIZES_VISUAL);

        showAndWaitForKeyboard();

        createTransitionAndWaitUntilDomUpdateDispatched();

        // Note: rendering is blocked here so we can't wait for the keyboard to hide since the size
        // depends on the renderer.
        hideKeyboard();

        // Start the animation. The "animation" simply displays the old transition for the full
        // duration of the test.
        startTransitionAnimation();

        waitForKeyboardHidden();

        // Wait for a frame to be presented to ensure the animation has started and the updated
        // viewport size is rendered.
        mViewportTestUtils.waitForFramePresented();

        Bitmap oldState = takeScreenshot();
        mRenderTestRule.compareForResult(oldState, "old_state_keyboard_resizes_visual");

        animateToEndState();
        mViewportTestUtils.waitForFramePresented();

        Bitmap newState = takeScreenshot();
        mRenderTestRule.compareForResult(newState, "new_state_keyboard_resizes_visual");

        finishAnimations();
    }

    /**
     * Test view transitions when going from a state with a virtual keyboard shown to the virtual
     * keyboard hidden.
     *
     * <p>This tests the mode where the author opts in to the keyboard resizing page content.
     */
    @Test
    @MediumTest
    @Feature({"RenderTest"})
    public void testVirtualKeyboardResizesContent() throws Throwable {
        startKeyboardTest(VirtualKeyboardMode.RESIZES_CONTENT);

        showAndWaitForKeyboard();

        createTransitionAndWaitUntilDomUpdateDispatched();

        // Note: rendering is blocked here so we can't wait for the keyboard to hide since the size
        // of the renderer wont update until we start rendering again.
        hideKeyboard();

        // Start the animation. The "animation" simply displays the old transition for the full
        // duration of the test.
        startTransitionAnimation();

        waitForKeyboardHidden();

        // Wait for a frame to be presented to ensure the animation has started and the updated
        // viewport size is rendered.
        mViewportTestUtils.waitForFramePresented();

        Bitmap oldState = takeScreenshot();
        mRenderTestRule.compareForResult(oldState, "old_state_keyboard_resizes_content");

        animateToEndState();
        mViewportTestUtils.waitForFramePresented();

        Bitmap newState = takeScreenshot();
        mRenderTestRule.compareForResult(newState, "new_state_keyboard_resizes_content");

        finishAnimations();
    }

    /**
     * Test view transitions when into a <dialog> element.
     *
     * <p>Tested here to ensure snapshot viewport positioning behavior with respect to top-layer
     * objects like <dialog>.
     */
    @Test
    @MediumTest
    @Feature({"RenderTest"})
    public void testDialog() throws Throwable {
        String url = "/chrome/test/data/android/view_transition_dialog.html";
        mActivityTestRule.startMainActivityWithURL(mTestServer.getURL(url));
        mActivityTestRule.waitForActivityNativeInitializationComplete();

        createTransitionAndWaitUntilDomUpdateDispatched();

        // Start the animation. The "animation" simply displays the old transition for the full
        // duration of the test. This test is interested in how the <dialog> element is positioned.
        // Since that's in the end-state, skip straight to that.
        startTransitionAnimation();
        animateToEndState();
        mViewportTestUtils.waitForFramePresented();

        Bitmap newState = takeScreenshot();
        mRenderTestRule.compareForResult(newState, "incoming_dialog_element");

        finishAnimations();
    }

    /**
     * Test view transitions in a page wider than the initial containing block.
     *
     * <p>Tests that a view-transition fills the viewport when the fixed-containing-block is larger
     * than the initial-containing-block. This happens when an element on the page horizontally
     * overflows the initial-containing-block, increasing how much the page can be zoomed out.
     */
    @Test
    @MediumTest
    @Feature({"RenderTest"})
    public void testPageWiderThanICB() throws Throwable {
        String url = "/chrome/test/data/android/view_transition_wider_than_icb.html";
        mActivityTestRule.startMainActivityWithURL(mTestServer.getURL(url));
        mActivityTestRule.waitForActivityNativeInitializationComplete();

        createTransitionAndWaitUntilDomUpdateDispatched();

        // Start the animation. The "animation" simply displays the old transition for the full
        // duration of the test. This test is interested in how the <dialog> element is positioned.
        // Since that's in the end-state, skip straight to that.
        startTransitionAnimation();
        animateToEndState();
        mViewportTestUtils.waitForFramePresented();

        Bitmap newState = takeScreenshot();
        mRenderTestRule.compareForResult(newState, "wider-than-icb");

        finishAnimations();
    }

    /**
     * Test the root snapshot is correctly displayed when browser controls overlay the page.
     *
     * <p>Perform a cross-document transition from a starting state where the browser controls are
     * hidden. The navigation will cause the browser controls to animate in, overlaying the content.
     * Ensure the root snapshot is correctly captured and displayed so that the old snapshot matches
     * the live incoming page.
     *
     * <p>The output screenshot should show blue strips (old snapshot) on the left and green strips
     * (new snapshot) on the right. The strips should line up exactly. Only the "OVERLAY" peach bar
     * should be visible and aligned with the viewport top edge.
     */
    @Test
    @MediumTest
    @Feature({"RenderTest"})
    public void testBrowserControlsRootSnapshotControlsOverlay() throws Throwable {
        String url = "/chrome/test/data/android/view_transition_browser_controls.html";
        mActivityTestRule.startMainActivityWithURL(mTestServer.getURL(url));
        mActivityTestRule.waitForActivityNativeInitializationComplete();

        mViewportTestUtils.hideBrowserControls();

        // Scrolling to a non-0 y offset will cause controls to overlay content when they're shown.
        // Ensure we wait a frame before navigating so that the compositor receives the new scroll
        // offset before controls start to show.
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                getWebContents(), "window.scrollTo(0, 1)");
        mViewportTestUtils.waitForFramePresented();
        setLocationAndWaitForLoad(getCurrentUrl() + "?next");

        mViewportTestUtils.waitForFramePresented();
        waitForTransitionReady();

        int oldPageScrollOffset = mViewportTestUtils.getTopControlsHeightDp() + 1;

        // Scroll the incoming page too just so the strips should line up.
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                getWebContents(), "window.scrollTo(0, " + oldPageScrollOffset + ")");

        mViewportTestUtils.waitForFramePresented();
        mViewportTestUtils.waitForFramePresented();
        Bitmap newState = takeScreenshot();
        mRenderTestRule.compareForResult(newState, "browser-controls-overlay-root");

        finishAnimations();
    }

    /**
     * Test the root snapshot is correctly displayed when browser controls push the page.
     *
     * <p>Perform a cross-document transition from a starting state where the browser controls are
     * hidden. The navigation will cause the browser controls to animate in and push down the
     * content (as opposed to overlaying it). Ensure the root snapshot is correctly captured and
     * displayed so that the old snapshot matches the live incoming page.
     *
     * <p>The output screenshot should show blue strips (old snapshot) on the left and green strips
     * (new snapshot) on the right. The strips should line up exactly. Both peach bars should be
     * visible, with the "TOP" one at the viewport top edge.
     */
    @Test
    @MediumTest
    @Feature({"RenderTest"})
    public void testBrowserControlsRootSnapshotControlsPush() throws Throwable {
        String url = "/chrome/test/data/android/view_transition_browser_controls.html";
        mActivityTestRule.startMainActivityWithURL(mTestServer.getURL(url));
        mActivityTestRule.waitForActivityNativeInitializationComplete();

        mViewportTestUtils.hideBrowserControls();

        // Ensure the page is at offset 0. When scrolled to the top the controls animation will push
        // the page down, rather than overlaying it. Ensure we wait a frame before navigating so
        // that the compositor receives the new scroll offset before controls start to show.
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                getWebContents(), "window.scrollTo(0, 0)");
        mViewportTestUtils.waitForFramePresented();
        setLocationAndWaitForLoad(getCurrentUrl() + "?next");

        mViewportTestUtils.waitForFramePresented();
        waitForTransitionReady();

        mViewportTestUtils.waitForFramePresented();
        mViewportTestUtils.waitForFramePresented();
        Bitmap newState = takeScreenshot();
        mRenderTestRule.compareForResult(newState, "browser-controls-push-root");

        finishAnimations();
    }

    /**
     * Test child snapshots are correctly displayed when browser controls overlay the page.
     *
     * <p>Ensures child snapshots are correctly positioned on the new page when the browser controls
     * animation overlays page content. Tests for both in-flow (green box) and fixed (purple box)
     * children.
     *
     * <p>The output should show a green box exactly centered in a blue box. The purple bar at the
     * top should line up exactly with the black line on top of the "CONTROLS" peach bar.
     */
    @Test
    @MediumTest
    @Feature({"RenderTest"})
    public void testBrowserControlsChildSnapshotControlsOverlay() throws Throwable {
        String url = "/chrome/test/data/android/view_transition_browser_controls_child.html";
        mActivityTestRule.startMainActivityWithURL(mTestServer.getURL(url));
        mActivityTestRule.waitForActivityNativeInitializationComplete();

        mViewportTestUtils.hideBrowserControls();

        // Scrolling to a non-0 y offset will cause controls to overlay content when they're shown.
        // Ensure we wait a frame before navigating so that the compositor receives the new scroll
        // offset before controls start to show.
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                getWebContents(), "window.scrollTo(0, 1)");
        mViewportTestUtils.waitForFramePresented();
        setLocationAndWaitForLoad(getCurrentUrl() + "?next");

        mViewportTestUtils.waitForFramePresented();
        waitForTransitionReady();

        int oldPageScrollOffset = mViewportTestUtils.getTopControlsHeightDp() + 1;

        // Scroll the incoming page too just so the strips should line up.
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                getWebContents(), "window.scrollTo(0, " + oldPageScrollOffset + ")");

        mViewportTestUtils.waitForFramePresented();
        mViewportTestUtils.waitForFramePresented();
        Bitmap newState = takeScreenshot();
        mRenderTestRule.compareForResult(newState, "browser-controls-overlay-child");

        finishAnimations();
    }

    /**
     * Test child snapshots are correctly displayed when browser controls push the page.
     *
     * <p>Ensures child snapshots are correctly positioned on the new page when the browser controls
     * animation pushes page content. Tests for both in-flow (green box) and fixed (purple box)
     * children.
     *
     * <p>The output should show a green box exactly centered in a blue box. There should be two
     * peach bars visible: "TOP" and "CONTROLS". The purple bar at the top should line up exactly
     * with the black line on top of the "TOP" peach bar.
     */
    @Test
    @MediumTest
    @Feature({"RenderTest"})
    public void testBrowserControlsChildSnapshotControlsPush() throws Throwable {
        String url = "/chrome/test/data/android/view_transition_browser_controls_child.html";
        mActivityTestRule.startMainActivityWithURL(mTestServer.getURL(url));
        mActivityTestRule.waitForActivityNativeInitializationComplete();

        mViewportTestUtils.hideBrowserControls();

        // Ensure the page is at offset 0. When scrolled to the top the controls animation will push
        // the page down, rather than overlaying it. Ensure we wait a frame before navigating so
        // that the compositor receives the new scroll offset before controls start to show.
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                getWebContents(), "window.scrollTo(0, 0)");
        mViewportTestUtils.waitForFramePresented();
        setLocationAndWaitForLoad(getCurrentUrl() + "?next");

        mViewportTestUtils.waitForFramePresented();
        waitForTransitionReady();

        mViewportTestUtils.waitForFramePresented();
        mViewportTestUtils.waitForFramePresented();
        Bitmap newState = takeScreenshot();
        mRenderTestRule.compareForResult(newState, "browser-controls-push-child");

        finishAnimations();
    }
}