blob: 618820db21df936d60ad166bdabe951bb4612c7e [file] [log] [blame]
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.webkit.webdriver;
import android.os.Handler;
import android.os.Message;
import android.webkit.WebView;
import com.android.internal.R;
import com.google.android.collect.Lists;
import com.google.android.collect.Maps;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
/**
* Drives a web application by controlling the WebView. This class
* provides a DOM-like API allowing to get information about the page,
* navigate, and interact with the web application. This is particularly useful
* for testing a web application.
*
* <p/>{@link android.webkit.webdriver.WebDriver} should be created in the main
* thread, and invoked from another thread. Here is a sample usage:
*
* public class WebDriverStubActivity extends Activity {
* private WebDriver mDriver;
*
* public void onCreate(Bundle savedInstanceState) {
* super.onCreate(savedInstanceState);
* WebView view = new WebView(this);
* mDriver = new WebDriver(view);
* setContentView(view);
* }
*
*
* public WebDriver getDriver() {
* return mDriver;
* }
*}
*
* public class WebDriverTest extends
* ActivityInstrumentationTestCase2<WebDriverStubActivity>{
* private WebDriver mDriver;
*
* public WebDriverTest() {
* super(WebDriverStubActivity.class);
* }
*
* protected void setUp() throws Exception {
* super.setUp();
* mDriver = getActivity().getDriver();
* }
*
* public void testGoogle() {
* mDriver.get("http://google.com");
* WebElement searchBox = mDriver.findElement(By.name("q"));
* q.sendKeys("Cheese!");
* q.submit();
* assertTrue(mDriver.findElements(By.partialLinkText("Cheese")).size() > 0);
* }
*}
*
* @hide
*/
public class WebDriver {
// Timeout for page load in milliseconds.
private static final int LOADING_TIMEOUT = 30000;
// Timeout for executing JavaScript in the WebView in milliseconds.
private static final int JS_EXECUTION_TIMEOUT = 10000;
// Commands posted to the handler
private static final int CMD_GET_URL = 1;
private static final int CMD_EXECUTE_SCRIPT = 2;
private static final String ELEMENT_KEY = "ELEMENT";
private static final String STATUS = "status";
private static final String VALUE = "value";
private static final long MAIN_THREAD = Thread.currentThread().getId();
// This is updated by a callabck from JavaScript when the result is ready.
private String mJsResult;
// Used for synchronization
private final Object mSyncObject;
// Updated when the command is done executing in the main thread.
private volatile boolean mCommandDone;
private WebView mWebView;
// This WebElement represents the object document.documentElement
private WebElement mDocumentElement;
// This Handler runs in the main UI thread.
private final Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (msg.what == CMD_GET_URL) {
final String url = (String) msg.obj;
mWebView.loadUrl(url);
} else if (msg.what == CMD_EXECUTE_SCRIPT) {
mWebView.loadUrl("javascript:" + (String) msg.obj);
}
}
};
/**
* Error codes from the WebDriver wire protocol
* http://code.google.com/p/selenium/wiki/JsonWireProtocol#Response_Status_Codes
*/
private enum ErrorCode {
SUCCESS(0),
NO_SUCH_ELEMENT(7),
NO_SUCH_FRAME(8),
UNKNOWN_COMMAND(9),
UNSUPPORTED_OPERATION(9), // Alias
STALE_ELEMENT_REFERENCE(10),
ELEMENT_NOT_VISISBLE(11),
INVALID_ELEMENT_STATE(12),
UNKNOWN_ERROR(13),
ELEMENT_NOT_SELECTABLE(15),
XPATH_LOOKUP_ERROR(19),
NO_SUCH_WINDOW(23),
INVALID_COOKIE_DOMAIN(24),
UNABLE_TO_SET_COOKIE(25),
MODAL_DIALOG_OPENED(26),
MODAL_DIALOG_OPEN(27),
SCRIPT_TIMEOUT(28);
private final int mCode;
private static ErrorCode[] values = ErrorCode.values();
ErrorCode(int code) {
this.mCode = code;
}
public int getCode() {
return mCode;
}
public static ErrorCode get(final int intValue) {
for (int i = 0; i < values.length; i++) {
if (values[i].getCode() == intValue) {
return values[i];
}
}
throw new IllegalArgumentException(intValue
+ " does not map to any ErrorCode.");
}
}
public WebDriver(WebView webview) {
this.mWebView = webview;
if (mWebView == null) {
throw new IllegalArgumentException("WebView cannot be null");
}
if (!mWebView.getSettings().getJavaScriptEnabled()) {
throw new RuntimeException("Javascript is disabled in the WebView. "
+ "Enable it to use WebDriver");
}
shouldRunInMainThread(true);
mSyncObject = new Object();
this.mWebView = webview;
WebchromeClientWrapper chromeWrapper = new WebchromeClientWrapper(
webview.getWebChromeClient(), this);
mWebView.setWebChromeClient(chromeWrapper);
mWebView.addJavascriptInterface(new JavascriptResultReady(),
"webdriver");
}
/**
* Loads a URL in the WebView. This function is blocking and will return
* when the page has finished loading.
*
* @param url The URL to load.
*/
public void get(String url) {
executeCommand(CMD_GET_URL, url, LOADING_TIMEOUT);
mDocumentElement = (WebElement) executeScript("return document.body;");
}
/**
* @return The source page of the currently loaded page in WebView.
*/
public String getPageSource() {
return (String) executeScript("return new XMLSerializer()."
+ "serializeToString(document);");
}
/**
* Find the first {@link android.webkit.webdriver.WebElement} using the
* given method.
*
* @param by The locating mechanism to use.
* @return The first matching element on the current context.
* @throws {@link android.webkit.webdriver.WebElementNotFoundException} if
* no matching element was found.
*/
public WebElement findElement(By by) {
checkNotNull(mDocumentElement, "Load a page using WebDriver.get() "
+ "before looking for elements.");
return by.findElement(mDocumentElement);
}
/**
* Finds all {@link android.webkit.webdriver.WebElement} within the page
* using the given method.
*
* @param by The locating mechanism to use.
* @return A list of all {@link android.webkit.webdriver.WebElement} found,
* or an empty list if nothing matches.
*/
public List<WebElement> findElements(By by) {
checkNotNull(mDocumentElement, "Load a page using WebDriver.get() "
+ "before looking for elements.");
return by.findElements(mDocumentElement);
}
/**
* Clears the WebView.
*/
public void quit() {
mWebView.clearCache(true);
mWebView.clearFormData();
mWebView.clearHistory();
mWebView.clearSslPreferences();
mWebView.clearView();
}
/**
* Executes javascript in the context of the main frame.
*
* If the script has a return value the following happens:
* <ul>
* <li>For an HTML element, this method returns a WebElement</li>
* <li>For a decimal, a Double is returned</li>
* <li>For non-decimal number, a Long is returned</li>
* <li>For a boolean, a Boolean is returned</li>
* <li>For all other cases, a String is returned</li>
* <li>For an array, this returns a List<Object> with each object
* following the rules above.</li>
* <li>For an object literal this returns a Map<String, Object>. Note that
* Object literals keys can only be Strings. Non Strings keys will
* be filtered out.</li>
* </ul>
*
* <p> Arguments must be a number, a boolean, a string a WebElement or
* a list of any combination of the above. The arguments will be made
* available to the javascript via the "arguments" magic variable,
* as if the function was called via "Function.apply".
*
* @param script The JavaScript to execute.
* @param args The arguments to the script. Can be any of a number, boolean,
* string, WebElement or a List of those.
* @return A Boolean, Long, Double, String, WebElement, List or null.
*/
public Object executeScript(final String script, final Object... args) {
String scriptArgs = "[" + convertToJsArgs(args) + "]";
String injectScriptJs = getResourceAsString(R.raw.execute_script_android);
return executeRawJavascript("(" + injectScriptJs +
")(" + escapeAndQuote(script) + ", " + scriptArgs + ", true)");
}
/**
* Converts the arguments passed to a JavaScript friendly format.
*
* @param args The arguments to convert.
* @return Comma separated Strings containing the arguments.
*/
/*package*/ String convertToJsArgs(final Object... args) {
StringBuilder toReturn = new StringBuilder();
int length = args.length;
for (int i = 0; i < length; i++) {
toReturn.append((i > 0) ? "," : "");
if (args[i] instanceof List<?>) {
toReturn.append("[");
List<Object> aList = (List<Object>) args[i];
for (int j = 0 ; j < aList.size(); j++) {
String comma = ((j == 0) ? "" : ",");
toReturn.append(comma + convertToJsArgs(aList.get(j)));
}
toReturn.append("]");
} else if (args[i] instanceof Map<?, ?>) {
Map<Object, Object> aMap = (Map<Object, Object>) args[i];
String toAdd = "{";
for (Object key: aMap.keySet()) {
toAdd += key + ":"
+ convertToJsArgs(aMap.get(key)) + ",";
}
toReturn.append(toAdd.substring(0, toAdd.length() -1) + "}");
} else if (args[i] instanceof WebElement) {
// WebElement are represented in JavaScript by Objects as
// follow: {ELEMENT:"id"} where "id" refers to the id
// of the HTML element in the javascript cache that can
// be accessed throught bot.inject.cache.getCache_()
toReturn.append("{\"" + ELEMENT_KEY + "\":\""
+ ((WebElement) args[i]).getId() + "\"}");
} else if (args[i] instanceof Number || args[i] instanceof Boolean) {
toReturn.append(String.valueOf(args[i]));
} else if (args[i] instanceof String) {
toReturn.append(escapeAndQuote((String) args[i]));
} else {
throw new IllegalArgumentException(
"Javascript arguments can be "
+ "a Number, a Boolean, a String, a WebElement, "
+ "or a List or a Map of those. Got: "
+ ((args[i] == null) ? "null" : args[i].getClass()
+ ", value: " + args[i].toString()));
}
}
return toReturn.toString();
}
/*package*/ Object executeRawJavascript(final String script) {
String result = executeCommand(CMD_EXECUTE_SCRIPT,
"window.webdriver.resultReady(" + script + ")",
JS_EXECUTION_TIMEOUT);
try {
JSONObject json = new JSONObject(result);
throwIfError(json);
Object value = json.get(VALUE);
return convertJsonToJavaObject(value);
} catch (JSONException e) {
throw new RuntimeException("Failed to parse JavaScript result: "
+ result.toString(), e);
}
}
/*package*/ String getResourceAsString(final int resourceId) {
InputStream is = mWebView.getResources().openRawResource(resourceId);
BufferedReader br = new BufferedReader(new InputStreamReader(is));
StringBuilder sb = new StringBuilder();
String line = null;
try {
while ((line = br.readLine()) != null) {
sb.append(line);
}
br.close();
is.close();
} catch (IOException e) {
throw new RuntimeException("Failed to open JavaScript resource.", e);
}
return sb.toString();
}
/**
* Wraps the given string into quotes and escape existing quotes
* and backslashes.
* "foo" -> "\"foo\""
* "foo\"" -> "\"foo\\\"\""
* "fo\o" -> "\"fo\\o\""
*
* @param toWrap The String to wrap in quotes
* @return a String wrapping the original String in quotes
*/
private static String escapeAndQuote(final String toWrap) {
StringBuilder toReturn = new StringBuilder("\"");
for (int i = 0; i < toWrap.length(); i++) {
char c = toWrap.charAt(i);
if (c == '\"') {
toReturn.append("\\\"");
} else if (c == '\\') {
toReturn.append("\\\\");
} else {
toReturn.append(c);
}
}
toReturn.append("\"");
return toReturn.toString();
}
private Object convertJsonToJavaObject(final Object toConvert) {
try {
if (toConvert == null
|| toConvert.equals(null)
|| "undefined".equals(toConvert)
|| "null".equals(toConvert)) {
return null;
} else if (toConvert instanceof Boolean) {
return toConvert;
} else if (toConvert instanceof Double
|| toConvert instanceof Float) {
return Double.valueOf(String.valueOf(toConvert));
} else if (toConvert instanceof Integer
|| toConvert instanceof Long) {
return Long.valueOf(String.valueOf(toConvert));
} else if (toConvert instanceof JSONArray) { // List
return convertJsonArrayToList((JSONArray) toConvert);
} else if (toConvert instanceof JSONObject) { // Map or WebElment
JSONObject map = (JSONObject) toConvert;
if (map.opt(ELEMENT_KEY) != null) { // WebElement
return new WebElement(this, (String) map.get(ELEMENT_KEY));
} else { // Map
return convertJsonObjectToMap(map);
}
} else {
return toConvert.toString();
}
} catch (JSONException e) {
throw new RuntimeException("Failed to parse JavaScript result: "
+ toConvert.toString(), e);
}
}
private List<Object> convertJsonArrayToList(final JSONArray json) {
List<Object> toReturn = Lists.newArrayList();
for (int i = 0; i < json.length(); i++) {
try {
toReturn.add(convertJsonToJavaObject(json.get(i)));
} catch (JSONException e) {
throw new RuntimeException("Failed to parse JSON: "
+ json.toString(), e);
}
}
return toReturn;
}
private Map<Object, Object> convertJsonObjectToMap(final JSONObject json) {
Map<Object, Object> toReturn = Maps.newHashMap();
for (Iterator it = json.keys(); it.hasNext();) {
String key = (String) it.next();
try {
Object value = json.get(key);
toReturn.put(convertJsonToJavaObject(key),
convertJsonToJavaObject(value));
} catch (JSONException e) {
throw new RuntimeException("Failed to parse JSON:"
+ json.toString(), e);
}
}
return toReturn;
}
private void throwIfError(final JSONObject jsonObject) {
ErrorCode status;
String errorMsg;
try {
status = ErrorCode.get((Integer) jsonObject.get(STATUS));
errorMsg = String.valueOf(jsonObject.get(VALUE));
} catch (JSONException e) {
throw new RuntimeException("Failed to parse JSON Object: "
+ jsonObject, e);
}
switch (status) {
case SUCCESS:
return;
case NO_SUCH_ELEMENT:
throw new WebElementNotFoundException("Could not find "
+ "WebElement.");
case STALE_ELEMENT_REFERENCE:
throw new WebElementStaleException("WebElement is stale.");
default:
throw new WebDriverException("Error: " + errorMsg);
}
}
private void shouldRunInMainThread(boolean value) {
assert (value == (MAIN_THREAD == Thread.currentThread().getId()));
}
/**
* Interface called from JavaScript when the result is ready.
*/
private class JavascriptResultReady {
/**
* A callback from JavaScript to Java that passes the result as a
* parameter. This method is available from the WebView's
* JavaScript DOM as window.webdriver.resultReady().
*
* @param result The result that should be sent to Java from Javascript.
*/
public void resultReady(final String result) {
synchronized (mSyncObject) {
mJsResult = result;
mCommandDone = true;
mSyncObject.notify();
}
}
}
/* package */ void notifyCommandDone() {
synchronized (mSyncObject) {
mCommandDone = true;
mSyncObject.notify();
}
}
/**
* Executes the given command by posting a message to mHandler. This thread
* will block until the command which runs in the main thread is done.
*
* @param command The command to run.
* @param arg The argument for that command.
* @param timeout A timeout in milliseconds.
*/
private String executeCommand(int command, final Object arg, long timeout) {
shouldRunInMainThread(false);
synchronized (mSyncObject) {
mCommandDone = false;
Message msg = mHandler.obtainMessage(command);
msg.obj = arg;
mHandler.sendMessage(msg);
long end = System.currentTimeMillis() + timeout;
while (!mCommandDone) {
if (System.currentTimeMillis() >= end) {
throw new RuntimeException("Timeout executing command.");
}
try {
mSyncObject.wait(timeout);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
return mJsResult;
}
private void checkNotNull(Object obj, String errosMsg) {
if (obj == null) {
throw new NullPointerException(errosMsg);
}
}
}