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 +}