1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 package org.mozilla.gecko.tests.helpers; 6 7 import java.lang.reflect.InvocationTargetException; 8 import java.lang.reflect.Method; 9 10 import junit.framework.AssertionFailedError; 11 12 import org.json.JSONArray; 13 import org.json.JSONException; 14 import org.json.JSONObject; 15 16 import org.mozilla.gecko.Actions; 17 import org.mozilla.gecko.Actions.EventExpecter; 18 import org.mozilla.gecko.Assert; 19 import org.mozilla.gecko.tests.UITestContext; 20 21 /** 22 * Javascript bridge allows calls to and from JavaScript. 23 * 24 * To establish communication, create an instance of JavascriptBridge in Java and pass in 25 * an object that will receive calls from JavaScript. For example: 26 * 27 * {@code final JavascriptBridge js = new JavascriptBridge(javaObj);} 28 * 29 * Next, create an instance of JavaBridge in JavaScript and pass in another object 30 * that will receive calls from Java. For example: 31 * 32 * {@code let java = new JavaBridge(jsObj);} 33 * 34 * Once a link is established, calls can be made using the methods syncCall and asyncCall. 35 * syncCall waits for the call to finish before returning. For example: 36 * 37 * {@code js.syncCall("abc", 1, 2, 3);} will synchronously call the JavaScript method 38 * jsObj.abc and pass in arguments 1, 2, and 3. 39 * 40 * {@code java.asyncCall("def", 4, 5, 6);} will asynchronously call the Java method 41 * javaObj.def and pass in arguments 4, 5, and 6. 42 * 43 * Supported argument types include int, double, boolean, String, and JSONObject. Note 44 * that only implicit conversion is done, meaning if a floating point argument is passed 45 * from JavaScript to Java, the call will fail if the Java method has an int argument. 46 * 47 * Because JavascriptBridge and JavaBridge use one underlying communication channel, 48 * creating multiple instances of them will not create independent links. 49 * 50 * Note also that because Robocop tests finish as soon as the Java test method returns, 51 * the last call to JavaScript from Java must be a synchronous call. Otherwise, the test 52 * will finish before the JavaScript method is run. Calls to Java from JavaScript do not 53 * have this requirement. Because of these considerations, calls from Java to JavaScript 54 * are usually synchronous and calls from JavaScript to Java are usually asynchronous. 55 * See testJavascriptBridge.java for examples. 56 */ 57 public final class JavascriptBridge { 58 59 private static enum MessageStatus { 60 QUEUE_EMPTY, // Did not process a message; queue was empty. 61 PROCESSED, // A message other than sync was processed. 62 REPLIED, // A sync message was processed. 63 SAVED, // An async message was saved; see processMessage(). 64 }; 65 66 @SuppressWarnings("serial") 67 public static class CallException extends RuntimeException { CallException()68 public CallException() { 69 super(); 70 } 71 CallException(final String msg)72 public CallException(final String msg) { 73 super(msg); 74 } 75 CallException(final String msg, final Throwable e)76 public CallException(final String msg, final Throwable e) { 77 super(msg, e); 78 } 79 CallException(final Throwable e)80 public CallException(final Throwable e) { 81 super(e); 82 } 83 } 84 85 public static final String EVENT_TYPE = "Robocop:JS"; 86 87 private static Actions sActions; 88 private static Assert sAsserter; 89 90 // Target of JS-to-Java calls 91 private final Object mTarget; 92 // List of public methods in subclass 93 private final Method[] mMethods; 94 // Parser for handling xpcshell assertions 95 private final JavascriptMessageParser mLogParser; 96 // Expecter of our internal Robocop event 97 private final EventExpecter mExpecter; 98 // Saved async message; see processMessage() for its purpose. 99 private JSONObject mSavedAsyncMessage; 100 // Number of levels in the synchronous call stack 101 private int mCallStackDepth; 102 // If JavaBridge has been loaded 103 private boolean mJavaBridgeLoaded; 104 init(final UITestContext context)105 /* package */ static void init(final UITestContext context) { 106 sActions = context.getActions(); 107 sAsserter = context.getAsserter(); 108 } 109 JavascriptBridge(final Object target)110 public JavascriptBridge(final Object target) { 111 mTarget = target; 112 mMethods = target.getClass().getMethods(); 113 mExpecter = sActions.expectGeckoEvent(EVENT_TYPE); 114 // The JS here is unrelated to a test harness, so we 115 // have our message parser end on assertion failure. 116 mLogParser = new JavascriptMessageParser(sAsserter, true); 117 } 118 119 /** 120 * Synchronously calls a method in Javascript. 121 * 122 * @param method Name of the method to call 123 * @param args Arguments to pass to the Javascript method; must be a list of 124 * values allowed by JSONObject. 125 */ syncCall(final String method, final Object... args)126 public void syncCall(final String method, final Object... args) { 127 mCallStackDepth++; 128 129 sendMessage("sync-call", method, args); 130 try { 131 while (processPendingMessage() != MessageStatus.REPLIED) { 132 } 133 } catch (final AssertionFailedError e) { 134 // Most likely an event expecter time out 135 throw new CallException("Cannot call " + method, e); 136 } 137 138 // If syncCall was called reentrantly from processPendingMessage(), mCallStackDepth 139 // will be greater than 1 here. In that case we don't have to wait for pending calls 140 // because the outermost syncCall will do it for us. 141 if (mCallStackDepth == 1) { 142 // We want to wait for all asynchronous calls to finish, 143 // because the test may end immediately after this method returns. 144 finishPendingCalls(); 145 } 146 mCallStackDepth--; 147 } 148 149 /** 150 * Asynchronously calls a method in Javascript. 151 * 152 * @param method Name of the method to call 153 * @param args Arguments to pass to the Javascript method; must be a list of 154 * values allowed by JSONObject. 155 */ asyncCall(final String method, final Object... args)156 public void asyncCall(final String method, final Object... args) { 157 sendMessage("async-call", method, args); 158 } 159 160 /** 161 * Disconnect the bridge. 162 */ disconnect()163 public void disconnect() { 164 mExpecter.unregisterListener(); 165 } 166 167 /** 168 * Process a new message; wait for new message if necessary. 169 * 170 * @return MessageStatus value to indicate result of processing the message 171 */ processPendingMessage()172 private MessageStatus processPendingMessage() { 173 // We're on the test thread. 174 // We clear mSavedAsyncMessage in maybeProcessPendingMessage() but not here, 175 // because we always have a new message for processing here, so we never 176 // get a chance to clear mSavedAsyncMessage. 177 try { 178 final String message = mExpecter.blockForEventData(); 179 return processMessage(new JSONObject(message)); 180 } catch (final JSONException e) { 181 throw new IllegalStateException("Invalid message", e); 182 } 183 } 184 185 /** 186 * Process a message if a new or saved message is available. 187 * 188 * @return MessageStatus value to indicate result of processing the message 189 */ maybeProcessPendingMessage()190 private MessageStatus maybeProcessPendingMessage() { 191 // We're on the test thread. 192 final String message = mExpecter.blockForEventDataWithTimeout(0); 193 if (message != null) { 194 try { 195 return processMessage(new JSONObject(message)); 196 } catch (final JSONException e) { 197 throw new IllegalStateException("Invalid message", e); 198 } 199 } 200 if (mSavedAsyncMessage != null) { 201 // processMessage clears mSavedAsyncMessage. 202 return processMessage(mSavedAsyncMessage); 203 } 204 return MessageStatus.QUEUE_EMPTY; 205 } 206 207 /** 208 * Wait for all asynchronous messages from Javascript to be processed. 209 */ finishPendingCalls()210 private void finishPendingCalls() { 211 MessageStatus result; 212 do { 213 result = maybeProcessPendingMessage(); 214 if (result == MessageStatus.REPLIED) { 215 throw new IllegalStateException("Sync reply was unexpected"); 216 } 217 } while (result != MessageStatus.QUEUE_EMPTY); 218 } 219 ensureJavaBridgeLoaded()220 private void ensureJavaBridgeLoaded() { 221 while (!mJavaBridgeLoaded) { 222 processPendingMessage(); 223 } 224 } 225 sendMessage(final String innerType, final String method, final Object[] args)226 private void sendMessage(final String innerType, final String method, final Object[] args) { 227 ensureJavaBridgeLoaded(); 228 229 // Call from Java to Javascript 230 final JSONObject message = new JSONObject(); 231 final JSONArray jsonArgs = new JSONArray(); 232 try { 233 if (args != null) { 234 for (final Object arg : args) { 235 jsonArgs.put(convertToJSONValue(arg)); 236 } 237 } 238 message.put("type", EVENT_TYPE) 239 .put("innerType", innerType) 240 .put("method", method) 241 .put("args", jsonArgs); 242 } catch (final JSONException e) { 243 throw new IllegalStateException("Unable to create JSON message", e); 244 } 245 sActions.sendGeckoEvent(EVENT_TYPE, message.toString()); 246 } 247 processMessage(JSONObject message)248 private MessageStatus processMessage(JSONObject message) { 249 final String type; 250 final String methodName; 251 final JSONArray argsArray; 252 final Object[] args; 253 try { 254 if (!EVENT_TYPE.equals(message.getString("type"))) { 255 throw new IllegalStateException("Message type is not " + EVENT_TYPE); 256 } 257 type = message.getString("innerType"); 258 259 switch (type) { 260 case "progress": 261 // Javascript harness message 262 mLogParser.logMessage(message.getString("message")); 263 return MessageStatus.PROCESSED; 264 265 case "notify-loaded": 266 mJavaBridgeLoaded = true; 267 return MessageStatus.PROCESSED; 268 269 case "sync-reply": 270 // Reply to Java-to-Javascript sync call 271 return MessageStatus.REPLIED; 272 273 case "sync-call": 274 case "async-call": 275 276 if ("async-call".equals(type)) { 277 // Save this async message until another async message arrives, then we 278 // process the saved message and save the new one. This is done as a 279 // form of tail call optimization, by making sync-replies come before 280 // async-calls. On the other hand, if (message == mSavedAsyncMessage), 281 // it means we're currently processing the saved message and should clear 282 // mSavedAsyncMessage. 283 final JSONObject newSavedMessage = 284 (message != mSavedAsyncMessage ? message : null); 285 message = mSavedAsyncMessage; 286 mSavedAsyncMessage = newSavedMessage; 287 if (message == null) { 288 // Saved current message and there wasn't an already saved one. 289 return MessageStatus.SAVED; 290 } 291 } 292 293 methodName = message.getString("method"); 294 argsArray = message.getJSONArray("args"); 295 args = new Object[argsArray.length()]; 296 for (int i = 0; i < args.length; i++) { 297 args[i] = convertFromJSONValue(argsArray.get(i)); 298 } 299 invokeMethod(methodName, args); 300 301 if ("sync-call".equals(type)) { 302 // Reply for sync messages 303 sendMessage("sync-reply", methodName, null); 304 } 305 return MessageStatus.PROCESSED; 306 } 307 308 throw new IllegalStateException("Message type is unexpected"); 309 310 } catch (final JSONException e) { 311 throw new IllegalStateException("Unable to retrieve JSON message", e); 312 } 313 } 314 315 /** 316 * Given a method name and a list of arguments, 317 * call the most suitable method in the subclass. 318 */ invokeMethod(final String methodName, final Object[] args)319 private Object invokeMethod(final String methodName, final Object[] args) { 320 final Class<?>[] argTypes = new Class<?>[args.length]; 321 for (int i = 0; i < argTypes.length; i++) { 322 if (args[i] == null) { 323 argTypes[i] = Object.class; 324 } else { 325 argTypes[i] = args[i].getClass(); 326 } 327 } 328 329 // Try using argument types directly without casting. 330 try { 331 return invokeMethod(mTarget.getClass().getMethod(methodName, argTypes), args); 332 } catch (final NoSuchMethodException e) { 333 // getMethod() failed; try fallback below. 334 } 335 336 // One scenario for getMethod() to fail above is that we don't have the exact 337 // argument types in argTypes (e.g. JS gave us an int but we're using a double, 338 // or JS gave us a null and we don't know its intended type), or the number of 339 // arguments is incorrect. Now we find all the methods with the given name and 340 // try calling them one-by-one. If one call fails, we move to the next call. 341 // Java will try to convert our arguments to the right types. 342 Throwable lastException = null; 343 for (final Method method : mMethods) { 344 if (!method.getName().equals(methodName)) { 345 continue; 346 } 347 try { 348 return invokeMethod(method, args); 349 } catch (final IllegalArgumentException e) { 350 lastException = e; 351 // Try the next method 352 } catch (final UnsupportedOperationException e) { 353 // "Cannot access method" exception below, see if there are other public methods 354 lastException = e; 355 // Try the next method 356 } 357 } 358 // Now we're out of options 359 throw new UnsupportedOperationException( 360 "Cannot call method " + methodName + " (not public? wrong argument types?)", 361 lastException); 362 } 363 invokeMethod(final Method method, final Object[] args)364 private Object invokeMethod(final Method method, final Object[] args) { 365 try { 366 return method.invoke(mTarget, args); 367 } catch (final IllegalAccessException e) { 368 throw new UnsupportedOperationException( 369 "Cannot access method " + method.getName(), e); 370 } catch (final InvocationTargetException e) { 371 final Throwable cause = e.getCause(); 372 if (cause instanceof CallException) { 373 // Don't wrap CallExceptions; this can happen if a call is nested on top 374 // of existing sync calls, and the nested call throws a CallException 375 throw (CallException) cause; 376 } 377 throw new CallException("Failed to invoke " + method.getName(), cause); 378 } 379 } 380 convertFromJSONValue(final Object value)381 private Object convertFromJSONValue(final Object value) { 382 if (value == JSONObject.NULL) { 383 return null; 384 } 385 return value; 386 } 387 convertToJSONValue(final Object value)388 private Object convertToJSONValue(final Object value) { 389 if (value == null) { 390 return JSONObject.NULL; 391 } 392 return value; 393 } 394 } 395