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