/* * Copyright 2008 Google Inc. * * 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 com.google.gwt.junit; import com.google.gwt.core.ext.TreeLogger; import com.google.gwt.core.ext.UnableToCompleteException; import com.thoughtworks.selenium.DefaultSelenium; import com.thoughtworks.selenium.Selenium; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Runs via browsers managed by Selenium. */ public class RunStyleSelenium extends RunStyle { /** * The maximum amount of time that a selenia can take to start in * milliseconds. 10 minutes. */ private static final int LAUNCH_TIMEOUT = 10 * 60 * 1000; /** * Wraps a Selenium instance. */ protected static interface SeleniumWrapper { void createSelenium(String domain); Selenium getSelenium(); String getSpecifier(); } /** * Implements SeleniumWrapper using DefaultSelenium. Visible for testing. */ static class RCSelenium implements SeleniumWrapper { private static final Pattern PATTERN = Pattern.compile("([\\w\\.-]+):([\\d]+)/(.+)"); /* * Visible for testing. */ String browser; String host; int port; private Selenium selenium; private final String specifier; public RCSelenium(String specifier) { this.specifier = specifier; parseSpecifier(); } public void createSelenium(String domain) { this.selenium = new DefaultSelenium(host, port, browser, domain); } public Selenium getSelenium() { return selenium; } public String getSpecifier() { return specifier; } private void parseSpecifier() { Matcher matcher = PATTERN.matcher(specifier); if (!matcher.matches()) { throw new IllegalArgumentException("Unable to parse Selenium target " + specifier + " (expected format is [host]:[port]/[browser])"); } this.browser = matcher.group(3); this.host = matcher.group(1); this.port = Integer.parseInt(matcher.group(2)); } } /** * A {@link Thread} used to interact with {@link Selenium} instances. Selenium * does not support execution of multiple methods at the same time, so its * important to make sure that {@link SeleniumThread#isComplete()} returns * true before calling more methods in {@link Selenium}. */ class SeleniumThread extends Thread { /** * {@link RunStyleSelenium#lock} is sometimes active when calling * {@link #isComplete()}, so we need a separate lock to avoid deadlock. */ Object accessLock = new Object(); /** * The exception thrown while running this thread, if any. */ private Throwable exception; /** * True if the selenia has successfully completed the action. Protected by * {@link #accessLock}. */ private boolean isComplete; private final SeleniumWrapper remote; /** * Construct a new {@link SeleniumThread}. * * @param remote the {@link SeleniumWrapper} instance */ public SeleniumThread(SeleniumWrapper remote) { this.remote = remote; setDaemon(true); } /** * Get the {@link Throwable} caused by the action. * * @return the exception if one occurred, null if none occurred */ public Throwable getException() { synchronized (accessLock) { return exception; } } public SeleniumWrapper getRemote() { return remote; } public boolean isComplete() { synchronized (accessLock) { return isComplete; } } protected void markComplete() { synchronized (accessLock) { isComplete = true; } } protected void setException(Throwable e) { synchronized (accessLock) { this.exception = e; isComplete = true; } } } /** * <p> * The {@link Thread} used to launch a module on a single Selenium target. We * launch {@link Selenium} instances in a separate thread because * {@link Selenium#start()} can hang if the browser cannot be opened * successfully. Instead of blocking the test indefinitely, we use a separate * thread and timeout if needed. * </p> * <p> * We wait until {@link LaunchThread#isComplete()} returns <code>true</code> * before starting the keep alive thread or creating a {@link StopThread}, so * no other thread can be accessing {@link Selenium} at the same time. * </p> */ class LaunchThread extends SeleniumThread { private final String moduleName; /** * Construct a new {@link LaunchThread}. * * @param remote the remote {@link SeleniumWrapper} instance * @param moduleName the module to load */ public LaunchThread(SeleniumWrapper remote, String moduleName) { super(remote); this.moduleName = moduleName; } @Override public void run() { SeleniumWrapper remote = getRemote(); try { String domain = "http://" + getLocalHostName() + ":" + shell.getPort() + "/"; String url = shell.getModuleUrl(moduleName); // Create the selenium instance and open the browser. if (shell.getTopLogger().isLoggable(TreeLogger.TRACE)) { shell.getTopLogger().log(TreeLogger.TRACE, "Starting with domain: " + domain + " Opening URL: " + url); } remote.createSelenium(domain); remote.getSelenium().start(); // We set the speed to 1000ms as a workaround a bug where Selenium#open // can hang. remote.getSelenium().setSpeed("1000"); remote.getSelenium().open(url); remote.getSelenium().setSpeed("0"); markComplete(); } catch (Throwable e) { shell.getTopLogger().log( TreeLogger.ERROR, "Error launching browser via Selenium-RC at " + remote.getSpecifier(), e); setException(e); } } } /** * <p> * The {@link Thread} used to stop a selenium instance. * </p> * <p> * We stop the keep alive thread before creating {@link StopThread}s, and we * do not create {@link StopThread}s if a {@link LaunchThread} is still * running for a {@link Selenium} instance, so no other thread can possible be * accessing {@link Selenium} at the same time. * </p> */ class StopThread extends SeleniumThread { public StopThread(SeleniumWrapper remote) { super(remote); } @Override public void run() { SeleniumWrapper remote = getRemote(); try { remote.getSelenium().stop(); markComplete(); } catch (Throwable e) { shell.getTopLogger().log(TreeLogger.WARN, "Error stopping selenium session at " + remote.getSpecifier(), e); setException(e); } } } /** * The list of hosts that were interrupted. Protected by {@link #lock}. */ private Set<String> interruptedHosts; /** * We keep a list of {@link LaunchThread} instances so that we know which * selenia successfully started. Only selenia that have been successfully * started should be stopped when the test is finished. Protected by * {@link #lock}; */ private List<LaunchThread> launchThreads = new ArrayList<LaunchThread>(); /** * Indicates that testing has stopped, and we no longer need to run keep alive * checks. Protected by {@link #lock}. */ private boolean stopped; private SeleniumWrapper remotes[]; /** * A separate lock to control access to {@link Selenium}, {@link #stopped}, * {@link #remotes}, and {@link #interruptedHosts}. This ensures that the * keepAlive thread doesn't call getTitle after the shutdown thread calls * {@link Selenium#stop()}. */ private final Object lock = new Object(); public RunStyleSelenium(final JUnitShell shell) { super(shell); } @Override public String[] getInterruptedHosts() { synchronized (lock) { if (interruptedHosts == null) { return null; } return interruptedHosts.toArray(new String[interruptedHosts.size()]); } } @Override public int initialize(String args) { if (args == null || args.length() == 0) { getLogger().log(TreeLogger.ERROR, "Selenium runstyle requires comma-separated Selenium-RC targets"); return -1; } String[] targetsIn = args.split(","); SeleniumWrapper targets[] = new SeleniumWrapper[targetsIn.length]; for (int i = 0; i < targets.length; ++i) { try { targets[i] = createSeleniumWrapper(targetsIn[i]); } catch (IllegalArgumentException e) { getLogger().log(TreeLogger.ERROR, e.getMessage()); return -1; } } // We don't need a lock at this point because we haven't started the keep- // alive thread. this.remotes = targets; // Install a shutdown hook that will close all of our outstanding Selenium // sessions. The hook is only executed if the JVM is exited normally. If the // process is terminated, the shutdown hook will not run, which leaves // browser instances open on the Selenium server. We'll need to modify // Selenium Server to do its own cleanup after a timeout. Runtime.getRuntime().addShutdownHook(new Thread() { @Override public void run() { List<StopThread> stopThreads = new ArrayList<StopThread>(); synchronized (lock) { stopped = true; for (LaunchThread launchThread : launchThreads) { // Closing selenium instances that have not successfully started // results in an error on the selenium client. By doing this check, // we are ensuring that no other calls to the remote instance are // being done by another thread. if (launchThread.isComplete()) { StopThread stopThread = new StopThread(launchThread.getRemote()); stopThreads.add(stopThread); stopThread.start(); } } } // Wait for all threads to stop. try { waitForThreadsToComplete(stopThreads, false, "stop", 500); } catch (UnableToCompleteException e) { // This should never happen. } } }); return targets.length; } @Override public void launchModule(String moduleName) throws UnableToCompleteException { // Startup all the selenia and point them at the module url. for (SeleniumWrapper remote : remotes) { LaunchThread thread = new LaunchThread(remote, moduleName); synchronized (lock) { launchThreads.add(thread); } thread.start(); } // Wait for all selenium targets to start. waitForThreadsToComplete(launchThreads, true, "start", 1000); // Check if any threads have thrown an exception. We wait until all threads // have had a change to start so that we don't shutdown while some threads // are still starting. synchronized (lock) { for (LaunchThread thread : launchThreads) { if (thread.getException() != null) { // The thread has already logged the exception. throw new UnableToCompleteException(); } } } // Start the keep alive thread. start(); } /** * Factory method for {@link SeleniumWrapper}. * * @param seleniumSpecifier Specifies the Selenium instance to create * @return an instance of {@link SeleniumWrapper} */ protected SeleniumWrapper createSeleniumWrapper(String seleniumSpecifier) { return new RCSelenium(seleniumSpecifier); } /** * Create the keep-alive thread. */ protected void start() { // This will periodically check for failure of the Selenium session and stop // the test if something goes wrong. Thread keepAliveThread = new Thread() { @Override public void run() { do { try { Thread.sleep(1000); } catch (InterruptedException ignored) { break; } } while (doKeepAlives()); } }; keepAliveThread.setDaemon(true); keepAliveThread.start(); } private boolean doKeepAlives() { synchronized (lock) { if (remotes != null) { // If the shutdown thread has already executed, then we can stop this // thread. if (stopped) { return false; } for (SeleniumWrapper remote : remotes) { // Use getTitle() as a cheap way to see if the Selenium server's still // responding (Selenium seems to provide no way to check the server // status directly). try { if (remote.getSelenium() != null) { remote.getSelenium().getTitle(); } } catch (Throwable e) { // If we ask for the title of the page while a new module is // loading, IE will throw a permission denied exception. String message = e.getMessage(); if (message == null || !message.toLowerCase().contains("permission denied")) { if (interruptedHosts == null) { interruptedHosts = new HashSet<String>(); } interruptedHosts.add(remote.getSpecifier()); } } } } return interruptedHosts == null; } } /** * Get the display list of specifiers for threads that did not complete. * * @param threads the list of threads * @return a list of specifiers */ private <T extends SeleniumThread> String getIncompleteSpecifierList( List<T> threads) { String list = ""; for (SeleniumThread thread : threads) { if (!thread.isComplete()) { list += " " + thread.getRemote().getSpecifier() + "\n"; } } return list; } /** * Iterate over a list of {@link SeleniumThread}s, waiting for them to finish. * * @param <T> the thread type * @param threads the list of threads * @param fatalExceptions true to treat all exceptions as errors, false to * treat exceptions as warnings * @param action the action being performed by the thread * @param sleepTime the amount of time to sleep in milliseconds * @throws UnableToCompleteException if the thread times out and * fatalExceptions is true */ private <T extends SeleniumThread> void waitForThreadsToComplete( List<T> threads, boolean fatalExceptions, String action, int sleepTime) throws UnableToCompleteException { boolean allComplete; long endTime = System.currentTimeMillis() + LAUNCH_TIMEOUT; do { try { Thread.sleep(sleepTime); } catch (InterruptedException e) { // This should not happen. throw new UnableToCompleteException(); } allComplete = true; synchronized (lock) { for (SeleniumThread thread : threads) { if (!thread.isComplete()) { allComplete = false; } } } // Check if we have timed out. if (!allComplete && endTime < System.currentTimeMillis()) { allComplete = true; String message = "The following Selenium instances did not " + action + " within " + LAUNCH_TIMEOUT + "ms:\n"; synchronized (lock) { message += getIncompleteSpecifierList(threads); } if (fatalExceptions) { shell.getTopLogger().log(TreeLogger.ERROR, message); throw new UnableToCompleteException(); } else { shell.getTopLogger().log(TreeLogger.WARN, message); } } } while (!allComplete); } }