package com.thoughtworks.calabash.android; import org.joda.time.DateTime; import org.jruby.RubyArray; import org.jruby.RubyHash; import org.jruby.embed.LocalContextScope; import org.jruby.embed.LocalVariableBehavior; import org.jruby.embed.PathType; import org.jruby.embed.ScriptingContainer; import java.io.File; import java.io.FileFilter; import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.net.URL; import java.net.URLClassLoader; import java.net.URLDecoder; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import static com.thoughtworks.calabash.android.CalabashLogger.error; import static com.thoughtworks.calabash.android.CalabashLogger.info; import static java.io.File.separator; import static java.lang.String.format; public class CalabashWrapper { public static final String QUERY_STRING = "cajQueryString"; public static final String QUERY_ARGS = "cajQueryArgs"; public static final String SCREENSHOT_PREFIX = "cajPrefix"; public static final String SCREENSHOT_FILENAME = "cajFileName"; public static final String PREFERENCE_NAME = "cajPreferenceName"; public static final String MENU_ITEM = "cajMenuItem"; public static final String WAIT_CONDITION = "cajWaitCondition"; public static final String WAIT_TIMEOUT = "cajWaitTimeout"; public static final String WAIT_RETRY_FREQ = "cajWaitRetryFreq"; public static final String WAIT_POST_TIMEOUT = "cajWaitPostTimeout"; public static final String WAIT_TIMEOUT_MESSAGE = "cajWaitTimeoutMessage"; public static final String WAIT_SHOULD_TAKE_SCREENSHOT = "cajWaitShouldTakeScreenshot"; public static final String ENVIRONMENT_VAR_PLACEHOLDER = "cajEnv"; public static final String ARGV = "ARGV"; private static final String ADB_DEVICE_ARG = "ADB_DEVICE_ARG"; private static final String APP_PATH = "APP_PATH"; private static final String TEST_SERVER_PATH = "TEST_APP_PATH"; private static final String ACTION = "cajAction"; private static final String ACTION_ARGS = "cajActionArgs"; private final ScriptingContainer container = new ScriptingContainer(LocalContextScope.SINGLETHREAD, LocalVariableBehavior.PERSISTENT); private final File rbScriptsPath; private final File apk; private final AndroidConfiguration configuration; private final Environment environment; private File gemsDir; private AndroidBridge androidBridge; private boolean disposed = false; private long pauseTimeInMilliSec = 500; public CalabashWrapper(File rbScriptsPath, File apk, AndroidConfiguration configuration, Environment environment) throws CalabashException { this.rbScriptsPath = rbScriptsPath; this.gemsDir = new File(rbScriptsPath, "gems"); this.apk = apk; this.configuration = configuration; this.environment = environment; this.androidBridge = new AndroidBridge(environment); this.initializeScriptingContainer(); if (configuration != null && configuration.getPauseTimeInMs() >= 0) pauseTimeInMilliSec = configuration.getPauseTimeInMs(); } private void initializeScriptingContainer() throws CalabashException { container.setHomeDirectory(new File(rbScriptsPath, "jruby.home").getAbsolutePath()); HashMap<String, String> environmentVariables = new HashMap<String, String>(); environmentVariables.putAll(System.getenv()); environmentVariables.putAll(environment.getEnvVariables()); container.setEnvironment(environmentVariables); container.getLoadPaths().addAll(getLoadPaths()); container.setErrorWriter(new StringWriter()); } private List<String> getLoadPaths() throws CalabashException { ArrayList<String> loadPaths = new ArrayList<String>(); File[] gems = gemsDir.listFiles(new FileFilter() { public boolean accept(File arg0) { return arg0.isDirectory(); } }); if (gems == null || gems.length == 0) throw new CalabashException("Couldn't find any gems inside " + gemsDir.getAbsolutePath()); for (File gem : gems) { File libPath = new File(gem, "lib"); loadPaths.add(libPath.getAbsolutePath()); } return loadPaths; } public void setup() throws CalabashException { try { addSystemCommandHack(); createDebugCertificateIfMissing(); String jrubyClasspath = getClasspathFor("jruby"); addContainerEnv("CLASSPATH", jrubyClasspath); container.runScriptlet(format("Dir.chdir '%s'", apk.getParent())); container.put(ARGV, new String[]{"resign", apk.getAbsolutePath()}); String calabashAndroid = new File(getCalabashGemDirectory(), "calabash-android").getAbsolutePath(); container.runScriptlet(PathType.ABSOLUTE, calabashAndroid); info("Done signing the app"); container.put(ARGV, new String[]{"build", apk.getAbsolutePath()}); container.runScriptlet(PathType.ABSOLUTE, calabashAndroid); info("App build complete"); } catch (Exception e) { error("Failed to setup calabash for project: %s", e, apk.getAbsolutePath()); throw new CalabashException(format("Failed to setup calabash. %s", e.getMessage())); } } public void start(String serial) throws CalabashException { try { addRequiresAndIncludes("Calabash::Android::Operations"); addSystemCommandHack(); container.runScriptlet(format("Dir.chdir '%s'", apk.getParentFile().getAbsolutePath())); addContainerEnv(ADB_DEVICE_ARG, serial); addContainerEnv(APP_PATH, apk.getAbsolutePath()); String testServerPath = container.runScriptlet("test_server_path(ENV['APP_PATH'])").toString(); addContainerEnv(TEST_SERVER_PATH, testServerPath); String packageName = container.runScriptlet("package_name(ENV['APP_PATH'])").toString(); if (configuration.shouldReinstallApp() || !androidBridge.isAppInstalled(packageName, serial)) { info("Reinstalling app %s and test server on %s", packageName, serial); container.runScriptlet("reinstall_apps"); } else { info("Reinstalling test server on %s", serial); container.runScriptlet("reinstall_test_server"); } container.runScriptlet("start_test_server_in_background"); info("Started the app"); } catch (Exception e) { error("Error starting the app: ", e); throw new CalabashException("Error starting the app:" + e.getMessage(), e); } } //HACK - Jruby system call fails crashing the JVM on attempting to start test server command which redirects error stream to input stream. //Overriding kernel system call to execute command via backtick for the particular edge case. Rest of the calls will be executed via the regular //kernel system call. Bug has been reported on jruby - https://github.com/jruby/jruby/issues/1500 //TODO: Remove this once the bug is fixed on jruby and the new jruby jar is added. private void addSystemCommandHack() { StringBuilder script = new StringBuilder(); script.append(" def system(cmd)\n" + " `#{cmd}`\n" + " return $?.success?\n" + " end\n"); container.runScriptlet(script.toString()); } private void addRequiresAndIncludes(String... modules) throws CalabashException { StringBuilder script = new StringBuilder("require 'calabash-android'\n"); for (String module : modules) { script.append(String.format("extend %s\n", module)); } // HACK - Calabash ruby calls embed method when there is a error. // This is from cucumber and won't be available in the Jruby // environment. So just defining a function to suppress the error if (configuration != null && configuration.getScreenshotListener() != null) { container.put("@cajScreenshotCallback", configuration.getScreenshotListener()); script.append("def embed(path,image_type,file_name)\n @cajScreenshotCallback.screenshotTaken(path, image_type, file_name)\n end\n"); } else { script.append("def embed(path,image_type,file_name)\nend\n"); } container.runScriptlet(script.toString()); } private void createDebugCertificateIfMissing() throws CalabashException { List<File> keystoreLocation = getKeystoreLocation(); for (File file : keystoreLocation) { if (file.exists()) { info("Debug Keystore found at %s", file.getAbsolutePath()); return; } info("Could not find debug keystore at %s", file.getAbsolutePath()); } generateDefaultAndroidKeyStore(); } private void generateDefaultAndroidKeyStore() throws CalabashException { File destinationKeystoreLocation = new File(apk.getParentFile(), "debug.keystore"); String[] keygenCommand = getKeygenCommand(destinationKeystoreLocation.getAbsolutePath()); info("Generating keystore at %s", destinationKeystoreLocation.getAbsolutePath()); Utils.runCommand(keygenCommand, "could not generate debug.keystore"); } private String[] getKeygenCommand(final String keystore) { return new String[]{environment.getKeytool(), "-genkey", "-v", "-keystore", keystore, "-alias", "androiddebugkey", "-storepass", "android", "-keypass", "android", "-keyalg", "RSA", "-keysize", "2048", "-validity", "10000", "-dname", "CN=AndroidDebug,O=Android,C=US"}; } private List<File> getKeystoreLocation() { return new ArrayList<File>() {{ add(new File(System.getProperty("user.home") + separator + ".android" + separator + "debug.keystore")); add(new File(System.getProperty("user.home") + separator + ".local" + separator + "share" + separator + "Xamarin" + separator + "Mono for Android" + separator + "debug.keystore")); add(new File(apk.getParentFile(), "debug.keystore")); add(new File("AppData" + separator + "Local" + separator + "Xamarin" + separator + "Mono for Android" + separator + "debug.keystore")); }}; } private String getClasspathFor(String resource) throws CalabashException, UnsupportedEncodingException { if (environment.getJrubyHome() != null) { return environment.getJrubyHome(); } ClassLoader classLoader = container.getClassLoader(); if (!(classLoader instanceof URLClassLoader)) { //eclipse doesn't give URLClassLoader error("Could not get %s path", resource); throw new CalabashException(String.format("Could not get %s path", resource)); } URLClassLoader urlClassLoader = (URLClassLoader) classLoader; URL[] urls = urlClassLoader.getURLs(); for (URL url : urls) { if (url.toString().contains(resource)) { String jrubyPath = URLDecoder.decode(url.getFile(), "UTF-8"); info("Found %s in classpath at : %s", resource, jrubyPath); return jrubyPath; } } error("Could not find %s in classpath", resource); throw new CalabashException(String.format("Could not find %s in classpath", resource)); } private File getCalabashGemDirectory() throws CalabashException { File[] calabashGemPath = gemsDir.listFiles(new FileFilter() { public boolean accept(File pathname) { return pathname.isDirectory() && pathname.getName().startsWith("calabash-android"); } }); if (calabashGemPath.length == 0) throw new CalabashException(format("Error finding 'calabash-android' in the gempath : %s", gemsDir.getAbsolutePath())); if (calabashGemPath.length > 1) throw new CalabashException(format("Multiple matches for 'calabash-android' in the gempath : %s", gemsDir.getAbsolutePath())); return new File(calabashGemPath[0], "bin"); } public RubyArray query(String query, String... args) throws CalabashException { ensureNotDisposed(); try { info("Executing query - %s", query); container.put(QUERY_STRING, query); container.put(QUERY_ARGS, args); RubyArray queryResults = null; if (args != null && args.length > 0) queryResults = (RubyArray) container.runScriptlet(String.format("query(%s, *%s)", QUERY_STRING, QUERY_ARGS)); else queryResults = (RubyArray) container.runScriptlet(String.format("query(%s)", QUERY_STRING)); return queryResults; } catch (Exception e) { error("Execution of query: %s, failed", e, query); throw new CalabashException(String.format("Failed to execute '%s'. %s", query, e.getMessage())); } } public void touch(String query) throws CalabashException { try { info("Touching - %s", query); container.put(QUERY_STRING, query); container.runScriptlet(String.format("touch(%s)", QUERY_STRING)); pause(); } catch (Exception e) { error("Failed to touch on: %s", e, query); throw new CalabashException(String.format("Failed to touch on: %s. %s", query, e.getMessage())); } } public void enterText(String text, String query) throws CalabashException { try { info("Entering text %s into %s", text, query); container.put(QUERY_STRING, query); container.put("TEXT", text); container.runScriptlet(String.format("enter_text(%s,%s)", QUERY_STRING, "TEXT")); pause(); } catch (Exception e) { error("Failed to enter text %s into %s", e, text, query); throw new CalabashException(String.format("Failed to enter text %s into %s :%s", text, query, e.getMessage())); } } public void dispose() throws CalabashException { try { container.clear(); container.getProvider().getRuntime().tearDown(true); container.terminate(); disposed = true; } catch (Throwable e) { error("Failed to dispose container. ", e); throw new CalabashException("Failed to dispose container. " + e.getMessage()); } } public void takeScreenShot(File dir, String fileName) throws CalabashException { try { info("Taking screenshot"); container.put(SCREENSHOT_PREFIX, dir.getAbsolutePath() + "/"); container.put(SCREENSHOT_FILENAME, fileName); container.runScriptlet(String.format("screenshot(options={:prefix => %s, :name => %s})", SCREENSHOT_PREFIX, SCREENSHOT_FILENAME)); } catch (Exception e) { error("Failed to take screenshot.", e); throw new CalabashException(String.format("Failed to take screenshot. %s", e.getMessage())); } } public Map<String, String> getPreferences(String preferenceName) throws CalabashException { try { info("Finding preferences: %s", preferenceName); container.put(PREFERENCE_NAME, preferenceName); RubyHash preferenceHash = (RubyHash) container.runScriptlet(String.format("get_preferences(%s)", PREFERENCE_NAME)); return (Map<String, String>) Utils.toJavaHash(preferenceHash); } catch (Exception e) { error("Failed to get preferences: %s", preferenceName); throw new CalabashException(String.format("Failed to find preferences: %s", preferenceName)); } } public String getCurrentActivity() throws CalabashException { try { info("Getting current activity"); RubyHash activityInfoMap = (RubyHash) container.runScriptlet("perform_action('get_activity_name')"); String activityName = (String) Utils.toJavaHash(activityInfoMap).get("message"); info("Current activity: %s", activityName); return activityName; } catch (Exception e) { String message = "Failed to get Current Activity"; error(message, e); throw new CalabashException(message, e); } } public DateTime getDate(String query) throws CalabashException { try { info("Getting date from %s", query); container.put(QUERY_STRING, query); RubyArray rubyArray = (RubyArray) container.runScriptlet(String.format("query(%s, :getYear)", QUERY_STRING)); int year = Utils.getFirstIntValue(rubyArray); rubyArray = (RubyArray) container.runScriptlet(String.format("query(%s, :getMonth)", QUERY_STRING)); int month = Utils.getFirstIntValue(rubyArray); rubyArray = (RubyArray) container.runScriptlet(String.format("query(%s, :getDayOfMonth)", QUERY_STRING)); int day = Utils.getFirstIntValue(rubyArray); return new DateTime(year, month + 1, day, 0, 0); } catch (Exception e) { String message = "Error getting date from " + query; throw new CalabashException(message, e); } } public String getTime(String query) throws CalabashException { try { info("Getting time from %s", query); container.put(QUERY_STRING, query); RubyArray rubyArray = (RubyArray) container.runScriptlet(String.format("query(%s, :getCurrentHour)", QUERY_STRING)); int hour = Utils.getFirstIntValue(rubyArray); rubyArray = (RubyArray) container.runScriptlet(String.format("query(%s, :getCurrentMinute)", QUERY_STRING)); int minute = Utils.getFirstIntValue(rubyArray); return hour + ":" + minute; } catch (Exception e) { String message = "Error getting time from " + query; throw new CalabashException(message, e); } } public void setChecked(String query, boolean checked) throws CalabashException { try { info("Setting checked to : %s", checked); container.put(QUERY_STRING, query); container.runScriptlet(String.format("query(%s, {:method_name => :setChecked, :arguments => [%s] })", QUERY_STRING, checked)); } catch (Exception e) { String message = String.format("Failed to set checked property to: %s", checked); error(message, e); throw new CalabashException(message, e); } } public void performGoBack() throws CalabashException { try { info("Pressing back button"); container.runScriptlet("press_back_button"); pause(); } catch (Exception e) { String message = "Failed to go back"; error(message, e); throw new CalabashException(message, e); } } public void pressEnterKey() throws CalabashException { try { info("Pressing enter key"); container.runScriptlet("press_user_action_button"); pause(); } catch (Exception e) { String message = "Failed to press enter key"; error(message, e); throw new CalabashException(message, e); } } public void scrollDown() throws CalabashException { try { info("Scrolling down"); container.runScriptlet("scroll_down"); } catch (Exception e) { String message = "Failed to scroll down"; error(message, e); throw new CalabashException(message, e); } } public void scrollUp() throws CalabashException { try { info("Scrolling up"); container.runScriptlet("scroll_up"); } catch (Exception e) { String message = "Failed to scroll up"; error(message, e); throw new CalabashException(message, e); } } public void selectMenuItem(String menuItem) throws CalabashException { info("Selecting menu item %s", menuItem); try { touch(String.format("com.android.internal.view.menu.ActionMenuItemView marked:\"%s\"", menuItem)); } catch (CalabashException ce) { selectOptionsMenuItem(menuItem); } } private void selectOptionsMenuItem(String menuItem) throws CalabashException { try { container.put(MENU_ITEM, menuItem); container.runScriptlet(String.format("select_options_menu_item %s", MENU_ITEM)); pause(); } catch (Exception e) { throw new CalabashException(String.format("Failed to select menu item '%s'", menuItem)); } } public void drag(Integer fromX, Integer toX, Integer fromY, Integer toY, Integer steps) throws CalabashException { try { info("Performing drag from: (%s,%s) to: (%s,%s) in %s steps", fromX, fromY, toX, toY, steps); container.runScriptlet(String.format("perform_action('drag', '%d', '%d', '%d', '%d', '%d')", fromX, toX, fromY, toY, steps)); } catch (Exception e) { String message = "Error performing drag"; error(message, e); throw new CalabashException(message, e); } } public void longPress(String query) throws CalabashException { try { info("Long pressing element: %s", query); container.runScriptlet(String.format("long_press_when_element_exists(\"%s\")", query)); pause(); } catch (Exception e) { String message = "Failed to long press"; error(message, e); throw new CalabashException(message, e); } } public void setGPSCoordinates(double latitude, double longitude) throws CalabashException { try { info("Setting gps coordinates %f : %f", latitude, longitude); container.runScriptlet(String.format("set_gps_coordinates(%f, %f)", latitude, longitude)); } catch (Exception e) { String message = String.format("Failed to set coordinates %f : %f", latitude, longitude); error(message, e); throw new CalabashException(message, e); } } public void setGPSLocation(String location) throws CalabashException { try { info("Setting GPS location to : %s", location); container.runScriptlet(String.format("set_gps_coordinates_from_location('%s')", location)); } catch (Exception e) { String message = "Failed to set gps location to : " + location; error(message, e); throw new CalabashException(message, e); } } public void setDate(String query, int year, int month, int day) throws CalabashException { try { info("Setting date: %d-%d-%d - format yyyy-mm-dd", year, month, day); container.put(QUERY_STRING, query); container.runScriptlet(String.format("set_date(%s,%d,%d,%d)", QUERY_STRING, year, month, day)); } catch (Exception e) { String message = String.format("Failed to set date : %d-%d-%d", year, month, day); error(message, e); throw new CalabashException(message, e); } } public void setTime(String query, int hour, int minute) throws CalabashException { try { info("Setting time: %d:%d ", hour, minute); container.put(QUERY_STRING, query); container.runScriptlet(String.format("set_time(%s,%d,%d)", QUERY_STRING, hour, minute)); } catch (Exception e) { String message = String.format("Failed to set time : %d:%d", hour, minute); error(message, e); throw new CalabashException(message, e); } } public RubyHash performAction(String action, String[] args) throws CalabashException { try { info("performing action %s with args %s", action, Utils.getStringFromArray(args)); container.put(ACTION, action); container.put(ACTION_ARGS, args); return (RubyHash) container.runScriptlet(String.format("perform_action(%s,*%s)", ACTION, ACTION_ARGS)); } catch (Exception e) { String message = String.format("Failed to perform action %s with args %s", action, Utils.getStringFromArray(args)); error(message, e); throw new CalabashException(message, e); } } public void waitFor(ICondition condition, WaitOptions options) throws CalabashException, OperationTimedoutException { try { info("Waiting for condition"); addRequiresAndIncludes("Calabash::Android::WaitHelpers"); container.put(WAIT_CONDITION, condition); String waitOptionsHash = getWaitOptionsHash(options); if (waitOptionsHash == null) container.runScriptlet(String.format("wait_for { %s.test }", WAIT_CONDITION)); else { container.runScriptlet(String.format("wait_for(%s) { %s.test }", waitOptionsHash, WAIT_CONDITION)); } } catch (Exception e) { handleWaitException(e, options); } } private void ensureNotDisposed() throws CalabashException { if (disposed) throw new CalabashException("Object is disposed."); } private void handleWaitException(Exception e, WaitOptions options) throws OperationTimedoutException, CalabashException { if (e.getMessage().contains("WaitError")) { String message = null; if (options != null) message = options.getTimeoutMessage(); error("Wait Timed-out"); throw new OperationTimedoutException(message == null ? "Timed out waiting..." : message); } else { error("Failed to wait for condition. %s", e, e.getMessage()); throw new CalabashException(String.format("Failed to wait for condition. %s", e.getMessage())); } } private String getWaitOptionsHash(WaitOptions options) { if (options == null) return null; else { container.put(WAIT_TIMEOUT, options.getTimeoutInSec()); container.put(WAIT_RETRY_FREQ, options.getRetryFreqInSec()); container.put(WAIT_POST_TIMEOUT, options.getPostTimeoutInSec()); container.put(WAIT_TIMEOUT_MESSAGE, options.getTimeoutMessage()); container.put(WAIT_SHOULD_TAKE_SCREENSHOT, options.shouldScreenshotOnError()); return String.format("{:timeout => %s, " + ":retry_frequency => %s, " + ":post_timeout => %s, " + ":timeout_message => %s, " + ":screenshot_on_error => %s}", WAIT_TIMEOUT, WAIT_RETRY_FREQ, WAIT_POST_TIMEOUT, WAIT_TIMEOUT_MESSAGE, WAIT_SHOULD_TAKE_SCREENSHOT); } } private void pause() { try { Thread.sleep(pauseTimeInMilliSec); } catch (InterruptedException ignored) { } } private void addContainerEnv(String envName, String envValue) { String cajEnv = ENVIRONMENT_VAR_PLACEHOLDER; container.put(cajEnv, envValue); container.runScriptlet(format("ENV['%s'] = %s", envName, cajEnv)); } public String getTestServerPort() throws CalabashException { addRequiresAndIncludes("Calabash::Android::Operations"); final Object serverPort = container.runScriptlet("default_device.default_server_port"); return serverPort.toString(); } public boolean elementExistsById(String id) throws CalabashException { try { info("Checking for element's existence"); Boolean existsAsUIElement = (Boolean) container.runScriptlet(String.format("element_exists(\"%s\")", id)); Boolean existsAsWebView = (Boolean) container.runScriptlet("element_exists(\"webView css:'#" + id + "'\")"); return existsAsUIElement || existsAsWebView; } catch (Exception e) { String message = "Failed to check for element's existence"; error(message, e); throw new CalabashException(message, e); } } public void hideKeyboard() throws CalabashException { try { info("hiding keyboard"); container.runScriptlet("hide_soft_keyboard"); } catch (Exception e) { String message = "Failed hide keyboard"; error(message, e); throw new CalabashException(message, e); } } public void waitForActivity(String activityName, int timeout) throws OperationTimedoutException { try { info("waiting for activity %s for %d seconds", activityName, timeout); container.runScriptlet(String.format("wait_for_activity('%s',%d)", activityName, timeout)); } catch (Exception e) { String message = String.format("Activity '%s' did not appear within %d seconds", activityName, timeout); error(message, e); throw new OperationTimedoutException(message); } } public Object executeCommand(String calabashCommand) throws CalabashException { try { info("Executing : %s", calabashCommand); return container.runScriptlet(calabashCommand); } catch (Exception e) { String message = String.format("Failed executing command : %s", calabashCommand); error(message, e); throw new CalabashException(message); } } }