Add option to use JSONP in ExternalTextResource

Review at http://gwt-code-reviews.appspot.com/1310801

Review by: robertvawter@google.com

git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@9622 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/tools/api-checker/config/gwt21_22userApi.conf b/tools/api-checker/config/gwt21_22userApi.conf
index 63bdabe..ef56579 100644
--- a/tools/api-checker/config/gwt21_22userApi.conf
+++ b/tools/api-checker/config/gwt21_22userApi.conf
@@ -36,6 +36,7 @@
 :com/google/gwt/junit/client/GWTTestCase.java\
 :com/google/gwt/junit/client/impl/GWTRunner.java\
 :com/google/gwt/junit/remote/**\
+:com/google/gwt/resources/client/impl/**\
 :com/google/gwt/resources/css/**\
 :com/google/gwt/resources/ext/**\
 :com/google/gwt/resources/rg/**\
diff --git a/user/src/com/google/gwt/jsonp/client/JsonpRequest.java b/user/src/com/google/gwt/jsonp/client/JsonpRequest.java
index 54ca127..c2859af 100644
--- a/user/src/com/google/gwt/jsonp/client/JsonpRequest.java
+++ b/user/src/com/google/gwt/jsonp/client/JsonpRequest.java
@@ -44,6 +44,17 @@
   private static final JavaScriptObject CALLBACKS = getOrCreateCallbacksObject();
 
   /**
+   * Prefix appended to all id's that are determined by the callbacks counter
+   */
+  private static final String INCREMENTAL_ID_PREFIX = "P";
+
+  /**
+   * Prefix appended to all id's that are passed in by the user. The "P" 
+   * suffix must stay in sync with ExternalTextResourceGenerator.java
+   */
+  private static final String PREDETERMINED_ID_PREFIX = "P";
+
+  /**
    * Returns the next ID to use, incrementing the global counter.
    */
   private static native int getAndIncrementCallbackCounter() /*-{
@@ -71,8 +82,12 @@
     return $wnd[name];
   }-*/;
 
+  private static String getPredeterminedId(String suffix) {
+    return PREDETERMINED_ID_PREFIX + suffix;
+  }
+
   private static String nextCallbackId() {
-    return "I" + getAndIncrementCallbackCounter();
+    return INCREMENTAL_ID_PREFIX + getAndIncrementCallbackCounter();
   }
 
   private final String callbackId;
@@ -91,6 +106,8 @@
 
   private final String failureCallbackParam;
 
+  private final boolean canHaveMultipleRequestsForSameId;
+
   /**
    * Timer which keeps track of timeouts.
    */
@@ -117,10 +134,41 @@
     this.expectInteger = expectInteger;
     this.callbackParam = callbackParam;
     this.failureCallbackParam = failureCallbackParam;
