chromium/android_webview/tools/automated_ui_tests/javatests/src/org/chromium/webview_ui_test/test/util/WebViewSyncWrapper.java

// Copyright 2016 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.webview_ui_test.test.util;

import static androidx.test.InstrumentationRegistry.getInstrumentation;

import android.os.Looper;
import android.webkit.ConsoleMessage;
import android.webkit.JavascriptInterface;
import android.webkit.JsResult;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import android.webkit.WebViewClient;

import org.junit.Assert;

import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.UrlUtils;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeoutException;

/**
 * WebViewSyncWrapper is a WebView wrapper to store the lock and condition provided by the tests
 * and ensure webview signal the tests once page load and javascript load is over.
 */
public class WebViewSyncWrapper {
    private static final String JS_BRIDGE = "WEBVIEW_JS_BRIDGE";
    private static final int TIME_WAIT_MS = 100000;
    private static final String TAG = "WebViewSyncWrapper";
    private static final String EXTERNAL_PREFIX = UrlUtils.getIsolatedTestRoot() + "/";
    private static final String FILE_URL_BASE =
            "file://" + EXTERNAL_PREFIX + "android_webview/tools/automated_ui_tests/test/data/";

    private final WebView mWebView;

    private CallbackHelper mPageCallback = new CallbackHelper();
    private CallbackHelper mJsCallback = new CallbackHelper();

    private List<ConsoleMessage> mErrorMessageList =
            Collections.synchronizedList(new ArrayList<ConsoleMessage>());

    public WebViewSyncWrapper(WebView wv) {
        mWebView = wv;
        init();
    }

    /**
     * SyncJavaScriptBridge instance will be used by WebView to allow javascript in WebView can
     * make callbacks to signal that JavaScript is finished loading.
     * <p/>
     * For example, if a test has a long async javascript snippet that needed to be loaded before
     * the test proceed like below.
     * <p/>
     * <pre>
     * <code>
     * window.setTimeout(function() {
     *     document.findElementById("button");
     *     window.WEBVIEW_JS_BRIDGE.onJavaScriptFinished();
     *     }, 10000)
     * };
     * </code>
     * </pre>
     * <p/>
     * Despite that the javascript above takes 10 seconds to finish loading, the test will still
     * wait until javascript is finished loading
     */
    private class SyncJavaScriptBridge {
        @JavascriptInterface
        public void onJavaScriptFinished() {
            mJsCallback.notifyCalled();
        }
    }

    /** A custom WebViewClient that signals tests when onPageStarted and onPageFinished is called */
    private class SyncWebViewClient extends WebViewClient {
        @Override
        public void onPageFinished(WebView view, String url) {
            mPageCallback.notifyCalled();
            super.onPageFinished(view, url);
        }
    }

    private void init() {
        runOnUiThread(
                new Runnable() {
                    @Override
                    public void run() {
                        mWebView.getSettings().setJavaScriptEnabled(true);
                        mWebView.setWebViewClient(WebViewSyncWrapper.this.new SyncWebViewClient());
                        mWebView.setWebChromeClient(
                                new WebChromeClient() {
                                    @Override
                                    public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
                                        if (consoleMessage.messageLevel()
                                                == ConsoleMessage.MessageLevel.ERROR) {
                                            mErrorMessageList.add(consoleMessage);
                                        }
                                        return super.onConsoleMessage(consoleMessage);
                                    }

                                    @Override
                                    public boolean onJsAlert(
                                            WebView view,
                                            String url,
                                            String message,
                                            JsResult result) {
                                        mJsCallback.notifyCalled();
                                        return super.onJsAlert(view, url, message, result);
                                    }
                                });
                        mWebView.addJavascriptInterface(
                                WebViewSyncWrapper.this.new SyncJavaScriptBridge(), JS_BRIDGE);
                    }
                });
    }

    public void loadDataSync(
            final String data,
            final String mimeType,
            final String encoding,
            boolean confirmByJavaScript) {
        mErrorMessageList.clear();
        int currentPageCount = mPageCallback.getCallCount();
        int currentJsCount = mJsCallback.getCallCount();
        runOnUiThread(
                new Runnable() {
                    @Override
                    public void run() {
                        mWebView.loadData(data, mimeType, encoding);
                    }
                });
        try {
            if (confirmByJavaScript) {
                mJsCallback.waitForCallback(currentJsCount);
            }
            mPageCallback.waitForCallback(currentPageCount);
        } catch (TimeoutException e) {
            Assert.fail(e.getMessage());
        }
    }

    public void loadFileSync(final String html, boolean confirmByJavaScript) {
        mErrorMessageList.clear();
        int currentPageCount = mPageCallback.getCallCount();
        int currentJsCount = mJsCallback.getCallCount();
        runOnUiThread(
                new Runnable() {
                    @Override
                    public void run() {
                        mWebView.getSettings().setAllowFileAccess(true);
                        mWebView.loadUrl(FILE_URL_BASE + html);
                    }
                });
        try {
            if (confirmByJavaScript) {
                mJsCallback.waitForCallback(currentJsCount);
            }
            mPageCallback.waitForCallback(currentPageCount);
        } catch (TimeoutException e) {
            Assert.fail(e.getMessage());
        }
    }

    public void loadJavaScriptSync(String js, boolean appendJavaScriptConfirmation) {
        mErrorMessageList.clear();
        int currentJsCount = mJsCallback.getCallCount();
        final String jsWithCallback;
        if (appendJavaScriptConfirmation) {
            jsWithCallback = String.format("%s;window.%s.onJavaScriptFinished()", js, JS_BRIDGE);
        } else {
            jsWithCallback = js;
        }
        runOnUiThread(
                new Runnable() {
                    @Override
                    public void run() {
                        mWebView.evaluateJavascript(jsWithCallback, null);
                    }
                });
        try {
            mJsCallback.waitForCallback(currentJsCount, 1);
        } catch (TimeoutException e) {
            if (mErrorMessageList.size() > 0) {
                StringBuilder builder = new StringBuilder();
                builder.append("Timeout caused by JavaScript Errors: \n");
                for (ConsoleMessage c : mErrorMessageList) {
                    builder.append(c.message());
                }
                Assert.fail(builder.toString());
            } else {
                Assert.fail(e.getMessage());
            }
        }
    }

    private static void runOnUiThread(final Runnable runnable) {
        if (Looper.myLooper() == Looper.getMainLooper()) {
            throw new RuntimeException(
                    "Actions in WebViewSyncWrapper is not allowed to be run on " + "UI thread");
        } else {
            getInstrumentation().runOnMainSync(runnable);
        }
    }
}