mobile/android/base/tests/helpers/JavascriptBridge.java

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/mobile/android/base/tests/helpers/JavascriptBridge.java	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,391 @@
     1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public
     1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.7 +
     1.8 +package org.mozilla.gecko.tests.helpers;
     1.9 +
    1.10 +import java.lang.reflect.InvocationTargetException;
    1.11 +import java.lang.reflect.Method;
    1.12 +
    1.13 +import junit.framework.AssertionFailedError;
    1.14 +
    1.15 +import org.json.JSONArray;
    1.16 +import org.json.JSONException;
    1.17 +import org.json.JSONObject;
    1.18 +
    1.19 +import org.mozilla.gecko.Actions;
    1.20 +import org.mozilla.gecko.Actions.EventExpecter;
    1.21 +import org.mozilla.gecko.Assert;
    1.22 +import org.mozilla.gecko.tests.UITestContext;
    1.23 +
    1.24 +/**
    1.25 + * Javascript bridge allows calls to and from JavaScript.
    1.26 + *
    1.27 + * To establish communication, create an instance of JavascriptBridge in Java and pass in
    1.28 + * an object that will receive calls from JavaScript. For example:
    1.29 + *
    1.30 + *  {@code final JavascriptBridge js = new JavascriptBridge(javaObj);}
    1.31 + *
    1.32 + * Next, create an instance of JavaBridge in JavaScript and pass in another object
    1.33 + * that will receive calls from Java. For example:
    1.34 + *
    1.35 + *  {@code let java = new JavaBridge(jsObj);}
    1.36 + *
    1.37 + * Once a link is established, calls can be made using the methods syncCall and asyncCall.
    1.38 + * syncCall waits for the call to finish before returning. For example:
    1.39 + *
    1.40 + *  {@code js.syncCall("abc", 1, 2, 3);} will synchronously call the JavaScript method
    1.41 + *    jsObj.abc and pass in arguments 1, 2, and 3.
    1.42 + *
    1.43 + *  {@code java.asyncCall("def", 4, 5, 6);} will asynchronously call the Java method
    1.44 + *    javaObj.def and pass in arguments 4, 5, and 6.
    1.45 + *
    1.46 + * Supported argument types include int, double, boolean, String, and JSONObject. Note
    1.47 + * that only implicit conversion is done, meaning if a floating point argument is passed
    1.48 + * from JavaScript to Java, the call will fail if the Java method has an int argument.
    1.49 + *
    1.50 + * Because JavascriptBridge and JavaBridge use one underlying communication channel,
    1.51 + * creating multiple instances of them will not create independent links.
    1.52 + *
    1.53 + * Note also that because Robocop tests finish as soon as the Java test method returns,
    1.54 + * the last call to JavaScript from Java must be a synchronous call. Otherwise, the test
    1.55 + * will finish before the JavaScript method is run. Calls to Java from JavaScript do not
    1.56 + * have this requirement. Because of these considerations, calls from Java to JavaScript
    1.57 + * are usually synchronous and calls from JavaScript to Java are usually asynchronous.
    1.58 + * See testJavascriptBridge.java for examples.
    1.59 + */
    1.60 +public final class JavascriptBridge {
    1.61 +
    1.62 +    private static enum MessageStatus {
    1.63 +        QUEUE_EMPTY, // Did not process a message; queue was empty.
    1.64 +        PROCESSED,   // A message other than sync was processed.
    1.65 +        REPLIED,     // A sync message was processed.
    1.66 +        SAVED,       // An async message was saved; see processMessage().
    1.67 +    };
    1.68 +
    1.69 +    @SuppressWarnings("serial")
    1.70 +    public static class CallException extends RuntimeException {
    1.71 +        public CallException() {
    1.72 +            super();
    1.73 +        }
    1.74 +
    1.75 +        public CallException(final String msg) {
    1.76 +            super(msg);
    1.77 +        }
    1.78 +
    1.79 +        public CallException(final String msg, final Throwable e) {
    1.80 +            super(msg, e);
    1.81 +        }
    1.82 +
    1.83 +        public CallException(final Throwable e) {
    1.84 +            super(e);
    1.85 +        }
    1.86 +    }
    1.87 +
    1.88 +    public static final String EVENT_TYPE = "Robocop:JS";
    1.89 +
    1.90 +    private static Actions sActions;
    1.91 +    private static Assert sAsserter;
    1.92 +
    1.93 +    // Target of JS-to-Java calls
    1.94 +    private final Object mTarget;
    1.95 +    // List of public methods in subclass
    1.96 +    private final Method[] mMethods;
    1.97 +    // Parser for handling xpcshell assertions
    1.98 +    private final JavascriptMessageParser mLogParser;
    1.99 +    // Expecter of our internal Robocop event
   1.100 +    private final EventExpecter mExpecter;
   1.101 +    // Saved async message; see processMessage() for its purpose.
   1.102 +    private JSONObject mSavedAsyncMessage;
   1.103 +    // Number of levels in the synchronous call stack
   1.104 +    private int mCallStackDepth;
   1.105 +    // If JavaBridge has been loaded
   1.106 +    private boolean mJavaBridgeLoaded;
   1.107 +
   1.108 +    /* package */ static void init(final UITestContext context) {
   1.109 +        sActions = context.getActions();
   1.110 +        sAsserter = context.getAsserter();
   1.111 +    }
   1.112 +
   1.113 +    public JavascriptBridge(final Object target) {
   1.114 +        mTarget = target;
   1.115 +        mMethods = target.getClass().getMethods();
   1.116 +        mExpecter = sActions.expectGeckoEvent(EVENT_TYPE);
   1.117 +        // The JS here is unrelated to a test harness, so we
   1.118 +        // have our message parser end on assertion failure.
   1.119 +        mLogParser = new JavascriptMessageParser(sAsserter, true);
   1.120 +    }
   1.121 +
   1.122 +    /**
   1.123 +     * Synchronously calls a method in Javascript.
   1.124 +     *
   1.125 +     * @param method Name of the method to call
   1.126 +     * @param args Arguments to pass to the Javascript method; must be a list of
   1.127 +     *             values allowed by JSONObject.
   1.128 +     */
   1.129 +    public void syncCall(final String method, final Object... args) {
   1.130 +        mCallStackDepth++;
   1.131 +
   1.132 +        sendMessage("sync-call", method, args);
   1.133 +        try {
   1.134 +            while (processPendingMessage() != MessageStatus.REPLIED) {
   1.135 +            }
   1.136 +        } catch (final AssertionFailedError e) {
   1.137 +            // Most likely an event expecter time out
   1.138 +            throw new CallException("Cannot call " + method, e);
   1.139 +        }
   1.140 +
   1.141 +        // If syncCall was called reentrantly from processPendingMessage(), mCallStackDepth
   1.142 +        // will be greater than 1 here. In that case we don't have to wait for pending calls
   1.143 +        // because the outermost syncCall will do it for us.
   1.144 +        if (mCallStackDepth == 1) {
   1.145 +            // We want to wait for all asynchronous calls to finish,
   1.146 +            // because the test may end immediately after this method returns.
   1.147 +            finishPendingCalls();
   1.148 +        }
   1.149 +        mCallStackDepth--;
   1.150 +    }
   1.151 +
   1.152 +    /**
   1.153 +     * Asynchronously calls a method in Javascript.
   1.154 +     *
   1.155 +     * @param method Name of the method to call
   1.156 +     * @param args Arguments to pass to the Javascript method; must be a list of
   1.157 +     *             values allowed by JSONObject.
   1.158 +     */
   1.159 +    public void asyncCall(final String method, final Object... args) {
   1.160 +        sendMessage("async-call", method, args);
   1.161 +    }
   1.162 +
   1.163 +    /**
   1.164 +     * Disconnect the bridge.
   1.165 +     */
   1.166 +    public void disconnect() {
   1.167 +        mExpecter.unregisterListener();
   1.168 +    }
   1.169 +
   1.170 +    /**
   1.171 +     * Process a new message; wait for new message if necessary.
   1.172 +     *
   1.173 +     * @return MessageStatus value to indicate result of processing the message
   1.174 +     */
   1.175 +    private MessageStatus processPendingMessage() {
   1.176 +        // We're on the test thread.
   1.177 +        // We clear mSavedAsyncMessage in maybeProcessPendingMessage() but not here,
   1.178 +        // because we always have a new message for processing here, so we never
   1.179 +        // get a chance to clear mSavedAsyncMessage.
   1.180 +        try {
   1.181 +            final String message = mExpecter.blockForEventData();
   1.182 +            return processMessage(new JSONObject(message));
   1.183 +        } catch (final JSONException e) {
   1.184 +            throw new IllegalStateException("Invalid message", e);
   1.185 +        }
   1.186 +    }
   1.187 +
   1.188 +    /**
   1.189 +     * Process a message if a new or saved message is available.
   1.190 +     *
   1.191 +     * @return MessageStatus value to indicate result of processing the message
   1.192 +     */
   1.193 +    private MessageStatus maybeProcessPendingMessage() {
   1.194 +        // We're on the test thread.
   1.195 +        final String message = mExpecter.blockForEventDataWithTimeout(0);
   1.196 +        if (message != null) {
   1.197 +            try {
   1.198 +                return processMessage(new JSONObject(message));
   1.199 +            } catch (final JSONException e) {
   1.200 +                throw new IllegalStateException("Invalid message", e);
   1.201 +            }
   1.202 +        }
   1.203 +        if (mSavedAsyncMessage != null) {
   1.204 +            // processMessage clears mSavedAsyncMessage.
   1.205 +            return processMessage(mSavedAsyncMessage);
   1.206 +        }
   1.207 +        return MessageStatus.QUEUE_EMPTY;
   1.208 +    }
   1.209 +
   1.210 +    /**
   1.211 +     * Wait for all asynchronous messages from Javascript to be processed.
   1.212 +     */
   1.213 +    private void finishPendingCalls() {
   1.214 +        MessageStatus result;
   1.215 +        do {
   1.216 +            result = maybeProcessPendingMessage();
   1.217 +            if (result == MessageStatus.REPLIED) {
   1.218 +                throw new IllegalStateException("Sync reply was unexpected");
   1.219 +            }
   1.220 +        } while (result != MessageStatus.QUEUE_EMPTY);
   1.221 +    }
   1.222 +
   1.223 +    private void ensureJavaBridgeLoaded() {
   1.224 +        while (!mJavaBridgeLoaded) {
   1.225 +            processPendingMessage();
   1.226 +        }
   1.227 +    }
   1.228 +
   1.229 +    private void sendMessage(final String innerType, final String method, final Object[] args) {
   1.230 +        ensureJavaBridgeLoaded();
   1.231 +
   1.232 +        // Call from Java to Javascript
   1.233 +        final JSONObject message = new JSONObject();
   1.234 +        final JSONArray jsonArgs = new JSONArray();
   1.235 +        try {
   1.236 +            if (args != null) {
   1.237 +                for (final Object arg : args) {
   1.238 +                    jsonArgs.put(convertToJSONValue(arg));
   1.239 +                }
   1.240 +            }
   1.241 +            message.put("type", EVENT_TYPE)
   1.242 +                   .put("innerType", innerType)
   1.243 +                   .put("method", method)
   1.244 +                   .put("args", jsonArgs);
   1.245 +        } catch (final JSONException e) {
   1.246 +            throw new IllegalStateException("Unable to create JSON message", e);
   1.247 +        }
   1.248 +        sActions.sendGeckoEvent(EVENT_TYPE, message.toString());
   1.249 +    }
   1.250 +
   1.251 +    private MessageStatus processMessage(JSONObject message) {
   1.252 +        final String type;
   1.253 +        final String methodName;
   1.254 +        final JSONArray argsArray;
   1.255 +        final Object[] args;
   1.256 +        try {
   1.257 +            if (!EVENT_TYPE.equals(message.getString("type"))) {
   1.258 +                throw new IllegalStateException("Message type is not " + EVENT_TYPE);
   1.259 +            }
   1.260 +            type = message.getString("innerType");
   1.261 +
   1.262 +            if ("progress".equals(type)) {
   1.263 +                // Javascript harness message
   1.264 +                mLogParser.logMessage(message.getString("message"));
   1.265 +                return MessageStatus.PROCESSED;
   1.266 +
   1.267 +            } else if ("notify-loaded".equals(type)) {
   1.268 +                mJavaBridgeLoaded = true;
   1.269 +                return MessageStatus.PROCESSED;
   1.270 +
   1.271 +            } else if ("sync-reply".equals(type)) {
   1.272 +                // Reply to Java-to-Javascript sync call
   1.273 +                return MessageStatus.REPLIED;
   1.274 +
   1.275 +            } else if ("sync-call".equals(type) || "async-call".equals(type)) {
   1.276 +
   1.277 +                if ("async-call".equals(type)) {
   1.278 +                    // Save this async message until another async message arrives, then we
   1.279 +                    // process the saved message and save the new one. This is done as a
   1.280 +                    // form of tail call optimization, by making sync-replies come before
   1.281 +                    // async-calls. On the other hand, if (message == mSavedAsyncMessage),
   1.282 +                    // it means we're currently processing the saved message and should clear
   1.283 +                    // mSavedAsyncMessage.
   1.284 +                    final JSONObject newSavedMessage =
   1.285 +                        (message != mSavedAsyncMessage ? message : null);
   1.286 +                    message = mSavedAsyncMessage;
   1.287 +                    mSavedAsyncMessage = newSavedMessage;
   1.288 +                    if (message == null) {
   1.289 +                        // Saved current message and there wasn't an already saved one.
   1.290 +                        return MessageStatus.SAVED;
   1.291 +                    }
   1.292 +                }
   1.293 +
   1.294 +                methodName = message.getString("method");
   1.295 +                argsArray = message.getJSONArray("args");
   1.296 +                args = new Object[argsArray.length()];
   1.297 +                for (int i = 0; i < args.length; i++) {
   1.298 +                    args[i] = convertFromJSONValue(argsArray.get(i));
   1.299 +                }
   1.300 +                invokeMethod(methodName, args);
   1.301 +
   1.302 +                if ("sync-call".equals(type)) {
   1.303 +                    // Reply for sync messages
   1.304 +                    sendMessage("sync-reply", methodName, null);
   1.305 +                }
   1.306 +                return MessageStatus.PROCESSED;
   1.307 +            }
   1.308 +            throw new IllegalStateException("Message type is unexpected");
   1.309 +
   1.310 +        } catch (final JSONException e) {
   1.311 +            throw new IllegalStateException("Unable to retrieve JSON message", e);
   1.312 +        }
   1.313 +    }
   1.314 +
   1.315 +    /**
   1.316 +     * Given a method name and a list of arguments,
   1.317 +     * call the most suitable method in the subclass.
   1.318 +     */
   1.319 +    private Object invokeMethod(final String methodName, final Object[] args) {
   1.320 +        final Class<?>[] argTypes = new Class<?>[args.length];
   1.321 +        for (int i = 0; i < argTypes.length; i++) {
   1.322 +            if (args[i] == null) {
   1.323 +                argTypes[i] = Object.class;
   1.324 +            } else {
   1.325 +                argTypes[i] = args[i].getClass();
   1.326 +            }
   1.327 +        }
   1.328 +
   1.329 +        // Try using argument types directly without casting.
   1.330 +        try {
   1.331 +            return invokeMethod(mTarget.getClass().getMethod(methodName, argTypes), args);
   1.332 +        } catch (final NoSuchMethodException e) {
   1.333 +            // getMethod() failed; try fallback below.
   1.334 +        }
   1.335 +
   1.336 +        // One scenario for getMethod() to fail above is that we don't have the exact
   1.337 +        // argument types in argTypes (e.g. JS gave us an int but we're using a double,
   1.338 +        // or JS gave us a null and we don't know its intended type), or the number of
   1.339 +        // arguments is incorrect. Now we find all the methods with the given name and
   1.340 +        // try calling them one-by-one. If one call fails, we move to the next call.
   1.341 +        // Java will try to convert our arguments to the right types.
   1.342 +        Throwable lastException = null;
   1.343 +        for (final Method method : mMethods) {
   1.344 +            if (!method.getName().equals(methodName)) {
   1.345 +                continue;
   1.346 +            }
   1.347 +            try {
   1.348 +                return invokeMethod(method, args);
   1.349 +            } catch (final IllegalArgumentException e) {
   1.350 +                lastException = e;
   1.351 +                // Try the next method
   1.352 +            } catch (final UnsupportedOperationException e) {
   1.353 +                // "Cannot access method" exception below, see if there are other public methods
   1.354 +                lastException = e;
   1.355 +                // Try the next method
   1.356 +            }
   1.357 +        }
   1.358 +        // Now we're out of options
   1.359 +        throw new UnsupportedOperationException(
   1.360 +            "Cannot call method " + methodName + " (not public? wrong argument types?)",
   1.361 +            lastException);
   1.362 +    }
   1.363 +
   1.364 +    private Object invokeMethod(final Method method, final Object[] args) {
   1.365 +        try {
   1.366 +            return method.invoke(mTarget, args);
   1.367 +        } catch (final IllegalAccessException e) {
   1.368 +            throw new UnsupportedOperationException(
   1.369 +                "Cannot access method " + method.getName(), e);
   1.370 +        } catch (final InvocationTargetException e) {
   1.371 +            final Throwable cause = e.getCause();
   1.372 +            if (cause instanceof CallException) {
   1.373 +                // Don't wrap CallExceptions; this can happen if a call is nested on top
   1.374 +                // of existing sync calls, and the nested call throws a CallException
   1.375 +                throw (CallException) cause;
   1.376 +            }
   1.377 +            throw new CallException("Failed to invoke " + method.getName(), cause);
   1.378 +        }
   1.379 +    }
   1.380 +
   1.381 +    private Object convertFromJSONValue(final Object value) {
   1.382 +        if (value == JSONObject.NULL) {
   1.383 +            return null;
   1.384 +        }
   1.385 +        return value;
   1.386 +    }
   1.387 +
   1.388 +    private Object convertToJSONValue(final Object value) {
   1.389 +        if (value == null) {
   1.390 +            return JSONObject.NULL;
   1.391 +        }
   1.392 +        return value;
   1.393 +    }
   1.394 +}

mercurial