+    this.canHaveMultipleRequestsForSameId = false;
   }
 
   /**
-   * Cancels a pending request.
+   * Create a new JSONP request with a hardcoded id. This could be used to
+   * manually control which resources are considered duplicates (by giving them
+   * identical ids). Could also be used if the callback name needs to be
+   * completely user controlled (since the id is part of the callback name).
+   *
+   * @param callback The callback instance to notify when the response comes
+   *          back
+   * @param timeout Time in ms after which a {@link TimeoutException} will be
+   *          thrown
+   * @param expectInteger Should be true if T is {@link Integer}, false
+   *          otherwise
+   * @param callbackParam Name of the url param of the callback function name
+   * @param failureCallbackParam Name of the url param containing the the
+   *          failure callback function name, or null for no failure callback
+   * @param id unique id for the resource that is being fetched
+   */
+  JsonpRequest(AsyncCallback<T> callback, int timeout, boolean expectInteger,
+      String callbackParam, String failureCallbackParam, String id) {
+    callbackId = getPredeterminedId(id);
+    this.callback = callback;
+    this.timeout = timeout;
+    this.expectInteger = expectInteger;
+    this.callbackParam = callbackParam;
+    this.failureCallbackParam = failureCallbackParam;
+    this.canHaveMultipleRequestsForSameId = true;
+  }
+
+  /**
+   * Cancels a pending request.  Note that if you are using preset ID's, this
+   * will not work, since there is no way of knowing if there are other
+   * requests pending (or have already returned) for the same data.
    */
   public void cancel() {
     timer.cancel();
@@ -151,7 +199,7 @@
    * @param baseUri To be sent to the server.
    */
   void send(final String baseUri) {
-    registerCallbacks(CALLBACKS);
+    registerCallbacks(CALLBACKS, canHaveMultipleRequestsForSameId);
     StringBuffer uri = new StringBuffer(baseUri);
     uri.append(baseUri.contains("?") ? "&" : "?");
     String prefix = CALLBACKS_NAME + "." + callbackId;
@@ -176,7 +224,7 @@
     timer.schedule(timeout);
     getHeadElement().appendChild(script);
   }
-
+  
   @SuppressWarnings("unused") // used by JSNI
   private void onFailure(String message) {
     onFailure(new Exception(message));
@@ -212,10 +260,9 @@
    *
    * @param callbacks the global JS object which stores callbacks
    */
-  private native void registerCallbacks(JavaScriptObject callbacks) /*-{
+  private native void registerCallbacks(JavaScriptObject callbacks, boolean canHaveMultipleRequestsForId) /*-{
     var self = this;
     var callback = new Object();
-    callbacks[this.@com.google.gwt.jsonp.client.JsonpRequest::callbackId] = callback;
     callback.onSuccess = $entry(function(data) {
       // Box primitive types
       if (typeof data == 'boolean') {
@@ -234,6 +281,36 @@
         self.@com.google.gwt.jsonp.client.JsonpRequest::onFailure(Ljava/lang/String;)(message);
       });
     }
+    
+    if (canHaveMultipleRequestsForId) {
+      // In this case, we keep a wrapper, with a list of callbacks.  Since the
+      // response for the request is the same each time, we call all of the
+      // callbacks as soon as any response comes back.
+      var callbackWrapper =
+        callbacks[this.@com.google.gwt.jsonp.client.JsonpRequest::callbackId];
+      if (!callbackWrapper) {
+        callbackWrapper = new Object();
+        callbackWrapper.callbackList = new Array();
+
+        callbackWrapper.onSuccess = function(data) {
+          while (callbackWrapper.callbackList.length > 0) {
+            callbackWrapper.callbackList.shift().onSuccess(data);
+          }
+        } 
+        callbackWrapper.onFailure = function(data) {
+          while (callbackWrapper.callbackList.length > 0) {
+            callbackWrapper.callbackList.shift().onFailure(data);
+          }
+        } 
+        callbacks[this.@com.google.gwt.jsonp.client.JsonpRequest::callbackId] =
+          callbackWrapper;
+      }
+      callbackWrapper.callbackList.push(callback);
+    } else {
+      // In this simple case, just associate the callback directly with the
+      // particular id in the callbacks object
+      callbacks[this.@com.google.gwt.jsonp.client.JsonpRequest::callbackId] = callback;
+    }
   }-*/;
 
   /**
@@ -248,7 +325,13 @@
      */
     DeferredCommand.addCommand(new Command() {
       public void execute() {
-        unregisterCallbacks(CALLBACKS);
+        if (!canHaveMultipleRequestsForSameId) {
+          // If there can me multiple requests for a particular ID, then we
+          // don't want to unregister the callback since there may be pending
+          // requests that have not yet come back and we don't want them to
+          // have an undefined callback function.
+          unregisterCallbacks(CALLBACKS);
+        }
         Node script = Document.get().getElementById(callbackId);
         if (script != null) {
           // The script may have already been deleted
diff --git a/user/src/com/google/gwt/jsonp/client/JsonpRequestBuilder.java b/user/src/com/google/gwt/jsonp/client/JsonpRequestBuilder.java
index 9fe9b86..fd4043a 100644
--- a/user/src/com/google/gwt/jsonp/client/JsonpRequestBuilder.java
+++ b/user/src/com/google/gwt/jsonp/client/JsonpRequestBuilder.java
@@ -104,6 +104,7 @@
   private int timeout = 10000;
   private String callbackParam = "callback";
   private String failureCallbackParam = null;
+  private String predeterminedId = null;
 
   /**
    * Returns the name of the callback url parameter to send to the server. The
@@ -184,6 +185,10 @@
     this.failureCallbackParam = failureCallbackParam;
   }
 
+  public void setPredeterminedId(String id) {
+    this.predeterminedId = id;
+  }
+  
   /**
    * @param timeout The expected timeout (ms) for this request. The default is 10s.
    */
@@ -192,8 +197,14 @@
   }
 
   private <T> JsonpRequest<T> send(String url, AsyncCallback<T> callback, boolean expectInteger) {
-    JsonpRequest<T> request = new JsonpRequest<T>(callback, timeout, expectInteger, callbackParam,
-        failureCallbackParam);
+    JsonpRequest<T> request;
+    if (predeterminedId != null) {
+      request = new JsonpRequest<T>(callback, timeout, expectInteger, callbackParam,
+          failureCallbackParam, predeterminedId);
+    } else {
+      request = new JsonpRequest<T>(callback, timeout, expectInteger, callbackParam,
+          failureCallbackParam);
+    }
     request.send(url);
     return request;
   }
diff --git a/user/src/com/google/gwt/resources/Resources.gwt.xml b/user/src/com/google/gwt/resources/Resources.gwt.xml
index e685abf..a78e59a 100644
--- a/user/src/com/google/gwt/resources/Resources.gwt.xml
+++ b/user/src/com/google/gwt/resources/Resources.gwt.xml
@@ -20,6 +20,7 @@
   <inherits name="com.google.gwt.dom.DOM" />
   <!-- Used by ExternalTextResource -->
   <inherits name="com.google.gwt.http.HTTP" />
+  <inherits name="com.google.gwt.jsonp.Jsonp" />
 
   <!--  This acts as a switch to disable the use of data: URLs -->
   <define-property name="ClientBundle.enableInlining" values="true,false" />
@@ -78,4 +79,9 @@
   <!-- This can be used to make CssResource produce human-readable CSS -->
   <define-configuration-property name="CssResource.style" is-multi-valued="false" />
   <set-configuration-property name="CssResource.style" value="obf" />
+
+  <!-- This can be used to make ExternalTextResource use JSONP rather than XHR -->
+  <!-- by setting the value to true. -->
+  <define-configuration-property name="ExternalTextResource.useJsonp" is-multi-valued="false" />
+  <set-configuration-property name="ExternalTextResource.useJsonp" value="false" />
 </module>
diff --git a/user/src/com/google/gwt/resources/client/impl/ExternalTextResourcePrototype.java b/user/src/com/google/gwt/resources/client/impl/ExternalTextResourcePrototype.java
index c76ae01..ca8f381 100644
--- a/user/src/com/google/gwt/resources/client/impl/ExternalTextResourcePrototype.java
+++ b/user/src/com/google/gwt/resources/client/impl/ExternalTextResourcePrototype.java
@@ -21,10 +21,12 @@
 import com.google.gwt.http.client.RequestCallback;
 import com.google.gwt.http.client.RequestException;
 import com.google.gwt.http.client.Response;
+import com.google.gwt.jsonp.client.JsonpRequestBuilder;
 import com.google.gwt.resources.client.ExternalTextResource;
 import com.google.gwt.resources.client.ResourceCallback;
 import com.google.gwt.resources.client.ResourceException;
 import com.google.gwt.resources.client.TextResource;
+import com.google.gwt.user.client.rpc.AsyncCallback;
 
 /**
  * Implements external resource fetching of TextResources.
@@ -34,25 +36,35 @@
   /**
    * Maps the HTTP callback onto the ResourceCallback.
    */
-  private class ETRCallback implements RequestCallback {
+  private class ETRCallback implements RequestCallback, AsyncCallback<JavaScriptObject> {
     final ResourceCallback<TextResource> callback;
 
     public ETRCallback(ResourceCallback<TextResource> callback) {
       this.callback = callback;
     }
 
+    // For RequestCallback
     public void onError(Request request, Throwable exception) {
+      onFailure(exception);
+    }
+
+    // For AsyncCallback
+    public void onFailure(Throwable exception) {
       callback.onError(new ResourceException(
           ExternalTextResourcePrototype.this,
           "Unable to retrieve external resource", exception));
     }
 
+    // For RequestCallback
     public void onResponseReceived(Request request, final Response response) {
-      // Get the contents of the JSON bundle
       String responseText = response.getText();
-
       // Call eval() on the object.
       JavaScriptObject jso = evalObject(responseText);
+      onSuccess(jso);
+     }
+
+    // For AsyncCallback
+    public void onSuccess(JavaScriptObject jso) {
       if (jso == null) {
         callback.onError(new ResourceException(
             ExternalTextResourcePrototype.this, "eval() returned null"));
@@ -60,20 +72,18 @@
       }
 
       // Populate the TextResponse cache array
-      for (int i = 0; i < cache.length; i++) {
-        final String resourceText = extractString(jso, i);
-        cache[i] = new TextResource() {
+      final String resourceText = extractString(jso, index);
+      cache[index] = new TextResource() {
 
-          public String getName() {
-            return name;
-          }
+        public String getName() {
+          return name;
+        }
 
-          public String getText() {
-            return resourceText;
-          }
+        public String getText() {
+          return resourceText;
+        }
 
-        };
-      }
+      };
 
       // Finish by invoking the callback
       callback.onSuccess(cache[index]);
@@ -117,6 +127,7 @@
    */
   private final TextResource[] cache;
   private final int index;
+  private final String md5Hash;
   private final String name;
   private final String url;
 
@@ -126,6 +137,16 @@
     this.url = url;
     this.cache = cache;
     this.index = index;
+    this.md5Hash = null;
+  }
+
+  public ExternalTextResourcePrototype(String name, String url,
+      TextResource[] cache, int index, String md5Hash) {
+    this.name = name;
+    this.url = url;
+    this.cache = cache;
+    this.index = index;
+    this.md5Hash = md5Hash;
   }
 
   public String getName() {
@@ -144,13 +165,19 @@
       return;
     }
 
-    // Otherwise, fire an HTTP request.
-    RequestBuilder rb = new RequestBuilder(RequestBuilder.GET, url);
-    try {
-      rb.sendRequest("", new ETRCallback(callback));
-    } catch (RequestException e) {
-      throw new ResourceException(this,
-          "Unable to initiate request for external resource", e);
+    if (md5Hash != null) {
+      // If we have an md5Hash, we should be using JSONP
+      JsonpRequestBuilder rb = new JsonpRequestBuilder();
+      rb.setPredeterminedId(md5Hash);
+      rb.requestObject(url, new ETRCallback(callback));
+    } else {
+      RequestBuilder rb = new RequestBuilder(RequestBuilder.GET, url);
+      try {
+        rb.sendRequest("", new ETRCallback(callback));
+      } catch (RequestException e) {
+        throw new ResourceException(this,
+            "Unable to initiate request for external resource", e);
+      }
     }
   }
 }
diff --git a/user/src/com/google/gwt/resources/rg/ExternalTextResourceGenerator.java b/user/src/com/google/gwt/resources/rg/ExternalTextResourceGenerator.java
index 279e109..928f989 100644
--- a/user/src/com/google/gwt/resources/rg/ExternalTextResourceGenerator.java
+++ b/user/src/com/google/gwt/resources/rg/ExternalTextResourceGenerator.java
@@ -15,6 +15,8 @@
  */
 package com.google.gwt.resources.rg;
 
+import com.google.gwt.core.ext.BadPropertyValueException;
+import com.google.gwt.core.ext.ConfigurationProperty;
 import com.google.gwt.core.ext.Generator;
 import com.google.gwt.core.ext.TreeLogger;
 import com.google.gwt.core.ext.UnableToCompleteException;
@@ -42,6 +44,15 @@
  */
 public final class ExternalTextResourceGenerator extends
     AbstractResourceGenerator {
+  /**
+   * The name of a deferred binding property that determines whether or not this
+   * generator will use JSONP to fetch the files.
+   */
+  static final String USE_JSONP = "ExternalTextResource.useJsonp";
+  
+  // This string must stay in sync with the values in JsonpRequest.java
+  static final String JSONP_CALLBACK_PREFIX = "__gwt_jsonp__.P";
+
   private StringBuffer data;
   private boolean first;
   private String urlExpression;
@@ -65,6 +76,9 @@
     // These are field names
     sw.println(externalTextUrlIdent + ", " + externalTextCacheIdent + ", ");
     sw.println(offsets.get(method.getName()).toString());
+    if (shouldUseJsonp(context, logger)) {
+      sw.println(", \"" + getMd5HashOfData() + "\"");
+    }
     sw.outdent();
     sw.print(")");
 
@@ -75,10 +89,20 @@
   public void createFields(TreeLogger logger, ResourceContext context,
       ClientBundleFields fields) throws UnableToCompleteException {
     data.append(']');
+    StringBuffer wrappedData = new StringBuffer();
+    if (shouldUseJsonp(context, logger)) {
+      wrappedData.append(JSONP_CALLBACK_PREFIX);
+      wrappedData.append(getMd5HashOfData());
+      wrappedData.append(".onSuccess(\n");
+      wrappedData.append(data.toString());
+      wrappedData.append(")");
+    } else {
+      wrappedData = data;
+    }
 
     urlExpression = context.deploy(
         context.getClientBundleType().getQualifiedSourceName().replace('.', '_')
-            + "_jsonbundle.txt", "text/plain", data.toString().getBytes(), true);
+            + "_jsonbundle.txt", "text/plain", wrappedData.toString().getBytes(), true);
 
     TypeOracle typeOracle = context.getGeneratorContext().getTypeOracle();
     JClassType stringType = typeOracle.findType(String.class.getName());
@@ -142,4 +166,22 @@
     // Store the (possibly n:1) mapping of resource function to bundle index.
     offsets.put(method.getName(), hashes.get(toWrite));
   }
+
+  private String getMd5HashOfData() {
+    return Util.computeStrongName(data.toString().getBytes());
+  }
+
+  private boolean shouldUseJsonp(ResourceContext context, TreeLogger logger) {
+    String useJsonpProp = null;
+    try {
+      ConfigurationProperty prop = context.getGeneratorContext()
+        .getPropertyOracle().getConfigurationProperty(USE_JSONP);
+      useJsonpProp = prop.getValues().get(0);
+    } catch (BadPropertyValueException e) {
+      logger.log(TreeLogger.ERROR, "Bad value for " + USE_JSONP, e);
+      return false;
+    }
+    return Boolean.parseBoolean(useJsonpProp);
+  }
+
 }
diff --git a/user/test/com/google/gwt/jsonp/client/JsonpRequestTest.java b/user/test/com/google/gwt/jsonp/client/JsonpRequestTest.java
index e88a64b..ff99107 100644
--- a/user/test/com/google/gwt/jsonp/client/JsonpRequestTest.java
+++ b/user/test/com/google/gwt/jsonp/client/JsonpRequestTest.java
@@ -283,6 +283,17 @@
         new AssertSuccessCallback<String>("C", counter));
   }
 
+  public void testPredeterminedIds() {
+    delayTestFinish(RESPONSE_DELAY);
+    String PREDETERMINED = "pred";
+    jsonp.setPredeterminedId(PREDETERMINED);
+    JsonpRequest<String> reqA = jsonp.requestString(echo("'A'"),
+        new AssertSuccessCallback<String>("A"));
+    String idA = reqA.getCallbackId().substring(1);
+    assertEquals("Unexpected ID sequence", PREDETERMINED, idA);
+    jsonp.setPredeterminedId(null);
+  }
+
   public void testString() {
     delayTestFinish(RESPONSE_DELAY);
     jsonp.requestString(echo("'Hello'"), new AssertSuccessCallback<String>(
diff --git a/user/test/com/google/gwt/resources/ExternalTextResourceJsonp.gwt.xml b/user/test/com/google/gwt/resources/ExternalTextResourceJsonp.gwt.xml
new file mode 100644
index 0000000..e395dd0
--- /dev/null
+++ b/user/test/com/google/gwt/resources/ExternalTextResourceJsonp.gwt.xml
@@ -0,0 +1,19 @@
+<!--                                                                        -->
+<!-- Copyright 2009 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   -->
+<!-- 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. License for the specific language governing permissions and   -->
+<!-- limitations under the License.                                         -->
+<module>
+  <inherits name="com.google.gwt.resources.Resources" />
+  <set-configuration-property name="ExternalTextResource.useJsonp"
+                              value="true" />
+</module>
+    
\ No newline at end of file
diff --git a/user/test/com/google/gwt/resources/ResourcesSuite.java b/user/test/com/google/gwt/resources/ResourcesSuite.java
index 1c1085c..b101b12 100644
--- a/user/test/com/google/gwt/resources/ResourcesSuite.java
+++ b/user/test/com/google/gwt/resources/ResourcesSuite.java
@@ -17,6 +17,8 @@
 
 import com.google.gwt.junit.tools.GWTTestSuite;
 import com.google.gwt.resources.client.CSSResourceTest;
+import com.google.gwt.resources.client.ExternalTextResourceJsonpTest;
+import com.google.gwt.resources.client.ExternalTextResourceTest;
 import com.google.gwt.resources.client.ImageResourceNoInliningTest;
 import com.google.gwt.resources.client.ImageResourceTest;
 import com.google.gwt.resources.client.NestedBundleTest;
@@ -43,17 +45,19 @@
     GWTTestSuite suite = new GWTTestSuite("Test for com.google.gwt.resources");
     suite.addTestSuite(CssClassNamesTestCase.class);
     suite.addTestSuite(CssExternalTest.class);
-    suite.addTestSuite(CSSResourceTest.class);
-    suite.addTestSuite(CssReorderTest.class);
-    suite.addTestSuite(CssRtlTest.class);
     suite.addTestSuite(CssNodeClonerTest.class);
-    suite.addTestSuite(ExtractClassNamesVisitorTest.class);
-    suite.addTestSuite(ImageResourceTest.class);
-    suite.addTestSuite(ImageResourceNoInliningTest.class);
-    suite.addTestSuite(NestedBundleTest.class);
+    suite.addTestSuite(CssReorderTest.class);
+    suite.addTestSuite(CSSResourceTest.class);
+    suite.addTestSuite(CssRtlTest.class);
     suite.addTestSuite(DataResourceDoNotEmbedTest.class);
-    suite.addTestSuite(ResourceGeneratorUtilTest.class);
     suite.addTestSuite(DataResourceMimeTypeTest.class);
+    suite.addTestSuite(ExternalTextResourceJsonpTest.class);
+    suite.addTestSuite(ExternalTextResourceTest.class);
+    suite.addTestSuite(ExtractClassNamesVisitorTest.class);
+    suite.addTestSuite(ImageResourceNoInliningTest.class);
+    suite.addTestSuite(ImageResourceTest.class);
+    suite.addTestSuite(NestedBundleTest.class);
+    suite.addTestSuite(ResourceGeneratorUtilTest.class);
     suite.addTestSuite(TextResourceTest.class);
     suite.addTestSuite(UnknownAtRuleTest.class);
     return suite;
diff --git a/user/test/com/google/gwt/resources/client/ExternalTextResourceJsonpTest.java b/user/test/com/google/gwt/resources/client/ExternalTextResourceJsonpTest.java
new file mode 100644
index 0000000..c00aec4
--- /dev/null
+++ b/user/test/com/google/gwt/resources/client/ExternalTextResourceJsonpTest.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2011 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.resources.client;
+
+/**
+ * Tests for ExternalTextResource's jsonp functionality.
+ */
+public class ExternalTextResourceJsonpTest extends ExternalTextResourceTest {
+  @Override
+  public String getModuleName() {
+    return "com.google.gwt.resources.ExternalTextResourceJsonp";
+  }
+}
diff --git a/user/test/com/google/gwt/resources/client/ExternalTextResourceTest.java b/user/test/com/google/gwt/resources/client/ExternalTextResourceTest.java
new file mode 100644
index 0000000..56b01df
--- /dev/null
+++ b/user/test/com/google/gwt/resources/client/ExternalTextResourceTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2011 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.resources.client;
+
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.junit.client.GWTTestCase;
+
+/**
+ * Tests for ExternalTextResource assembly and use.
+ */
+public class ExternalTextResourceTest extends GWTTestCase {
+
+  static interface Resources extends ClientBundleWithLookup {
+    @Source("hello.txt")
+    ExternalTextResource helloWorldExternal();
+
+    @Source("shouldBeEscaped.txt")
+    ExternalTextResource needsEscapeExternal();
+  }
+
+  private static final String HELLO = "Hello World!";
+  private static final String NEEDS_ESCAPE = "\"'\\";
+
+  @Override
+  public String getModuleName() {
+    return "com.google.gwt.resources.Resources";
+  }
+
+  public void testExternal() throws ResourceException {
+    final Resources r = GWT.create(Resources.class);
+    int numReturned = 0;
+
+    delayTestFinish(2000);
+
+    class TestCallback implements ResourceCallback<TextResource> {
+      private final String name;
+      private final String contents;
+      private TestCallback otherTest;
+      private boolean done = false;
+
+      TestCallback(String name, String contents) {
+        this.name = name;
+        this.contents = contents;
+      }
+
+      public boolean isDone() {
+        return done;
+      }
+      
+      public void onError(ResourceException e) {
+        e.printStackTrace();
+        fail("Unable to fetch " + e.getResource().getName());
+      }
+
+      public void onSuccess(TextResource resource) {
+        assertEquals(name, resource.getName());
+        assertEquals(contents, resource.getText());
+        done = true;
+        if (otherTest.isDone()) {
+          finishTest();
+        }
+      }
+      
+      public void setOtherTest(TestCallback test) {
+        otherTest = test;
+      }
+    };
+
+    TestCallback needsEscape = new TestCallback(
+        r.needsEscapeExternal().getName(), NEEDS_ESCAPE);
+    TestCallback helloWorld = new TestCallback(
+        r.helloWorldExternal().getName(), HELLO);
+    needsEscape.setOtherTest(helloWorld);
+    helloWorld.setOtherTest(needsEscape);
+    
+    r.needsEscapeExternal().getText(needsEscape);
+    r.helloWorldExternal().getText(helloWorld);
+  }
+}
diff --git a/user/test/com/google/gwt/resources/client/TextResourceTest.java b/user/test/com/google/gwt/resources/client/TextResourceTest.java
index 11080e3..3066812 100644
--- a/user/test/com/google/gwt/resources/client/TextResourceTest.java
+++ b/user/test/com/google/gwt/resources/client/TextResourceTest.java
@@ -58,28 +58,6 @@
     assertEquals(length, 12737792);
   }
 
-  public void testExternal() throws ResourceException {
-    final Resources r = GWT.create(Resources.class);
-
-    delayTestFinish(2000);
-
-    ResourceCallback<TextResource> c = new ResourceCallback<TextResource>() {
-
-      public void onError(ResourceException e) {
-        e.printStackTrace();
-        fail("Unable to fetch " + e.getResource().getName());
-      }
-
-      public void onSuccess(TextResource resource) {
-        assertEquals(r.helloWorldExternal().getName(), resource.getName());
-        assertEquals(HELLO, resource.getText());
-        finishTest();
-      }
-    };
-
-    r.helloWorldExternal().getText(c);
-  }
-
   public void testInline() {
     Resources r = GWT.create(Resources.class);
     assertEquals(HELLO, r.helloWorldRelative().getText());
diff --git a/user/test/com/google/gwt/resources/client/shouldBeEscaped.txt b/user/test/com/google/gwt/resources/client/shouldBeEscaped.txt
new file mode 100644
index 0000000..3d9f92b
--- /dev/null
+++ b/user/test/com/google/gwt/resources/client/shouldBeEscaped.txt
@@ -0,0 +1 @@
+"'\
\ No newline at end of file