Implements -wait mode for JUnit, where the system waits for a user to manually connect back through an external browser.  Supports multiple browsers, and when all are connected, runs them in sync.

Refactor of JUnitMessageQueue for better client tracking to enable good user feedback on the console.

Refactor of client-server architecture; instead of launching a new browser per module, JUnit now uses a single browser for the entire run; the client browser will browse to a new page when a new module is run.

Suggested by: knorton
Review by: zundel
Inspired by: free pour latte art

git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@2590 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/dev/core/src/com/google/gwt/dev/GWTShell.java b/dev/core/src/com/google/gwt/dev/GWTShell.java
index 6513b81..8d71094 100644
--- a/dev/core/src/com/google/gwt/dev/GWTShell.java
+++ b/dev/core/src/com/google/gwt/dev/GWTShell.java
@@ -743,7 +743,7 @@
     while (notDone()) {
       try {
         if (!display.readAndDispatch()) {
-          display.sleep();
+          sleep();
         }
       } catch (Throwable e) {
         String msg = e.getMessage();
@@ -770,6 +770,10 @@
     EmbeddedTomcatServer.stop();
   }
 
+  protected void sleep() {
+    display.sleep();
+  }
+
   protected boolean startUp() {
     if (started) {
       throw new IllegalStateException("Startup code has already been run");
diff --git a/user/src/com/google/gwt/junit/JUnitMessageQueue.java b/user/src/com/google/gwt/junit/JUnitMessageQueue.java
index dfbae0e..7947399 100644
--- a/user/src/com/google/gwt/junit/JUnitMessageQueue.java
+++ b/user/src/com/google/gwt/junit/JUnitMessageQueue.java
@@ -20,11 +20,9 @@
 import com.google.gwt.junit.client.impl.JUnitHost.TestInfo;
 
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 
 /**
  * A message queue to pass data between {@link JUnitShell} and {@link
@@ -43,61 +41,43 @@
 public class JUnitMessageQueue {
 
   /**
-   * Tracks which test each client is requesting.
-   * 
-   * Key = client-id (e.g. agent+host) Value = the index of the current
-   * requested test
+   * Holds the state of an individual client.
    */
-  private Map<String, Integer> clientTestRequests = new HashMap<String, Integer>();
+  public static class ClientStatus {
+    public final String clientId;
+
+    public JUnitResult currentTestResults = null;
+    public boolean hasRequestedCurrentTest = false;
+    public boolean isNew = true;
+
+    public ClientStatus(String clientId) {
+      this.clientId = clientId;
+    }
+  }
 
   /**
-   * The index of the current test being executed.
+   * Records results for each client; must lock before accessing.
    */
-  private int currentTestIndex = -1;
+  private final Map<String, ClientStatus> clientStatuses = new HashMap<String, ClientStatus>();
+
+  /**
+   * The lock used to synchronize access to clientStatuses.
+   */
+  private Object clientStatusesLock = new Object();
+
+  /**
+   * The current test to execute.
+   */
+  private TestInfo currentTest;
 
   /**
    * The number of TestCase clients executing in parallel.
    */
-  private int numClients = 1;
+  private final int numClients;
 
-  /**
-   * The lock used to synchronize access around testMethod, clientTestRequests,
-   * and currentTestIndex.
-   */
-  private Object readTestLock = new Object();
+  private int numClientsHaveRequestedTest;
 
-  /**
-   * The lock used to synchronize access around testResult.
-   */
-  private Object resultsLock = new Object();
-
-  /**
-   * The name of the test class to execute.
-   */
-  private String testClass;
-
-  /**
-   * The name of the test method to execute.
-   */
-  private String testMethod;
-
-  /**
-   * The name of the module to execute.
-   */
-  private String testModule;
-
-  /**
-   * The results for the current test method.
-   */
-  private List<JUnitResult> testResult = new ArrayList<JUnitResult>();
-
-  /**
-   * Creates a message queue with one client.
-   * 
-   * @see JUnitMessageQueue#JUnitMessageQueue(int)
-   */
-  JUnitMessageQueue() {
-  }
+  private int numClientsHaveResults;
 
   /**
    * Only instantiable within this package.
@@ -112,29 +92,33 @@
   /**
    * Called by the servlet to query for for the next method to test.
    * 
-   * @param moduleName the name of the executing module
    * @param timeout how long to wait for an answer
    * @return the next test to run, or <code>null</code> if
    *         <code>timeout</code> is exceeded or the next test does not match
    *         <code>testClassName</code>
    */
-  public TestInfo getNextTestInfo(String clientId, String moduleName,
-      long timeout) throws TimeoutException {
-    synchronized (readTestLock) {
+  public TestInfo getNextTestInfo(String clientId, long timeout)
+      throws TimeoutException {
+    synchronized (clientStatusesLock) {
+      ClientStatus clientStatus = clientStatuses.get(clientId);
+      if (clientStatus == null) {
+        clientStatus = new ClientStatus(clientId);
+        clientStatuses.put(clientId, clientStatus);
+      }
+
       long startTime = System.currentTimeMillis();
       long stopTime = startTime + timeout;
-      while (!testIsAvailableFor(clientId, moduleName)) {
+      while (clientStatus.hasRequestedCurrentTest == true) {
         long timeToWait = stopTime - System.currentTimeMillis();
         if (timeToWait < 1) {
           double elapsed = (System.currentTimeMillis() - startTime) / 1000.0;
           throw new TimeoutException("The servlet did not respond to the "
               + "next query to test within " + timeout + "ms.\n"
-              + " Module Name: " + moduleName + "\n" + " Client id: "
-              + clientId + "\n" + " Actual time elapsed: " + elapsed
-              + " seconds.\n");
+              + " Client id: " + clientId + "\n" + " Actual time elapsed: "
+              + elapsed + " seconds.\n");
         }
         try {
-          readTestLock.wait(timeToWait);
+          clientStatusesLock.wait(timeToWait);
         } catch (InterruptedException e) {
           /*
            * Should never happen; but if it does, just send a null back to the
@@ -148,45 +132,73 @@
         }
       }
 
-      if (!moduleName.equals(testModule)) {
-        /*
-         * When a module finishes, clients will continue to query for another
-         * test case; return null to indicate we're done.
-         */
-        return null;
-      }
-
-      bumpClientTestRequest(clientId);
-      return new TestInfo(testClass, testMethod);
+      // Record that this client has retrieved the current test.
+      clientStatus.hasRequestedCurrentTest = true;
+      ++numClientsHaveRequestedTest;
+      return currentTest;
     }
   }
 
   /**
    * Called by the servlet to report the results of the last test to run.
    * 
-   * @param moduleName the name of the test module
+   * @param testInfo the testInfo the result is for
    * @param results the result of running the test
    */
-  public void reportResults(String moduleName, JUnitResult results) {
-    synchronized (resultsLock) {
-      if (!moduleName.equals(testModule)) {
-        // an old client is trying to report results, do nothing
+  public void reportResults(String clientId, TestInfo testInfo,
+      JUnitResult results) {
+    synchronized (clientStatusesLock) {
+      if (testInfo != null && !testInfo.equals(currentTest)) {
+        // A client is reporting results for the wrong test.
         return;
       }
-      testResult.add(results);
+      ClientStatus clientStatus = clientStatuses.get(clientId);
+      clientStatus.currentTestResults = results;
+      ++numClientsHaveResults;
+      clientStatusesLock.notifyAll();
+    }
+  }
+
+  /**
+   * Returns any new clients that have contacted the server since the last call.
+   * The same client will never be returned from this method twice.
+   */
+  String[] getNewClients() {
+    synchronized (clientStatusesLock) {
+      List<String> results = new ArrayList<String>();
+      for (ClientStatus clientStatus : clientStatuses.values()) {
+        if (clientStatus.isNew) {
+          results.add(clientStatus.clientId);
+          // Record that this client is no longer new.
+          clientStatus.isNew = false;
+        }
+      }
+      return results.toArray(new String[results.size()]);
+    }
+  }
+
+  /**
+   * Returns how many clients have requested the currently-running test.
+   */
+  int getNumClientsRetrievedCurrentTest() {
+    synchronized (clientStatusesLock) {
+      return numClientsHaveRequestedTest;
     }
   }
 
   /**
    * Fetches the results of a completed test.
    * 
-   * @param moduleName the name of the test module
-   * @return An getException thrown from a failed test, or <code>null</code>
-   *         if the test completed without error.
+   * @return A map of results from all clients.
    */
-  List<JUnitResult> getResults(String moduleName) {
-    assert (moduleName.equals(testModule));
-    return testResult;
+  Map<String, JUnitResult> getResults() {
+    Map<String, JUnitResult> result = new HashMap<String, JUnitResult>();
+    synchronized (clientStatusesLock) {
+      for (ClientStatus clientStatus : clientStatuses.values()) {
+        result.put(clientStatus.clientId, clientStatus.currentTestResults);
+      }
+    }
+    return result;
   }
 
   /**
@@ -197,26 +209,23 @@
    *         current test.
    */
   String getUnretrievedClients() {
-    int lineCount = 0;
     StringBuilder buf = new StringBuilder();
-    synchronized (readTestLock) {
-      Set<String> keys = clientTestRequests.keySet();
-
-      for (String key : keys) {
+    synchronized (clientStatusesLock) {
+      int lineCount = 0;
+      for (ClientStatus clientStatus : clientStatuses.values()) {
         if (lineCount > 0) {
           buf.append('\n');
         }
 
-        if (clientTestRequests.get(key) <= currentTestIndex) {
+        if (!clientStatus.hasRequestedCurrentTest) {
           buf.append(" - NO RESPONSE: ");
-          buf.append(key);
         } else {
           buf.append(" - (ok): ");
-          buf.append(key);
         }
+        buf.append(clientStatus.clientId);
         lineCount++;
       }
-      int difference = numClients - keys.size();
+      int difference = numClients - numClientsHaveRequestedTest;
       if (difference > 0) {
         if (lineCount > 0) {
           buf.append('\n');
@@ -232,85 +241,37 @@
   /**
    * Called by the shell to see if the currently-running test has completed.
    * 
-   * @param moduleName the name of the test module
    * @return If the test has completed, <code>true</code>, otherwise
    *         <code>false</code>.
    */
-  boolean hasResult(String moduleName) {
-    synchronized (resultsLock) {
-      assert (moduleName.equals(testModule));
-      return testResult.size() == numClients;
+  boolean hasResult() {
+    synchronized (clientStatusesLock) {
+      return numClients == numClientsHaveResults;
     }
   }
 
   /**
-   * Returns <code>true</code> if all clients have requested the
-   * currently-running test.
+   * Called by the shell to set the next test to run.
    */
-  boolean haveAllClientsRetrievedCurrentTest() {
-    synchronized (readTestLock) {
-      // If a client hasn't yet made contact back to JUnitShell, it will have
-      // no entry
-      Collection<Integer> clientIndices = clientTestRequests.values();
-      if (clientIndices.size() < numClients) {
-        return false;
+  void setNextTest(TestInfo testInfo) {
+    synchronized (clientStatusesLock) {
+      this.currentTest = testInfo;
+      for (ClientStatus clientStatus : clientStatuses.values()) {
+        clientStatus.hasRequestedCurrentTest = false;
+        clientStatus.currentTestResults = null;
       }
-      // Every client must have been bumped PAST the current test index
-      for (Integer value : clientIndices) {
-        if (value <= currentTestIndex) {
-          return false;
-        }
+      numClientsHaveResults = 0;
+      numClientsHaveRequestedTest = 0;
+      clientStatusesLock.notifyAll();
+    }
+  }
+
+  void waitForResults(int millis) {
+    synchronized (clientStatusesLock) {
+      try {
+        clientStatusesLock.wait(millis);
+      } catch (InterruptedException e) {
       }
-      return true;
     }
   }
-
-  /**
-   * Called by the shell to set the name of the next method to run for this test
-   * class.
-   * 
-   * @param testModule the name of the module to be run.
-   * @param testClass The name of the test class.
-   * @param testMethod The name of the method to run.
-   */
-  void setNextTestName(String testModule, String testClass, String testMethod) {
-    synchronized (readTestLock) {
-      this.testModule = testModule;
-      this.testClass = testClass;
-      this.testMethod = testMethod;
-      ++currentTestIndex;
-      testResult = new ArrayList<JUnitResult>(numClients);
-      readTestLock.notifyAll();
-    }
-  }
-
-  /**
-   * Sets the number of clients that will be executing the JUnit tests in
-   * parallel.
-   * 
-   * @param numClients must be > 0
-   */
-  void setNumClients(int numClients) {
-    this.numClients = numClients;
-  }
-
-  // This method requires that readTestLock is being held for the duration.
-  private void bumpClientTestRequest(String clientId) {
-    Integer index = clientTestRequests.get(clientId);
-    clientTestRequests.put(clientId, index + 1);
-  }
-
-  // This method requires that readTestLock is being held for the duration.
-  private boolean testIsAvailableFor(String clientId, String moduleName) {
-    if (!moduleName.equals(testModule)) {
-      // the "null" test is always available for an old client
-      return true;
-    }
-    Integer index = clientTestRequests.get(clientId);
-    if (index == null) {
-      index = 0;
-      clientTestRequests.put(clientId, index);
-    }
-    return index == currentTestIndex;
-  }
 }
diff --git a/user/src/com/google/gwt/junit/JUnitShell.java b/user/src/com/google/gwt/junit/JUnitShell.java
index f7c1e3d..ca01de9 100644
--- a/user/src/com/google/gwt/junit/JUnitShell.java
+++ b/user/src/com/google/gwt/junit/JUnitShell.java
@@ -29,6 +29,8 @@
 import com.google.gwt.junit.client.TimeoutException;
 import com.google.gwt.junit.client.impl.GWTRunner;
 import com.google.gwt.junit.client.impl.JUnitResult;
+import com.google.gwt.junit.client.impl.JUnitHost.TestInfo;
+import com.google.gwt.util.tools.ArgHandler;
 import com.google.gwt.util.tools.ArgHandlerFlag;
 import com.google.gwt.util.tools.ArgHandlerString;
 
@@ -37,7 +39,8 @@
 import junit.framework.TestResult;
 
 import java.util.ArrayList;
-import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -187,6 +190,11 @@
   private ModuleDef currentModule;
 
   /**
+   * If true, no launches have yet been successful.
+   */
+  private boolean firstLaunch = true;
+
+  /**
    * If true, the last attempt to launch failed.
    */
   private boolean lastLaunchFailed;
@@ -209,17 +217,17 @@
   private RunStyle runStyle = new RunStyleLocalHosted(this);
 
   /**
+   * The time the test actually began.
+   */
+  private long testBeginTime;
+
+  /**
    * The time at which the current test will fail if the client has not yet
    * started the test.
    */
   private long testBeginTimeout;
 
   /**
-   * The time the test actually began.
-   */
-  private long testBeginTime;
-
-  /**
    * Enforce the singleton pattern. The call to {@link GWTShell}'s ctor forces
    * server mode and disables processing extra arguments as URLs to be shown.
    */
@@ -241,6 +249,7 @@
       @Override
       public boolean setFlag() {
         runStyle = new RunStyleLocalWeb(JUnitShell.this);
+        numClients = 1;
         return true;
       }
 
@@ -271,12 +280,60 @@
       @Override
       public boolean setString(String str) {
         String[] urls = str.split(",");
-        numClients = urls.length;
         runStyle = RunStyleRemoteWeb.create(JUnitShell.this, urls);
+        numClients = urls.length;
         return runStyle != null;
       }
     });
 
+    registerHandler(new ArgHandler() {
+
+      @Override
+      public String[] getDefaultArgs() {
+        return null;
+      }
+
+      @Override
+      public String getPurpose() {
+        return "Causes the system to wait for a remote browser to connect";
+      }
+
+      @Override
+      public String getTag() {
+        return "-wait";
+      }
+
+      @Override
+      public String[] getTagArgs() {
+        return new String[] {"[numClients]"};
+      }
+
+      @Override
+      public int handle(String[] args, int tagIndex) {
+        int value = 1;
+        if (tagIndex + 1 < args.length) {
+          try {
+            // See if the next item is an integer.
+            value = Integer.parseInt(args[tagIndex + 1]);
+            if (value >= 1) {
+              setInt(value);
+              return 1;
+            }
+          } catch (NumberFormatException e) {
+            // fall-through
+          }
+        }
+        setInt(1);
+        return 0;
+      }
+
+      public void setInt(int value) {
+        runStyle = new RunStyleWait(JUnitShell.this, value);
+        numClients = value;
+      }
+
+    });
+
     registerHandler(new ArgHandlerFlag() {
 
       @Override
@@ -353,9 +410,27 @@
    */
   @Override
   protected boolean notDone() {
-    if (!messageQueue.haveAllClientsRetrievedCurrentTest()
-        && testBeginTimeout < System.currentTimeMillis()) {
-      double elapsed = (System.currentTimeMillis() - testBeginTime) / 1000.0;
+    int activeClients = messageQueue.getNumClientsRetrievedCurrentTest();
+    if (firstLaunch && runStyle instanceof RunStyleWait) {
+      String[] newClients = messageQueue.getNewClients();
+      int printIndex = activeClients - newClients.length + 1;
+      for (String newClient : newClients) {
+        System.out.println(printIndex + " - " + newClient);
+        ++printIndex;
+      }
+      if (activeClients == this.numClients) {
+        System.out.println("Starting tests");
+      } else {
+        // Wait forever for first contact; user-driven.
+        return true;
+      }
+    }
+
+    long currentTimeMillis = System.currentTimeMillis();
+    if (activeClients == numClients) {
+      firstLaunch = false;
+    } else if (testBeginTimeout < currentTimeMillis) {
+      double elapsed = (currentTimeMillis - testBeginTime) / 1000.0;
       throw new TimeoutException(
           "The browser did not contact the server within "
               + TEST_BEGIN_TIMEOUT_MILLIS + "ms.\n"
@@ -363,13 +438,22 @@
               + "\n Actual time elapsed: " + elapsed + " seconds.\n");
     }
 
-    if (messageQueue.hasResult(currentModule.getName())) {
+    if (messageQueue.hasResult()) {
       return false;
     }
 
     return !runStyle.wasInterrupted();
   }
 
+  @Override
+  protected void sleep() {
+    if (runStyle.isLocal()) {
+      super.sleep();
+    } else {
+      messageQueue.waitForResults(1000);
+    }
+  }
+
   void compileForWebMode(String moduleName, String userAgentString)
       throws UnableToCompleteException {
     ModuleDef module = doLoadModule(getTopLogger(), moduleName);
@@ -392,6 +476,10 @@
       TestResult testResult, Strategy strategy)
       throws UnableToCompleteException {
 
+    if (lastLaunchFailed) {
+      throw new UnableToCompleteException();
+    }
+
     String syntheticModuleName = moduleName + "."
         + strategy.getSyntheticModuleExtension();
     boolean sameTest = (currentModule != null)
@@ -416,14 +504,16 @@
           "junit.moduleName");
       moduleNameProp.addKnownValue(moduleName);
       moduleNameProp.setActiveValue(moduleName);
+      runStyle.maybeCompileModule(syntheticModuleName);
     }
 
-    lastLaunchFailed = false;
-    messageQueue.setNextTestName(currentModule.getName(),
-        testCase.getClass().getName(), testCase.getName());
+    messageQueue.setNextTest(new TestInfo(currentModule.getName(),
+        testCase.getClass().getName(), testCase.getName()));
 
     try {
-      runStyle.maybeLaunchModule(currentModule.getName(), !sameTest);
+      if (firstLaunch) {
+        runStyle.launchModule(currentModule.getName());
+      }
     } catch (UnableToCompleteException e) {
       lastLaunchFailed = true;
       testResult.addError(testCase, new JUnitFatalLaunchException(e));
@@ -443,22 +533,19 @@
       return;
     }
 
-    List<JUnitResult> results = messageQueue.getResults(currentModule.getName());
-
-    if (results == null) {
-      return;
-    }
+    Map<String, JUnitResult> results = messageQueue.getResults();
 
     boolean parallelTesting = numClients > 1;
 
-    for (JUnitResult result : results) {
+    for (Entry<String, JUnitResult> entry : results.entrySet()) {
+      String clientId = entry.getKey();
+      JUnitResult result = entry.getValue();
       Throwable exception = result.getException();
 
       // In the case that we're running multiple clients at once, we need to
       // let the user know the browser in which the failure happened
       if (parallelTesting && exception != null) {
-        String msg = "Remote test failed at " + result.getHost() + " on "
-            + result.getAgent();
+        String msg = "Remote test failed at " + clientId;
         if (exception instanceof AssertionFailedError) {
           AssertionFailedError newException = new AssertionFailedError(msg
               + "\n" + exception.getMessage());
diff --git a/user/src/com/google/gwt/junit/RunStyle.java b/user/src/com/google/gwt/junit/RunStyle.java
index ba1e255..694f56d 100644
--- a/user/src/com/google/gwt/junit/RunStyle.java
+++ b/user/src/com/google/gwt/junit/RunStyle.java
@@ -15,6 +15,7 @@
  */
 package com.google.gwt.junit;
 
+import com.google.gwt.core.ext.TreeLogger;
 import com.google.gwt.core.ext.UnableToCompleteException;
 
 /**
@@ -23,14 +24,38 @@
 abstract class RunStyle {
 
   /**
-   * Possibly launches a browser window to run the specified module.
+   * The containing shell.
+   */
+  protected final JUnitShell shell;
+
+  /**
+   * @param shell the containing shell
+   */
+  public RunStyle(JUnitShell shell) {
+    this.shell = shell;
+  }
+
+  /**
+   * Returns whether or not the local UI event loop needs to be pumped.
+   */
+  public abstract boolean isLocal();
+
+  /**
+   * Requests initial launch of the browser.
    * 
    * @param moduleName the module to run
-   * @param forceLaunch If <code>true</code>, forces a new browser window to
-   *          be launched (because <code>testCaseClassName</code> changed)
    * @throws UnableToCompleteException
    */
-  public abstract void maybeLaunchModule(String moduleName, boolean forceLaunch)
+  public abstract void launchModule(String moduleName)
+      throws UnableToCompleteException;
+
+  /**
+   * Possibly causes a compilation on the specified module.
+   * 
+   * @param moduleName the module to compile
+   * @throws UnableToCompleteException
+   */
+  public abstract void maybeCompileModule(String moduleName)
       throws UnableToCompleteException;
 
   /**
@@ -43,6 +68,13 @@
   }
 
   /**
+   * Gets the shell logger.
+   */
+  protected TreeLogger getLogger() {
+    return shell.getTopLogger();
+  }
+
+  /**
    * Gets the suffix of the URL to load.
    * 
    * @param moduleName the module to run
diff --git a/user/src/com/google/gwt/junit/RunStyleLocalHosted.java b/user/src/com/google/gwt/junit/RunStyleLocalHosted.java
index 5959f7a..5861c45 100644
--- a/user/src/com/google/gwt/junit/RunStyleLocalHosted.java
+++ b/user/src/com/google/gwt/junit/RunStyleLocalHosted.java
@@ -24,28 +24,28 @@
 class RunStyleLocalHosted extends RunStyle {
 
   /**
-   * The containing shell.
-   */
-  protected final JUnitShell shell;
-
-  /**
    * A browser window to host local tests.
    */
   private BrowserWidget browserWindow;
 
-  /**
-   * @param shell the containing shell
-   */
   RunStyleLocalHosted(JUnitShell shell) {
-    this.shell = shell;
+    super(shell);
   }
 
   @Override
-  public void maybeLaunchModule(String moduleName, boolean forceLaunch)
+  public boolean isLocal() {
+    return true;
+  }
+
+  @Override
+  public void launchModule(String moduleName) throws UnableToCompleteException {
+    launchUrl(getUrlSuffix(moduleName));
+  }
+
+  @Override
+  public void maybeCompileModule(String moduleName)
       throws UnableToCompleteException {
-    if (forceLaunch) {
-      launchUrl(getUrlSuffix(moduleName));
-    }
+    // nothing to do
   }
 
   protected BrowserWidget getBrowserWindow() throws UnableToCompleteException {
diff --git a/user/src/com/google/gwt/junit/RunStyleLocalWeb.java b/user/src/com/google/gwt/junit/RunStyleLocalWeb.java
index bf1266b..84c9159 100644
--- a/user/src/com/google/gwt/junit/RunStyleLocalWeb.java
+++ b/user/src/com/google/gwt/junit/RunStyleLocalWeb.java
@@ -36,13 +36,14 @@
   }
 
   @Override
-  public void maybeLaunchModule(String moduleName, boolean forceLaunch)
-      throws UnableToCompleteException {
-    if (forceLaunch) {
-      BrowserWidget browserWindow = getBrowserWindow();
-      shell.compileForWebMode(moduleName, browserWindow.getUserAgent());
-      launchUrl(getUrlSuffix(moduleName) + "?" + PROP_GWT_HYBRID_MODE);
-    }
+  public void launchModule(String moduleName) throws UnableToCompleteException {
+    launchUrl(getUrlSuffix(moduleName) + "?" + PROP_GWT_HYBRID_MODE);
   }
 
+  @Override
+  public void maybeCompileModule(String moduleName)
+      throws UnableToCompleteException {
+    BrowserWidget browserWindow = getBrowserWindow();
+    shell.compileForWebMode(moduleName, browserWindow.getUserAgent());
+  }
 }
diff --git a/user/src/com/google/gwt/junit/RunStyleRemote.java b/user/src/com/google/gwt/junit/RunStyleRemote.java
new file mode 100644
index 0000000..927b303
--- /dev/null
+++ b/user/src/com/google/gwt/junit/RunStyleRemote.java
@@ -0,0 +1,53 @@
+/*
+ * 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.UnableToCompleteException;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+/**
+ * Runs remotely in web mode. This feature is experimental and is not officially
+ * supported.
+ */
+abstract class RunStyleRemote extends RunStyle {
+
+  public RunStyleRemote(JUnitShell shell) {
+    super(shell);
+  }
+
+  @Override
+  public boolean isLocal() {
+    return false;
+  }
+
+  @Override
+  public void maybeCompileModule(String moduleName)
+      throws UnableToCompleteException {
+    shell.compileForWebMode(moduleName, null);
+  }
+
+  protected String getMyUrl(String moduleName) {
+    try {
+      String localhost = InetAddress.getLocalHost().getHostAddress();
+      return "http://" + localhost + ":" + shell.getPort() + "/"
+          + getUrlSuffix(moduleName);
+    } catch (UnknownHostException e) {
+      throw new RuntimeException("Unable to determine my ip address", e);
+    }
+  }
+}
diff --git a/user/src/com/google/gwt/junit/RunStyleRemoteWeb.java b/user/src/com/google/gwt/junit/RunStyleRemoteWeb.java
index c5c5349..95c3ede 100644
--- a/user/src/com/google/gwt/junit/RunStyleRemoteWeb.java
+++ b/user/src/com/google/gwt/junit/RunStyleRemoteWeb.java
@@ -21,20 +21,18 @@
 import com.google.gwt.junit.remote.BrowserManager;
 
 import java.io.IOException;
-import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.ServerSocket;
 import java.net.Socket;
 import java.net.SocketTimeoutException;
-import java.net.UnknownHostException;
 import java.rmi.Naming;
 import java.rmi.server.RMISocketFactory;
 
 /**
- * Runs remotely in web mode. This feature is experimental and is not officially
- * supported.
+ * Runs in web mode via browsers managed over RMI. This feature is experimental
+ * and is not officially supported.
  */
-class RunStyleRemoteWeb extends RunStyle {
+class RunStyleRemoteWeb extends RunStyleRemote {
 
   static class RMISocketFactoryWithTimeouts extends RMISocketFactory {
     private static boolean initialized;
@@ -120,13 +118,6 @@
    */
   private int[] remoteTokens;
 
-  private boolean running = false;
-
-  /**
-   * The containing shell.
-   */
-  private final JUnitShell shell;
-
   /**
    * @param shell the containing shell
    * @param browserManagers a populated array of RMI remote interfaces to each
@@ -136,54 +127,44 @@
    */
   private RunStyleRemoteWeb(JUnitShell shell, BrowserManager[] browserManagers,
       String[] urls) {
-    this.shell = shell;
+    super(shell);
     this.browserManagers = browserManagers;
     this.remoteTokens = new int[browserManagers.length];
     this.remoteBmsUrls = urls;
   }
 
   @Override
-  public void maybeLaunchModule(String moduleName, boolean forceLaunch)
-      throws UnableToCompleteException {
+  public boolean isLocal() {
+    return false;
+  }
 
-    if (forceLaunch || !running) {
-      shell.compileForWebMode(moduleName, null);
-      String localhost;
+  @Override
+  public void launchModule(String moduleName) throws UnableToCompleteException {
+    String url = getMyUrl(moduleName);
 
+    for (int i = 0; i < remoteTokens.length; ++i) {
+      long callStart = System.currentTimeMillis();
       try {
-        localhost = InetAddress.getLocalHost().getHostAddress();
-      } catch (UnknownHostException e) {
-        throw new RuntimeException("Unable to determine my ip address", e);
-      }
-      String url = "http://" + localhost + ":" + shell.getPort() + "/"
-          + getUrlSuffix(moduleName);
-
-      for (int i = 0; i < remoteTokens.length; ++i) {
-        long callStart = System.currentTimeMillis();
-        try {
-          int remoteToken = remoteTokens[i];
-          BrowserManager mgr = browserManagers[i];
-          if (remoteToken != 0) {
-            mgr.killBrowser(remoteToken);
-          }
-          remoteTokens[i] = mgr.launchNewBrowser(url, INITIAL_KEEPALIVE_MS);
-        } catch (Exception e) {
-          Throwable cause = e.getCause();
-          if (cause instanceof SocketTimeoutException) {
-            long elapsed = System.currentTimeMillis()
-                - callStart;
-            shell.getTopLogger().log(
-                TreeLogger.ERROR,
-                "Timeout: " + elapsed + "ms  launching remote browser at: "
-                    + remoteBmsUrls[i], e.getCause());
-            throw new UnableToCompleteException();
-          }
-          shell.getTopLogger().log(TreeLogger.ERROR,
-              "Error launching remote browser at " + remoteBmsUrls[i], e);
+        int remoteToken = remoteTokens[i];
+        BrowserManager mgr = browserManagers[i];
+        if (remoteToken != 0) {
+          mgr.killBrowser(remoteToken);
+        }
+        remoteTokens[i] = mgr.launchNewBrowser(url, INITIAL_KEEPALIVE_MS);
+      } catch (Exception e) {
+        Throwable cause = e.getCause();
+        if (cause instanceof SocketTimeoutException) {
+          long elapsed = System.currentTimeMillis() - callStart;
+          getLogger().log(
+              TreeLogger.ERROR,
+              "Timeout: " + elapsed + "ms  launching remote browser at: "
+                  + remoteBmsUrls[i], e.getCause());
           throw new UnableToCompleteException();
         }
+        getLogger().log(TreeLogger.ERROR,
+            "Error launching remote browser at " + remoteBmsUrls[i], e);
+        throw new UnableToCompleteException();
       }
-      running = true;
     }
   }
 
@@ -200,16 +181,15 @@
         } catch (Exception e) {
           Throwable cause = e.getCause();
           if (cause instanceof SocketTimeoutException) {
-            long elapsed = System.currentTimeMillis()
-                - callStart;
+            long elapsed = System.currentTimeMillis() - callStart;
             throw new TimeoutException("Timeout: " + elapsed
-                + "ms  keeping alive remote browser at: " + remoteBmsUrls[i], 
+                + "ms  keeping alive remote browser at: " + remoteBmsUrls[i],
                 e.getCause());
           } else if (e instanceof IllegalStateException) {
-            shell.getTopLogger().log(TreeLogger.INFO,
+            getLogger().log(TreeLogger.INFO,
                 "Browser at: " + remoteBmsUrls[i] + " already exited.", e);
           } else {
-            shell.getTopLogger().log(TreeLogger.ERROR,
+            getLogger().log(TreeLogger.ERROR,
                 "Error keeping alive remote browser at " + remoteBmsUrls[i], e);
           }
           return true;
diff --git a/user/src/com/google/gwt/junit/RunStyleWait.java b/user/src/com/google/gwt/junit/RunStyleWait.java
new file mode 100644
index 0000000..d3c38f5
--- /dev/null
+++ b/user/src/com/google/gwt/junit/RunStyleWait.java
@@ -0,0 +1,55 @@
+/*
+ * 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.UnableToCompleteException;
+
+/**
+ * Runs in web mode waiting for the user to contact the server with their own
+ * browser.
+ */
+class RunStyleWait extends RunStyleRemote {
+
+  private final int numClients;
+
+  public RunStyleWait(JUnitShell shell, int numClients) {
+    super(shell);
+    this.numClients = numClients;
+  }
+
+  @Override
+  public boolean isLocal() {
+    return false;
+  }
+
+  @Override
+  public void launchModule(String moduleName) throws UnableToCompleteException {
+    if (numClients == 1) {
+      System.out.println("Compilation finished; please navigate your browser to this URL:");
+    } else {
+      System.out.println("Compilation finished; please navigate " + numClients
+          + " browsers to this URL:");
+    }
+    System.out.println(getMyUrl(moduleName));
+  }
+
+  @Override
+  public void maybeCompileModule(String moduleName)
+      throws UnableToCompleteException {
+    System.out.println("Please wait while we compile " + moduleName + "...");
+    super.maybeCompileModule(moduleName);
+  }
+}
diff --git a/user/src/com/google/gwt/junit/client/impl/JUnitHost.java b/user/src/com/google/gwt/junit/client/impl/JUnitHost.java
index ce48462..a968fd1 100644
--- a/user/src/com/google/gwt/junit/client/impl/JUnitHost.java
+++ b/user/src/com/google/gwt/junit/client/impl/JUnitHost.java
@@ -31,8 +31,10 @@
   public static class TestInfo implements IsSerializable {
     private String testClass;
     private String testMethod;
+    private String testModule;
 
-    public TestInfo(String testClass, String testMethod) {
+    public TestInfo(String testModule, String testClass, String testMethod) {
+      this.testModule = testModule;
       this.testClass = testClass;
       this.testMethod = testMethod;
     }
@@ -43,6 +45,17 @@
     TestInfo() {
     }
 
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof TestInfo) {
+        TestInfo other = (TestInfo) o;
+        return getTestModule().equals(other.getTestModule())
+            && getTestClass().equals(other.getTestClass())
+            && getTestMethod().equals(other.getTestMethod());
+      }
+      return false;
+    }
+
     public String getTestClass() {
       return testClass;
     }
@@ -51,30 +64,38 @@
       return testMethod;
     }
 
+    public String getTestModule() {
+      return testModule;
+    }
+
+    @Override
+    public int hashCode() {
+      return toString().hashCode();
+    }
+
     @Override
     public String toString() {
-      return testClass + "." + testMethod;
+      return testModule + ":" + testClass + "." + testMethod;
     }
   }
 
   /**
    * Gets the name of next method to run.
    * 
-   * @param moduleName the module name of this client
    * @return the next test to run
    * @throws TimeoutException if the wait for the next method times out.
    */
-  TestInfo getFirstMethod(String moduleName) throws TimeoutException;
+  TestInfo getFirstMethod() throws TimeoutException;
 
   /**
    * Reports results for the last method run and gets the name of next method to
    * run.
    * 
-   * @param moduleName the module name of this client
+   * @param testInfo the testInfo the result is for
    * @param result the results of executing the test
    * @return the next test to run
    * @throws TimeoutException if the wait for the next method times out.
    */
-  TestInfo reportResultsAndGetNextMethod(String moduleName, JUnitResult result)
+  TestInfo reportResultsAndGetNextMethod(TestInfo testInfo, JUnitResult result)
       throws TimeoutException;
 }
diff --git a/user/src/com/google/gwt/junit/client/impl/JUnitHostAsync.java b/user/src/com/google/gwt/junit/client/impl/JUnitHostAsync.java
index c900a18..b26a588 100644
--- a/user/src/com/google/gwt/junit/client/impl/JUnitHostAsync.java
+++ b/user/src/com/google/gwt/junit/client/impl/JUnitHostAsync.java
@@ -26,21 +26,20 @@
   /**
    * Gets the name of next method to run.
    * 
-   * @param moduleName the module name of this client
    * @param callBack the object that will receive the name of the next method to
    *          run
    */
-  void getFirstMethod(String moduleName, AsyncCallback<TestInfo> callBack);
+  void getFirstMethod(AsyncCallback<TestInfo> callBack);
 
   /**
    * Reports results for the last method run and gets the name of next method to
    * run.
    * 
-   * @param moduleName the module name of this client
+   * @param testInfo the testInfo the result is for
    * @param result the result of the test
    * @param callBack the object that will receive the name of the next method to
    *          run
    */
-  void reportResultsAndGetNextMethod(String moduleName, JUnitResult result,
+  void reportResultsAndGetNextMethod(TestInfo testInfo, JUnitResult result,
       AsyncCallback<TestInfo> callBack);
 }
diff --git a/user/src/com/google/gwt/junit/server/JUnitHostImpl.java b/user/src/com/google/gwt/junit/server/JUnitHostImpl.java
index e69359a..81a38d5 100644
--- a/user/src/com/google/gwt/junit/server/JUnitHostImpl.java
+++ b/user/src/com/google/gwt/junit/server/JUnitHostImpl.java
@@ -80,20 +80,19 @@
     fld.set(obj, value);
   }
 
-  public TestInfo getFirstMethod(String moduleName) throws TimeoutException {
-    return getHost().getNextTestInfo(getClientId(), moduleName,
-        TIME_TO_WAIT_FOR_TESTNAME);
+  public TestInfo getFirstMethod() throws TimeoutException {
+    return getHost().getNextTestInfo(getClientId(), TIME_TO_WAIT_FOR_TESTNAME);
   }
 
-  public TestInfo reportResultsAndGetNextMethod(String moduleName,
+  public TestInfo reportResultsAndGetNextMethod(TestInfo testInfo,
       JUnitResult result) throws TimeoutException {
     initResult(getThreadLocalRequest(), result);
     ExceptionWrapper ew = result.getExceptionWrapper();
     result.setException(deserialize(ew));
     JUnitMessageQueue host = getHost();
-    host.reportResults(moduleName, result);
-    return host.getNextTestInfo(getClientId(), moduleName,
-        TIME_TO_WAIT_FOR_TESTNAME);
+    String clientId = getClientId();
+    host.reportResults(clientId, testInfo, result);
+    return host.getNextTestInfo(clientId, TIME_TO_WAIT_FOR_TESTNAME);
   }
 
   @Override
@@ -101,12 +100,11 @@
       HttpServletResponse response) throws ServletException, IOException {
     String requestURI = request.getRequestURI();
     if (requestURI.endsWith("/junithost/loadError")) {
-      String moduleName = getModuleName(requestURI);
       String requestPayload = RPCServletUtils.readContentAsUtf8(request);
       JUnitResult result = new JUnitResult();
       initResult(request, result);
       result.setException(new JUnitFatalLaunchException(requestPayload));
-      getHost().reportResults(moduleName, result);
+      getHost().reportResults(getClientId(), null, result);
     } else {
       super.service(request, response);
     }
@@ -228,14 +226,6 @@
     return machine + " / " + agent;
   }
 
-  private String getModuleName(String requestURI) {
-    int pos = requestURI.indexOf("/junithost");
-    String prefix = requestURI.substring(0, pos);
-    pos = prefix.lastIndexOf('/');
-    String moduleName = prefix.substring(pos + 1);
-    return moduleName;
-  }
-
   private void initResult(HttpServletRequest request, JUnitResult result) {
     String agent = request.getHeader("User-Agent");
     result.setAgent(agent);
diff --git a/user/super/com/google/gwt/junit/translatable/com/google/gwt/junit/client/impl/GWTRunner.java b/user/super/com/google/gwt/junit/translatable/com/google/gwt/junit/client/impl/GWTRunner.java
index c8d33f6..3707088 100644
--- a/user/super/com/google/gwt/junit/translatable/com/google/gwt/junit/client/impl/GWTRunner.java
+++ b/user/super/com/google/gwt/junit/translatable/com/google/gwt/junit/client/impl/GWTRunner.java
@@ -19,6 +19,8 @@
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.junit.client.GWTTestCase;
 import com.google.gwt.junit.client.impl.JUnitHost.TestInfo;
+import com.google.gwt.user.client.Timer;
+import com.google.gwt.user.client.Window;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwt.user.client.rpc.ServiceDefTarget;
 
@@ -41,17 +43,21 @@
      * A call to junitHost failed.
      */
     public void onFailure(Throwable caught) {
-      // We're not doing anything, which will stop the test harness.
-      // TODO: try the call again?
+      // Try the call again
+      new Timer() {
+        @Override
+        public void run() {
+          syncToServer();
+        }
+      }.schedule(1000);
     }
 
     /**
      * A call to junitHost succeeded; run the next test case.
      */
     public void onSuccess(TestInfo nextTest) {
-      if (nextTest != null) {
-        runTest(nextTest);
-      }
+      currentTest = nextTest;
+      doRunTest();
     }
   }
 
@@ -74,28 +80,9 @@
     return sInstance;
   }
 
-  private static native String getQuery() /*-{
-    return $wnd.location.search || '';  
-  }-*/;
+  private JUnitResult currentResult;
 
-  private static String getQueryParam(String query, String queryParam) {
-    int pos = query.indexOf("?" + queryParam + "=");
-    if (pos < 0) {
-      pos = query.indexOf("&" + queryParam + "=");
-    }
-    if (pos < 0) {
-      return null;
-    }
-    // advance past param name to to param value; +2 for the '&' and '='
-    pos += queryParam.length() + 2;
-    String result = query.substring(pos);
-    // trim any query params that follow
-    pos = result.indexOf('&');
-    if (pos >= 0) {
-      result = result.substring(0, pos);
-    }
-    return result;
-  }
+  private TestInfo currentTest;
 
   /**
    * The remote service to communicate with.
@@ -125,19 +112,19 @@
   }
 
   public void onModuleLoad() {
-    TestInfo queryParamTestToRun = checkForQueryParamTestToRun();
-    if (queryParamTestToRun != null) {
+    currentTest = checkForQueryParamTestToRun();
+    if (currentTest != null) {
       /*
        * Just run a single test with no server-side interaction.
        */
       serverless = true;
-      runTest(queryParamTestToRun);
+      runTest();
     } else {
       /*
        * Normal operation: Kick off the test running process by getting the
        * first method to run from the server.
        */
-      junitHost.getFirstMethod(GWT.getModuleName(), junitHostListener);
+      syncToServer();
     }
   }
 
@@ -146,8 +133,8 @@
       // That's it, we're done
       return;
     }
-    junitHost.reportResultsAndGetNextMethod(GWT.getModuleName(), result,
-        junitHostListener);
+    currentResult = result;
+    syncToServer();
   }
 
   /**
@@ -157,28 +144,54 @@
   protected abstract GWTTestCase createNewTestCase(String testClass);
 
   private TestInfo checkForQueryParamTestToRun() {
-    String query = getQuery();
-    String testClass = getQueryParam(query, TESTCLASS_QUERY_PARAM);
-    String testMethod = getQueryParam(query, TESTFUNC_QUERY_PARAM);
+    String testClass = Window.Location.getParameter(TESTCLASS_QUERY_PARAM);
+    String testMethod = Window.Location.getParameter(TESTFUNC_QUERY_PARAM);
     if (testClass == null || testMethod == null) {
       return null;
     }
-    return new TestInfo(testClass, testMethod);
+    return new TestInfo(GWT.getModuleName(), testClass, testMethod);
   }
 
-  private void runTest(TestInfo testToRun) {
+  private void doRunTest() {
+    // Make sure the module matches.
+    String currentModule = GWT.getModuleName();
+    String newModule = currentTest.getTestModule();
+    if (currentModule.equals(newModule)) {
+      // The module is correct.
+      runTest();
+    } else {
+      /*
+       * We're being asked to run a test in a different module. We must navigate
+       * the browser to a new URL which will run that other module.
+       */
+      String href = Window.Location.getHref();
+      String newHref = href.replace(currentModule, newModule);
+      Window.Location.replace(newHref);
+    }
+  }
+
+  private void runTest() {
     // Dynamically create a new test case.
-    GWTTestCase testCase = createNewTestCase(testToRun.getTestClass());
+    GWTTestCase testCase = createNewTestCase(currentTest.getTestClass());
     if (testCase == null) {
-      RuntimeException ex = new RuntimeException(testToRun
+      RuntimeException ex = new RuntimeException(currentTest
           + ": could not instantiate the requested class");
       JUnitResult result = new JUnitResult();
       result.setExceptionWrapper(new ExceptionWrapper(ex));
       reportResultsAndGetNextMethod(result);
     }
 
-    testCase.setName(testToRun.getTestMethod());
+    testCase.setName(currentTest.getTestMethod());
     testCase.__doRunTest();
   }
 
+  private void syncToServer() {
+    if (currentTest == null) {
+      junitHost.getFirstMethod(junitHostListener);
+    } else {
+      junitHost.reportResultsAndGetNextMethod(currentTest, currentResult,
+          junitHostListener);
+    }
+  }
+
 }