|
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 { |
|
68 public CallException() { |
|
69 super(); |
|
70 } |
|
71 |
|
72 public CallException(final String msg) { |
|
73 super(msg); |
|
74 } |
|
75 |
|
76 public CallException(final String msg, final Throwable e) { |
|
77 super(msg, e); |
|
78 } |
|
79 |
|
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 |
|
105 /* package */ static void init(final UITestContext context) { |
|
106 sActions = context.getActions(); |
|
107 sAsserter = context.getAsserter(); |
|
108 } |
|
109 |
|
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 */ |
|
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 */ |
|
156 public void asyncCall(final String method, final Object... args) { |
|
157 sendMessage("async-call", method, args); |
|
158 } |
|
159 |
|
160 /** |
|
161 * Disconnect the bridge. |
|
162 */ |
|
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 */ |
|
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 */ |
|
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 */ |
|
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 |
|
220 private void ensureJavaBridgeLoaded() { |
|
221 while (!mJavaBridgeLoaded) { |
|
222 processPendingMessage(); |
|
223 } |
|
224 } |
|
225 |
|
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 |
|
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 if ("progress".equals(type)) { |
|
260 // Javascript harness message |
|
261 mLogParser.logMessage(message.getString("message")); |
|
262 return MessageStatus.PROCESSED; |
|
263 |
|
264 } else if ("notify-loaded".equals(type)) { |
|
265 mJavaBridgeLoaded = true; |
|
266 return MessageStatus.PROCESSED; |
|
267 |
|
268 } else if ("sync-reply".equals(type)) { |
|
269 // Reply to Java-to-Javascript sync call |
|
270 return MessageStatus.REPLIED; |
|
271 |
|
272 } else if ("sync-call".equals(type) || "async-call".equals(type)) { |
|
273 |
|
274 if ("async-call".equals(type)) { |
|
275 // Save this async message until another async message arrives, then we |
|
276 // process the saved message and save the new one. This is done as a |
|
277 // form of tail call optimization, by making sync-replies come before |
|
278 // async-calls. On the other hand, if (message == mSavedAsyncMessage), |
|
279 // it means we're currently processing the saved message and should clear |
|
280 // mSavedAsyncMessage. |
|
281 final JSONObject newSavedMessage = |
|
282 (message != mSavedAsyncMessage ? message : null); |
|
283 message = mSavedAsyncMessage; |
|
284 mSavedAsyncMessage = newSavedMessage; |
|
285 if (message == null) { |
|
286 // Saved current message and there wasn't an already saved one. |
|
287 return MessageStatus.SAVED; |
|
288 } |
|
289 } |
|
290 |
|
291 methodName = message.getString("method"); |
|
292 argsArray = message.getJSONArray("args"); |
|
293 args = new Object[argsArray.length()]; |
|
294 for (int i = 0; i < args.length; i++) { |
|
295 args[i] = convertFromJSONValue(argsArray.get(i)); |
|
296 } |
|
297 invokeMethod(methodName, args); |
|
298 |
|
299 if ("sync-call".equals(type)) { |
|
300 // Reply for sync messages |
|
301 sendMessage("sync-reply", methodName, null); |
|
302 } |
|
303 return MessageStatus.PROCESSED; |
|
304 } |
|
305 throw new IllegalStateException("Message type is unexpected"); |
|
306 |
|
307 } catch (final JSONException e) { |
|
308 throw new IllegalStateException("Unable to retrieve JSON message", e); |
|
309 } |
|
310 } |
|
311 |
|
312 /** |
|
313 * Given a method name and a list of arguments, |
|
314 * call the most suitable method in the subclass. |
|
315 */ |
|
316 private Object invokeMethod(final String methodName, final Object[] args) { |
|
317 final Class<?>[] argTypes = new Class<?>[args.length]; |
|
318 for (int i = 0; i < argTypes.length; i++) { |
|
319 if (args[i] == null) { |
|
320 argTypes[i] = Object.class; |
|
321 } else { |
|
322 argTypes[i] = args[i].getClass(); |
|
323 } |
|
324 } |
|
325 |
|
326 // Try using argument types directly without casting. |
|
327 try { |
|
328 return invokeMethod(mTarget.getClass().getMethod(methodName, argTypes), args); |
|
329 } catch (final NoSuchMethodException e) { |
|
330 // getMethod() failed; try fallback below. |
|
331 } |
|
332 |
|
333 // One scenario for getMethod() to fail above is that we don't have the exact |
|
334 // argument types in argTypes (e.g. JS gave us an int but we're using a double, |
|
335 // or JS gave us a null and we don't know its intended type), or the number of |
|
336 // arguments is incorrect. Now we find all the methods with the given name and |
|
337 // try calling them one-by-one. If one call fails, we move to the next call. |
|
338 // Java will try to convert our arguments to the right types. |
|
339 Throwable lastException = null; |
|
340 for (final Method method : mMethods) { |
|
341 if (!method.getName().equals(methodName)) { |
|
342 continue; |
|
343 } |
|
344 try { |
|
345 return invokeMethod(method, args); |
|
346 } catch (final IllegalArgumentException e) { |
|
347 lastException = e; |
|
348 // Try the next method |
|
349 } catch (final UnsupportedOperationException e) { |
|
350 // "Cannot access method" exception below, see if there are other public methods |
|
351 lastException = e; |
|
352 // Try the next method |
|
353 } |
|
354 } |
|
355 // Now we're out of options |
|
356 throw new UnsupportedOperationException( |
|
357 "Cannot call method " + methodName + " (not public? wrong argument types?)", |
|
358 lastException); |
|
359 } |
|
360 |
|
361 private Object invokeMethod(final Method method, final Object[] args) { |
|
362 try { |
|
363 return method.invoke(mTarget, args); |
|
364 } catch (final IllegalAccessException e) { |
|
365 throw new UnsupportedOperationException( |
|
366 "Cannot access method " + method.getName(), e); |
|
367 } catch (final InvocationTargetException e) { |
|
368 final Throwable cause = e.getCause(); |
|
369 if (cause instanceof CallException) { |
|
370 // Don't wrap CallExceptions; this can happen if a call is nested on top |
|
371 // of existing sync calls, and the nested call throws a CallException |
|
372 throw (CallException) cause; |
|
373 } |
|
374 throw new CallException("Failed to invoke " + method.getName(), cause); |
|
375 } |
|
376 } |
|
377 |
|
378 private Object convertFromJSONValue(final Object value) { |
|
379 if (value == JSONObject.NULL) { |
|
380 return null; |
|
381 } |
|
382 return value; |
|
383 } |
|
384 |
|
385 private Object convertToJSONValue(final Object value) { |
|
386 if (value == null) { |
|
387 return JSONObject.NULL; |
|
388 } |
|
389 return value; |
|
390 } |
|
391 } |