com.google.gwt.junit.RunStyleSelenium Maven / Gradle / Ivy
/*
* 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;
}
}
}
/**
*
* 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.
*
*
* We wait until {@link LaunchThread#isComplete()} returns true
* before starting the keep alive thread or creating a {@link StopThread}, so
* no other thread can be accessing {@link Selenium} at the same time.
*
*/
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);
}
}
}
/**
*
* The {@link Thread} used to stop a selenium instance.
*
*
* 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.
*
*/
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 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 launchThreads = new ArrayList();
/**
* 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 stopThreads = new ArrayList();
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();
}
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 String getIncompleteSpecifierList(
List 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 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 void waitForThreadsToComplete(
List 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);
}
}