Add support multiple modules in a single browser window in hosted mode, moving much of the functionality into platform-dependent code.  Added a unit
test for this functionality, although currently it passes simply by not crashing -- in the future we will add instrumentation for the module loading/unloading code (when we can easily create new objects under window.external) and verify that it is being properly handled.

Review by: scottb, knorton

git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@799 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/dev/core/src/com/google/gwt/dev/shell/BrowserWidget.java b/dev/core/src/com/google/gwt/dev/shell/BrowserWidget.java
index 5bc3fbc..7c530b1 100644
--- a/dev/core/src/com/google/gwt/dev/shell/BrowserWidget.java
+++ b/dev/core/src/com/google/gwt/dev/shell/BrowserWidget.java
@@ -54,6 +54,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Map;
 import java.util.Set;
@@ -114,7 +115,13 @@
         browser.stop();
       } else if (evt.widget == openWebModeButton) {
         // first, compile
-        Set keySet = moduleSpacesByName.keySet();
+        Set keySet = new HashSet();
+        for (Iterator iter = loadedModules.entrySet().iterator();
+            iter.hasNext(); ) {
+          ModuleSpace module
+              = (ModuleSpace) ((Map.Entry) iter.next()).getValue();
+          keySet.add(module.getModuleName());
+        }
         String[] moduleNames = Util.toStringArray(keySet);
         if (moduleNames.length == 0) {
           // A latent problem with a module.
@@ -198,20 +205,21 @@
     logger.log(TreeLogger.ERROR,
         "Unable to find a default external web browser", null);
 
-    logger.log(
-        TreeLogger.WARN,
-        "Try setting the environment varable GWT_EXTERNAL_BROWSER to your web browser executable before launching the GWT shell",
-        null);
+    logger.log(TreeLogger.WARN, "Try setting the environment variable "
+        + "GWT_EXTERNAL_BROWSER to your web browser executable before "
+        + "launching the GWT shell", null);
   }
 
   protected Browser browser;
-
+  
   private Color bgColor = new Color(null, 239, 237, 216);
 
   private Button goButton;
 
   private final BrowserWidgetHost host;
 
+  private final Map loadedModules = new HashMap();
+
   private Text location;
 
   private final TreeLogger logger;
@@ -220,8 +228,6 @@
 
   private Toolbar toolbar;
 
-  private Map moduleSpacesByName = new HashMap();
-
   public BrowserWidget(Composite parent, BrowserWidgetHost host) {
     super(parent, SWT.NONE);
 
@@ -234,7 +240,7 @@
     Composite secondBar = buildLocationBar(this);
 
     browser = new Browser(this, SWT.NONE);
-
+    
     {
       statusBar = new Label(this, SWT.BORDER | SWT.SHADOW_IN);
       statusBar.setBackground(bgColor);
@@ -304,54 +310,70 @@
   /**
    * Initializes and attaches module space to this browser widget. Called by
    * subclasses in response to calls from JavaScript.
+   * 
+   * @param space ModuleSpace instance to initialize
    */
-  protected final void attachModuleSpace(String moduleName, ModuleSpace space)
+  protected final void attachModuleSpace(ModuleSpace space)
       throws UnableToCompleteException {
+    Object key = space.getKey();
+    loadedModules.put(key, space);
 
+    logger.log(TreeLogger.SPAM, "Loading module " + space.getModuleName()
+        + " (id " + key.toString() + ")", null);
+    
     // Let the space do its thing.
     //
     space.onLoad(logger);
 
-    // Remember this new module space so that we can dispose of it later.
-    //
-    moduleSpacesByName.put(moduleName, space);
-
     // Enable the compile button since we successfully loaded.
     //
     toolbar.openWebModeButton.setEnabled(true);
   }
 
   /**
-   * Disposes all the attached module spaces from the prior page (not the one
-   * that just loaded). Called when this widget is disposed but, more
-   * interestingly, whenever the browser's page changes.
+   * Unload one or more modules.  If key is null, emulate old behavior
+   * by unloading all loaded modules.
+   * 
+   * @param key unique key to identify module to unload or null for all 
    */
-  protected void onPageUnload() {
-    for (Iterator iter = moduleSpacesByName.entrySet().iterator(); iter.hasNext();) {
-      Map.Entry entry = (Map.Entry) iter.next();
-      String moduleName = (String) entry.getKey();
-      ModuleSpace space = (ModuleSpace) entry.getValue();
-      unloadModule(space, moduleName);
+  protected void doUnload(Object key) {
+    if (key == null) {
+      // BEGIN BACKWARD COMPATIBILITY
+      // remove all modules
+      for (Iterator iter = loadedModules.entrySet().iterator();
+          iter.hasNext(); ) {
+        unloadModule((ModuleSpace) ((Map.Entry) iter.next()).getValue());
+      }
+      loadedModules.clear();
+      // END BACKWARD COMPATIBILITY
+    } else {
+      ModuleSpace moduleSpace = (ModuleSpace) loadedModules.get(key);
+      if (moduleSpace == null) {
+        throw new HostedModeException("Can't find frame window for " + key);
+      }
+      unloadModule(moduleSpace);
+      loadedModules.remove(key);
     }
-    moduleSpacesByName.clear();
-
-    if (!toolbar.openWebModeButton.isDisposed()) {
-      // Disable the compile buton.
-      //
-      toolbar.openWebModeButton.setEnabled(false);
+    if (loadedModules.isEmpty()) {
+      if (!toolbar.openWebModeButton.isDisposed()) {
+        // Disable the compile buton.
+        //
+        toolbar.openWebModeButton.setEnabled(false);
+      }     
     }
   }
-  
+
   /**
    * Unload the specified module.
    * 
    * @param moduleSpace a ModuleSpace instance to unload.
-   * @param moduleName the name of the specified module
    */
-  protected void unloadModule(ModuleSpace moduleSpace, String moduleName) {
+  protected void unloadModule(ModuleSpace moduleSpace) {
+    String moduleName = moduleSpace.getModuleName();
+    Object key = moduleSpace.getKey();
     moduleSpace.dispose();
-    logger.log(TreeLogger.SPAM, "Cleaning up resources for module "
-        + moduleName, null);
+    logger.log(TreeLogger.SPAM, "Unloading module " + moduleName
+        + " (id " + key.toString() + ")", null);
   }
 
   private Composite buildLocationBar(Composite parent) {
diff --git a/dev/core/src/com/google/gwt/dev/shell/ModuleSpace.java b/dev/core/src/com/google/gwt/dev/shell/ModuleSpace.java
index 5e2724f..c8f34f1 100644
--- a/dev/core/src/com/google/gwt/dev/shell/ModuleSpace.java
+++ b/dev/core/src/com/google/gwt/dev/shell/ModuleSpace.java
@@ -85,8 +85,15 @@
 
   private final ModuleSpaceHost host;
 
-  protected ModuleSpace(final ModuleSpaceHost host) {
+  private final String moduleName;
+
+  private final Object key;
+
+  protected ModuleSpace(ModuleSpaceHost host, String moduleName,
+      Object key) {
     this.host = host;
+    this.moduleName = moduleName;
+    this.key = key;
     TreeLogger hostLogger = host.getLogger();
     threadLocalLogger.set(hostLogger);
   }
@@ -103,6 +110,24 @@
     host.getClassLoader().clear();
   }
 
+  /**
+   * Get the unique key for this module.
+   * 
+   * @return the unique key
+   */
+  public Object getKey() {
+    return key;
+  }
+
+  /**
+   * Get the module name.
+   * 
+   * @return the module name
+   */
+  public String getModuleName() {
+    return moduleName;
+  }
+
   public boolean invokeNativeBoolean(String name, Object jthis, Class[] types,
       Object[] args) {
     JsValue result = invokeNative(name, jthis, types, args);
diff --git a/dev/linux/src/com/google/gwt/dev/shell/moz/BrowserWidgetMoz.java b/dev/linux/src/com/google/gwt/dev/shell/moz/BrowserWidgetMoz.java
index 1ce9b3e..81ebe73 100644
--- a/dev/linux/src/com/google/gwt/dev/shell/moz/BrowserWidgetMoz.java
+++ b/dev/linux/src/com/google/gwt/dev/shell/moz/BrowserWidgetMoz.java
@@ -29,9 +29,6 @@
 import org.eclipse.swt.internal.mozilla.nsIWebBrowser;
 import org.eclipse.swt.widgets.Shell;
 
-import java.util.HashMap;
-import java.util.Map;
-
 /**
  * Represents an individual browser window and all of its controls.
  */
@@ -39,9 +36,6 @@
 
   private class ExternalObjectImpl implements ExternalObject {
 
-    private Map modulesByScriptObject = new HashMap();
-    private Map namesByScriptObject = new HashMap();
-
     public boolean gwtOnLoad(int scriptObject, String moduleName) {
       try {
         if (moduleName == null) {
@@ -51,14 +45,14 @@
           return true;
         }
 
+        Object key = new Integer(scriptObject);
         // Attach a new ModuleSpace to make it programmable.
         //
         ModuleSpaceHost msh = getHost().createModuleSpaceHost(
             BrowserWidgetMoz.this, moduleName);
-        ModuleSpace moduleSpace = new ModuleSpaceMoz(msh, scriptObject);
-        attachModuleSpace(moduleName, moduleSpace);
-        modulesByScriptObject.put(new Integer(scriptObject), moduleSpace);
-        namesByScriptObject.put(new Integer(scriptObject), moduleName);
+        ModuleSpace moduleSpace = new ModuleSpaceMoz(msh, scriptObject,
+            moduleName, key);
+        attachModuleSpace(moduleSpace);
         return true;
       } catch (Throwable e) {
         // We do catch Throwable intentionally because there are a ton of things
@@ -74,29 +68,14 @@
     /**
      * Unload one or more modules.
      * 
-     * TODO(jat): Note that currently the JS code does not unload individual
-     * modules, so this change is in preparation for when the JS code is
-     * fixed. 
-     * 
      * @param scriptObject window to unload, 0 if all
      */
     protected void handleUnload(int scriptObject) {
-      // TODO(jat): true below restores original behavior of always
-      // unloading all modules until the resulting GC issues (the
-      // order of destruction is undefined so JS_RemoveRoot gets
-      // called on a non-existent JSContext)
-      if (true || scriptObject == 0) {
-        onPageUnload();
-        modulesByScriptObject.clear();
-        namesByScriptObject.clear();
-        return;
+      Integer key = null;
+      if (scriptObject != 0) {
+        key = new Integer(scriptObject);
       }
-      Integer key = new Integer(scriptObject);
-      ModuleSpace moduleSpace = (ModuleSpace)modulesByScriptObject.get(key);
-      String moduleName = (String)namesByScriptObject.get(key);
-      unloadModule(moduleSpace, moduleName);
-      modulesByScriptObject.remove(key);
-      namesByScriptObject.remove(key);
+      doUnload(key);
     }
   }
 
diff --git a/dev/linux/src/com/google/gwt/dev/shell/moz/ModuleSpaceMoz.java b/dev/linux/src/com/google/gwt/dev/shell/moz/ModuleSpaceMoz.java
index 7cb960e..e326fb5 100644
--- a/dev/linux/src/com/google/gwt/dev/shell/moz/ModuleSpaceMoz.java
+++ b/dev/linux/src/com/google/gwt/dev/shell/moz/ModuleSpaceMoz.java
@@ -35,8 +35,9 @@
   /**
    * Constructs a browser interface for use with a Mozilla global window object.
    */
-  public ModuleSpaceMoz(ModuleSpaceHost host, int scriptGlobalObject) {
-    super(host);
+  public ModuleSpaceMoz(ModuleSpaceHost host, int scriptGlobalObject,
+      String moduleName, Object key) {
+    super(host, moduleName, key);
 
     // Hang on to the parent window.
     //
diff --git a/dev/mac/src/com/google/gwt/dev/shell/mac/BrowserWidgetSaf.java b/dev/mac/src/com/google/gwt/dev/shell/mac/BrowserWidgetSaf.java
index be49357..6d148d1 100644
--- a/dev/mac/src/com/google/gwt/dev/shell/mac/BrowserWidgetSaf.java
+++ b/dev/mac/src/com/google/gwt/dev/shell/mac/BrowserWidgetSaf.java
@@ -47,18 +47,19 @@
     public boolean gwtOnLoad(int scriptObject, String moduleName) {
       try {
         if (moduleName == null) {
-          // Indicates the page is being unloaded.
-          // TODO(jat): add support to unload a single module
-          onPageUnload();
+          // Indicates one or more modules are being unloaded.
+          handleUnload(scriptObject);
           return true;
         }
 
         // Attach a new ModuleSpace to make it programmable.
         //
+        Integer key = new Integer(scriptObject);
         ModuleSpaceHost msh = getHost().createModuleSpaceHost(
             BrowserWidgetSaf.this, moduleName);
-        ModuleSpace moduleSpace = new ModuleSpaceSaf(msh, scriptObject);
-        attachModuleSpace(moduleName, moduleSpace);
+        ModuleSpace moduleSpace = new ModuleSpaceSaf(msh, scriptObject,
+            moduleName, key);
+        attachModuleSpace(moduleSpace);
         return true;
       } catch (Throwable e) {
         // We do catch Throwable intentionally because there are a ton of things
@@ -73,6 +74,19 @@
 
     public void setField(String name, int value) {
     }
+
+    /**
+     * Unload one or more modules.
+     * 
+     * @param scriptObject window to unload, 0 if all
+     */
+    protected void handleUnload(int scriptObject) {
+      Integer key = null;
+      if (scriptObject != 0) {
+        key = new Integer(scriptObject);
+      }
+      doUnload(key);
+    }
   }
   private static final class GwtOnLoad implements DispatchMethod {
 
diff --git a/dev/mac/src/com/google/gwt/dev/shell/mac/ModuleSpaceSaf.java b/dev/mac/src/com/google/gwt/dev/shell/mac/ModuleSpaceSaf.java
index 7ef03aa..51ff4d6 100644
--- a/dev/mac/src/com/google/gwt/dev/shell/mac/ModuleSpaceSaf.java
+++ b/dev/mac/src/com/google/gwt/dev/shell/mac/ModuleSpaceSaf.java
@@ -32,9 +32,13 @@
 
   /**
    * Constructs a browser interface for use with a global window object.
+   * 
+   * @param moduleName name of the module 
+   * @param key unique key for this instance of the module
    */
-  public ModuleSpaceSaf(ModuleSpaceHost host, int scriptGlobalObject) {
-    super(host);
+  public ModuleSpaceSaf(ModuleSpaceHost host, int scriptGlobalObject,
+      String moduleName, Object key) {
+    super(host, moduleName, key);
 
     // Hang on to the global execution state.
     //
diff --git a/dev/windows/src/com/google/gwt/dev/shell/ie/BrowserWidgetIE6.java b/dev/windows/src/com/google/gwt/dev/shell/ie/BrowserWidgetIE6.java
index 70213ad..aafacab 100644
--- a/dev/windows/src/com/google/gwt/dev/shell/ie/BrowserWidgetIE6.java
+++ b/dev/windows/src/com/google/gwt/dev/shell/ie/BrowserWidgetIE6.java
@@ -23,6 +23,7 @@
 import org.eclipse.swt.SWTException;
 import org.eclipse.swt.internal.ole.win32.COM;
 import org.eclipse.swt.internal.ole.win32.IDispatch;
+import org.eclipse.swt.ole.win32.OleAutomation;
 import org.eclipse.swt.ole.win32.Variant;
 import org.eclipse.swt.widgets.Shell;
 
@@ -44,22 +45,29 @@
      * 
      * @param frameWnd a reference to the IFRAME in which the module's injected
      *          JavaScript will live
+     * @param moduleName the name of the module to load, null if this is being
+     *          unloaded
      */
     public boolean gwtOnLoad(IDispatch frameWnd, String moduleName) {
       try {
         if (moduleName == null) {
-          // Indicates the page is being unloaded.
-          // TODO(jat): add support for unloading only a single module
-          onPageUnload();
+          // Indicates one or more modules are being unloaded.
+          handleUnload(frameWnd);
           return true;
         }
+        
+        // set the module ID
+        int moduleID = ++nextModuleID;
+        Integer key = new Integer(moduleID);
+        setIntProperty(frameWnd, "__gwt_module_id", moduleID);
 
         // Attach a new ModuleSpace to make it programmable.
         //
         ModuleSpaceHost msh = getHost().createModuleSpaceHost(
             BrowserWidgetIE6.this, moduleName);
-        ModuleSpaceIE6 moduleSpace = new ModuleSpaceIE6(msh, frameWnd);
-        attachModuleSpace(moduleName, moduleSpace);
+        ModuleSpaceIE6 moduleSpace = new ModuleSpaceIE6(msh, frameWnd,
+            moduleName, key);
+        attachModuleSpace(moduleSpace);
         return true;
       } catch (Throwable e) {
         // We do catch Throwable intentionally because there are a ton of things
@@ -88,6 +96,19 @@
       throw new HResultException(DISP_E_UNKNOWNNAME);
     }
 
+    /**
+     * Unload one or more modules.
+     * 
+     * @param frameWnd window to unload, null if all
+     */
+    protected void handleUnload(IDispatch frameWnd) {
+      Integer key = null;
+      if (frameWnd != null) {
+        key = new Integer(getIntProperty(frameWnd, "__gwt_module_id"));
+      }
+      doUnload(key);
+    }
+
     protected Variant invoke(int dispId, int flags, Variant[] params)
         throws HResultException, InvocationTargetException {
 
@@ -130,6 +151,74 @@
     }
   }
 
+  // counter to generate unique module IDs
+  private static int nextModuleID = 0;
+
+  /**
+   * Get a property off a window object as an integer.
+   * 
+   * @param frameWnd inner code frame
+   * @param propName name of the property to get
+   * @return the property value as an integer
+   * @throws RuntimeException if the property does not exist
+   */
+  private static int getIntProperty(IDispatch frameWnd, String propName) {
+    OleAutomation window = null;
+    try {
+      window = new OleAutomation(frameWnd);
+      int[] dispID = window.getIDsOfNames(new String[] { propName });
+      if (dispID == null) {
+        throw new RuntimeException("No such property " + propName);
+      }
+      Variant value = null;
+      try {
+        value = window.getProperty(dispID[0]);
+        return value.getInt();
+      } finally {
+        if (value != null) {
+          value.dispose();
+        }
+      }
+    } finally {
+      if (window != null) {
+        window.dispose();
+      }
+    }
+  }
+
+  /**
+   * Set a property off a window object from an integer value.
+   * 
+   * @param frameWnd inner code frame
+   * @param propName name of the property to set
+   * @param intValue the value to set
+   * @throws RuntimeException if the property does not exist
+   */
+  private static void setIntProperty(IDispatch frameWnd, String propName,
+      int intValue) {
+    OleAutomation window = null;
+    try {
+      window = new OleAutomation(frameWnd);
+      int[] dispID = window.getIDsOfNames(new String[] { propName });
+      if (dispID == null) {
+        throw new RuntimeException("No such property " + propName);
+      }
+      Variant value = null;
+      try {
+        value = new Variant(intValue);
+        window.setProperty(dispID[0], value);
+      } finally {
+        if (value != null) {
+          value.dispose();
+        }
+      }
+    } finally {
+      if (window != null) {
+        window.dispose();
+      }
+    }
+  }
+
   public BrowserWidgetIE6(Shell shell, BrowserWidgetHost host) {
     super(shell, host);
 
@@ -142,5 +231,4 @@
     //
     LowLevelIE6.init();
   }
-
 }
diff --git a/dev/windows/src/com/google/gwt/dev/shell/ie/ModuleSpaceIE6.java b/dev/windows/src/com/google/gwt/dev/shell/ie/ModuleSpaceIE6.java
index f841b25..d16b710 100644
--- a/dev/windows/src/com/google/gwt/dev/shell/ie/ModuleSpaceIE6.java
+++ b/dev/windows/src/com/google/gwt/dev/shell/ie/ModuleSpaceIE6.java
@@ -30,6 +30,49 @@
  * Internet Explorer 6.
  */
 public class ModuleSpaceIE6 extends ModuleSpace {
+  /**
+   * Invoke a JavaScript function.  The static function exists to allow
+   * platform-dependent code to make JavaScript calls without having a
+   * ModuleSpaceIE6 (and all that entails) if it is not required.
+   * 
+   * @param window the window containing the function
+   * @param name the name of the function
+   * @param vArgs the array of arguments.  vArgs[0] is the this parameter
+   *     supplied to the function, which must be null if it is static.
+   * @return the return value of the JavaScript function
+   */
+  protected static Variant doInvokeOnWindow(OleAutomation window, String name,
+      Variant[] vArgs) {
+    OleAutomation funcObj = null;
+    Variant funcObjVar = null;
+    try {
+
+      // Get the function object and its 'call' method.
+      //
+      int[] ids = window.getIDsOfNames(new String[] {name});
+      if (ids == null) {
+        throw new RuntimeException(
+            "Could not find a native method with the signature '" + name + "'");
+      }
+      int functionId = ids[0];
+      funcObjVar = window.getProperty(functionId);
+      funcObj = funcObjVar.getAutomation();
+      int callDispId = funcObj.getIDsOfNames(new String[] {"call"})[0];
+
+      // Invoke it and return the result.
+      //
+      return funcObj.invoke(callDispId, vArgs);
+
+    } finally {
+      if (funcObjVar != null) {
+        funcObjVar.dispose();
+      }
+
+      if (funcObj != null) {
+        funcObj.dispose();
+      }
+    }
+  }
 
   // CHECKSTYLE_OFF
   private static int CODE(int hresult) {
@@ -47,9 +90,11 @@
   /**
    * Constructs a browser interface for use with an IE6 'window' automation
    * object.
+   * @param moduleName 
    */
-  public ModuleSpaceIE6(ModuleSpaceHost host, IDispatch scriptFrameWindow) {
-    super(host);
+  public ModuleSpaceIE6(ModuleSpaceHost host, IDispatch scriptFrameWindow,
+      String moduleName, Object key) {
+    super(host, moduleName, key);
 
     window = new OleAutomation(scriptFrameWindow);
   }
@@ -134,8 +179,6 @@
    */
   protected JsValue doInvoke(String name, Object jthis, Class[] types,
       Object[] args) {
-    OleAutomation funcObj = null;
-    Variant funcObjVar = null;
     Variant[] vArgs = null;
     try {
       // Build the argument list, including 'jthis'.
@@ -151,21 +194,7 @@
             getIsolatedClassLoader(), types[i], args[i]);
       }
 
-      // Get the function object and its 'call' method.
-      //
-      int[] ids = window.getIDsOfNames(new String[] {name});
-      if (ids == null) {
-        throw new RuntimeException(
-            "Could not find a native method with the signature '" + name + "'");
-      }
-      int functionId = ids[0];
-      funcObjVar = window.getProperty(functionId);
-      funcObj = funcObjVar.getAutomation();
-      int callDispId = funcObj.getIDsOfNames(new String[] {"call"})[0];
-
-      // Invoke it and return the result.
-      //
-      Variant result = funcObj.invoke(callDispId, vArgs);
+      Variant result = doInvokeOnWindow(window, name, vArgs);
       try {
         if (!isExceptionActive()) {
           return new JsValueIE6(result);
@@ -190,14 +219,6 @@
           vArgs[i].dispose();
         }
       }
-
-      if (funcObjVar != null) {
-        funcObjVar.dispose();
-      }
-
-      if (funcObj != null) {
-        funcObj.dispose();
-      }
     }
   }
 
diff --git a/user/src/com/google/gwt/core/public/hosted.html b/user/src/com/google/gwt/core/public/hosted.html
index 538cff2..9aeacec 100644
--- a/user/src/com/google/gwt/core/public/hosted.html
+++ b/user/src/com/google/gwt/core/public/hosted.html
@@ -20,6 +20,8 @@
   external.gwtOnLoad(window, null);
 };
 
+window.__gwt_module_id = 0;
+
 var query = window.location.search.substr(1);
 if (query && $wnd[query]) $wnd[query].onScriptLoad();
 --></script></body></html>
diff --git a/user/test/com/google/gwt/dev/shell/MultiModuleTest.gwt.xml b/user/test/com/google/gwt/dev/shell/MultiModuleTest.gwt.xml
new file mode 100644
index 0000000..7d49f4d
--- /dev/null
+++ b/user/test/com/google/gwt/dev/shell/MultiModuleTest.gwt.xml
@@ -0,0 +1,4 @@
+<module>
+	<inherits name='com.google.gwt.user.User'/>
+	<source path='test'/>
+</module>
diff --git a/user/test/com/google/gwt/dev/shell/test/MultiModuleTest.java b/user/test/com/google/gwt/dev/shell/test/MultiModuleTest.java
new file mode 100644
index 0000000..35af2a8
--- /dev/null
+++ b/user/test/com/google/gwt/dev/shell/test/MultiModuleTest.java
@@ -0,0 +1,381 @@
+/*
+ * Copyright 2007 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.dev.shell.test;
+
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.junit.client.GWTTestCase;
+import com.google.gwt.user.client.Window;
+import com.google.gwt.user.client.ui.Frame;
+import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.RootPanel;
+import com.google.gwt.user.client.ui.VerticalPanel;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Tests unloading individual modules when more than one are loaded on a page,
+ * including in nested frames.
+ * 
+ * The test will load up the initial configuration, then when all frames are
+ * done loading will toggle the first frame, then the second frame, then both at
+ * the same time. Buttons are provided for manual testing.
+ * 
+ * Currently, there isn't much it can do to actually verify the proper behavior
+ * other than not crashing (which does not verify that the removed module isn't
+ * holding a lot of memory), but it is hard to do more than that without adding
+ * a lot of hooks that would only be used for this test. When tobyr's profiling
+ * changes are merged in, we will have to create some of the hooks to allow
+ * calls into the external object which could be used for the hooks for this
+ * test.
+ */
+public class MultiModuleTest extends GWTTestCase {
+
+  /**
+   * Used to setup the variable to keep track of things to be loaded, plus the
+   * JavaScript function used to communicate with the main module (the one that
+   * sets up the frames) from other modules.
+   * 
+   * @param javaThis frameTest instance to use for callback
+   */
+  private static native void setupDoneLoading(MultiModuleTest javaThis) /*-{
+    $wnd.__count_to_be_loaded = 0;
+    $wnd.__done_loading = function() {
+      javaThis.@com.google.gwt.dev.shell.test.MultiModuleTest::doneLoading()();
+    };
+  }-*/;
+
+  /**
+   * Used to setup the JavaScript function used to communicate with the main
+   * module (the one that sets up the frames) from other modules.
+   * 
+   * @param javaThis frameTest instance to use for callback
+   */
+  private static native void setupTestComplete(MultiModuleTest javaThis) /*-{
+    $wnd.__test_complete = function() {
+      javaThis.@com.google.gwt.dev.shell.test.MultiModuleTest::completedTest()();
+    };
+  }-*/;
+
+  /**
+   * Child frames, which are unused in nested modules.
+   */
+  private Frame[] frame = new Frame[2];
+
+  /**
+   * Flags indicating the "A" version of frame i is displayed, used to toggle
+   * the individual frames.
+   */
+  private boolean[] frameA = new boolean[2];
+
+  /**
+   * The top-level panel for callbacks.
+   */
+  private VerticalPanel mainPanel = null;
+
+  /**
+   * The state for automated frame toggles.
+   */
+  private int state;
+
+  /**
+   * Get the name of the GWT module to use for this test.
+   * 
+   * @return the fully-qualified module name
+   */
+  public String getModuleName() {
+    return "com.google.gwt.dev.shell.MultiModuleTest";
+  }
+
+  /**
+   * Create the DOM elements for the module, based on the query string. The top
+   * level (query parameter frame=top) drives the process and sets up the
+   * automated state transition hooks.
+   * 
+   * This function returns with no effect if gwt.junit.testfuncname is not
+   * passed as a query parameter, which means it is being run as a real test
+   * rather than as a "submodule" of testMultipleModules.
+   */
+  public void testInnerModules() {
+    String url = getURL();
+    Map params = getURLParams(url);
+    if (!params.containsKey("gwt.junit.testfuncname")) {
+      // if this test is being run as a normal JUnit test, return success
+      return;
+    }
+
+    // we were invoked by testMultipleModules, get the frame to load
+    String frameName = (String) params.get("frame");
+    
+    VerticalPanel panel = new VerticalPanel();
+    RootPanel.get().add(panel);
+    if (frameName.equals("top")) {
+      // initial load
+      setupDoneLoading(this);
+      mainPanel = panel;
+      panel.add(new Label("Top level frame"));
+      state = 0;
+      params.put("frame", "1a");
+      frame[0] = new Frame(buildURL(url, params));
+      panel.add(frame[0]);
+      params.put("frame", "2a");
+      frame[1] = new Frame(buildURL(url, params));
+      panel.add(frame[1]);
+      addToBeLoaded(0, 2);
+    } else if (frameName.equals("1a")) {
+      panel.add(new Label("Frame 1a"));
+      markLoaded(1);
+    } else if (frameName.equals("1b")) {
+      panel.add(new Label("Frame 1b"));
+      markLoaded(1);
+    } else if (frameName.equals("2a")) {
+      panel.add(new Label("Frame 2a"));
+      params.put("frame", "2suba");
+      Frame sub = new Frame(buildURL(url, params));
+      panel.add(sub);
+    } else if (frameName.equals("2b")) {
+      panel.add(new Label("Frame 2b"));
+      params.put("frame", "2subb");
+      Frame sub = new Frame(buildURL(url, params));
+      panel.add(sub);
+    } else if (frameName.equals("2suba")) {
+      panel.add(new Label("Frame 2a inner"));
+      markLoaded(2);
+    } else if (frameName.equals("2subb")) {
+      panel.add(new Label("Frame 2b inner"));
+      markLoaded(2);
+    } else {
+      GWT.log("Unexpected frame name " + frameName, null);
+    }
+  }
+
+  public void testMultipleModules() {
+    setupTestComplete(this);
+    
+    // build new URL from current one
+    String url = getURL();
+    Map params = getURLParams(url);
+    params.put("frame", "top");
+    params.put("gwt.junit.testfuncname", "testInnerModules");
+    
+    // open a new frame containing the module that drives the actual test
+    Frame frame = new Frame(buildURL(url, params));
+    frame.setHeight("100%");
+    frame.setWidth("100%");
+    RootPanel.get().add(frame);
+    // wait up to 30 seconds for inner frames module to do its job
+    delayTestFinish(30000);
+  }
+
+  /**
+   * Increments the number of pages to be loaded. This count is kept in the
+   * context of the top-level module, so the depth parameter is provided to find
+   * it.
+   * 
+   * @param depth nesting depth of this module, 0 = top level
+   * @param count number of pages due to be loaded
+   */
+  private native void addToBeLoaded(int depth, int count) /*-{
+    var frame = $wnd;
+    while (depth-- > 0) {
+      frame = frame.parent;
+    }
+    frame.__count_to_be_loaded += count;
+  }-*/;
+
+  /**
+   * Create a URL given an old URL and a map of query parameters. The search
+   * portion of the original URL will be discarded and replaced with a string of
+   * the form ?param1&param2=value2 etc., where param1 has a null value in the
+   * map.
+   * 
+   * @param url the original URL to rewrite
+   * @param params a map of parameter names to values
+   * @return the revised URL
+   */
+  private String buildURL(String url, Map params) {
+
+    // strip off the query string if present
+    int pos = url.indexOf("?");
+    if (pos >= 0) {
+      url = url.substring(0, pos);
+    }
+
+    // flag if we are generating the first parameter in the URL
+    boolean firstParam = true;
+
+    // gwt.hybrid must be first if present
+    if (params.containsKey("gwt.hybrid")) {
+      url += "?gwt.hybrid";
+      firstParam = false;
+    }
+
+    // now add the rest of the parameters, excluding gwt.hybrid
+    for (Iterator it = params.entrySet().iterator(); it.hasNext();) {
+      Map.Entry entry = (Map.Entry) it.next();
+      String param = (String) entry.getKey();
+
+      if (param.equals("gwt.hybrid")) {
+        // we already included gwt.hybrid if it was present
+        continue;
+      }
+
+      // add the parameter name to the URL
+      if (firstParam) {
+        url += "?";
+        firstParam = false;
+      } else {
+        url += "&";
+      }
+      url += param;
+
+      // add the value if necessary
+      String value = (String) entry.getValue();
+      if (value != null) {
+        url += "=" + value;
+      }
+    }
+    return url;
+  }
+
+  /**
+   * Called via JSNI by testInnerModules when it successfully goes through
+   * all its iterations.
+   */
+  private void completedTest() {
+    // tell JUnit that we completed successfully
+    finishTest();
+  }
+
+  /**
+   * Proceed to the next automatic state change if any. This is called in the
+   * context of the top-level module via JSNI calls when all modules being
+   * waited on are loaded.
+   */
+  private void doneLoading() {
+    String url = getURL();
+    Map params = getURLParams(url);
+    mainPanel.add(new Label("done loading"));
+    if (++state == 4) {
+      // all tests complete, notify parent
+      notifyParent();
+    }
+    if (state >= 4) {
+      return;
+    }
+    StringBuffer buf = new StringBuffer();
+    buf.append("Toggling frame(s)");
+    if ((state & 1) != 0) {
+      buf.append(" 0");
+      toggleFrame(0, url, params);
+    }
+    if ((state & 2) != 0) {
+      buf.append(" 1");
+      toggleFrame(1, url, params);
+    }
+    mainPanel.add(new Label(buf.toString()));
+  }
+
+  /**
+   * Get the query string from the URL, including the question mark if present.
+   * 
+   * @return the query string
+   */
+  private native String getURL() /*-{
+    return $wnd.location.href || '';  
+  }-*/;
+
+  /**
+   * Parse a URL and return a map of query parameters. If a parameter is
+   * supplied without =value, it will be defined as null.
+   * 
+   * @param url the full or partial (ie, only location.search) URL to parse
+   * @return the map of parameter names to values
+   */
+  private Map getURLParams(String url) {
+    HashMap map = new HashMap();
+    int pos = url.indexOf("?");
+    
+    // loop precondition: pos is the index of the next ? or & character in url
+    while (pos >= 0) {
+      // skip over the separator character
+      url = url.substring(pos + 1);
+
+      // find the end of this parameter, which is the next ? or &
+      pos = url.indexOf("?");
+      int posAlt = url.indexOf("&");
+      if (pos < 0 || (posAlt >= 0 && posAlt < pos)) {
+        pos = posAlt;
+      }
+      String param;
+      if (pos >= 0) {
+        // trim this parameter if there is a terminator
+        param = url.substring(0, pos);
+      } else {
+        param = url;
+      }
+
+      // split value from parameter name if present
+      int equals = param.indexOf("=");
+      String value = null;
+      if (equals >= 0) {
+        value = param.substring(equals + 1);
+        param = param.substring(0, equals);
+      }
+
+      map.put(param, value);
+    }
+    return map;
+  }
+
+  /**
+   * Mark this page as loaded, using JSNI to mark it in the context of the
+   * top-level module space. If all outstanding modules have loaded, call the
+   * doneLoading method in the top-level module space (using JSNI and the depth
+   * to find it).
+   * 
+   * @param depth nesting depth of this module, 0 = top level
+   */
+  private native void markLoaded(int depth) /*-{
+      var frame = $wnd;
+      while (depth-- > 0) {
+        frame = frame.parent;
+      }
+      if (!--frame.__count_to_be_loaded) {
+        frame.__done_loading();
+      }
+    }-*/;
+
+  /**
+   * Notify our parent frame that the test is complete.
+   */
+  private native void notifyParent() /*-{
+      $wnd.parent.__test_complete();
+    }-*/;
+
+  /**
+   * Replace the specified frame with its alternate version.
+   * 
+   * @param frameNumber the number of the frame to replace, starting with 0
+   */
+  private void toggleFrame(int frameNumber, String url, Map params) {
+    params.put("frame", (frameNumber + 1) + (frameA[frameNumber] ? "b" : "a"));
+    frame[frameNumber].setUrl(buildURL(url, params));
+    frameA[frameNumber] = !frameA[frameNumber];
+    addToBeLoaded(0, 1);
+  }
+}