JUnit infrastructure now properly handles exceptions thrown from event handlers
that are triggered synchronously from within test methods (e.g. by calling
Button.click()).
Issue: 3258
Patch by: jgw
Review by: scottb


git-svn-id: https://google-web-toolkit.googlecode.com/svn/releases/1.6@4496 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/user/super/com/google/gwt/junit/translatable/com/google/gwt/junit/client/GWTTestCase.java b/user/super/com/google/gwt/junit/translatable/com/google/gwt/junit/client/GWTTestCase.java
index 9b592c8..7f166f0 100644
--- a/user/super/com/google/gwt/junit/translatable/com/google/gwt/junit/client/GWTTestCase.java
+++ b/user/super/com/google/gwt/junit/translatable/com/google/gwt/junit/client/GWTTestCase.java
@@ -62,6 +62,37 @@
   }
 
   /**
+   * UncaughtExceptionHandler used to catch exceptions thrown out of Javascript
+   * event handlers.
+   */
+  private final class TestCaseUncaughtExceptionHandler implements
+      UncaughtExceptionHandler {
+
+    // Holds the first exception that's throws "synchronously", meaning "before
+    // the test method returns".
+    private Throwable synchronousException = null;
+
+    /**
+     * An uncaught exception escaped to the browser; what we should do depends
+     * on what state we're in.
+     */
+    public void onUncaughtException(Throwable ex) {
+      if (mainTestHasRun && timer != null) {
+        // Asynchronous mode; uncaught exceptions cause an immediate failure.
+        assert (!testIsFinished);
+        reportResultsAndRunNextMethod(ex);
+      } else {
+        // Synchronous mode: hang on to it for after the test method returns.
+        // We can't call reportResultsAndRunNextMethod() yet, as it will cause
+        // a race condition that often causes the same test to be run again.
+        if (synchronousException == null) {
+          synchronousException = ex;
+        }
+      }
+    }
+  };
+
+  /**
    * The collected checkpoint messages.
    */
   private List<String> checkPoints;
@@ -86,21 +117,11 @@
    */
   private KillTimer timer;
 
-  private final UncaughtExceptionHandler uncaughtHandler = new UncaughtExceptionHandler() {
-    /**
-     * An uncaught exception escaped to the browser; what we should do depends
-     * on what state we're in.
-     */
-    public void onUncaughtException(Throwable ex) {
-      if (mainTestHasRun && timer != null) {
-        // Asynchronous mode; uncaught exceptions cause an immediate failure.
-        assert (!testIsFinished);
-        reportResultsAndRunNextMethod(ex);
-      } else {
-        // just ignore it
-      }
-    }
-  };
+  /**
+   * The UncaughtExceptionHandler that will be used to catch exceptions thrown
+   * from event handlers. We will create a new one for each test method.
+   */
+  private TestCaseUncaughtExceptionHandler uncaughtHandler;
 
   // CHECKSTYLE_OFF
   /**
@@ -111,7 +132,7 @@
 
     if (shouldCatchExceptions()) {
       // Make sure no exceptions escape
-      GWT.setUncaughtExceptionHandler(uncaughtHandler);
+      GWT.setUncaughtExceptionHandler(uncaughtHandler = new TestCaseUncaughtExceptionHandler());
       try {
         runBare();
       } catch (Throwable e) {
@@ -127,6 +148,13 @@
     // timer != null we are in true asynchronous mode.
     mainTestHasRun = true;
 
+    // See if any synchronous exceptions got picked up by the UncaughtExceptionHandler.
+    if ((uncaughtHandler != null) && (uncaughtHandler.synchronousException != null)) {
+      // If an exception was caught in an event handler, it must have happened
+      // before the exception was thrown from the test method.
+      caught = uncaughtHandler.synchronousException;
+    }
+
     if (caught != null) {
       // Test failed; finish test no matter what state we're in.
       reportResultsAndRunNextMethod(caught);
@@ -262,6 +290,10 @@
       // ignore any exceptions thrown from tearDown
     }
 
+    // Remove the UncaughtExceptionHandler we may have installed in __doRunTest.
+    GWT.setUncaughtExceptionHandler(null);
+    uncaughtHandler = null;
+
     JUnitResult myResult = __getOrCreateTestResult();
     if (ex != null) {
       ExceptionWrapper ew = new ExceptionWrapper(ex);
diff --git a/user/test/com/google/gwt/junit/client/TestManualAsync.java b/user/test/com/google/gwt/junit/client/TestManualAsync.java
index f4d62c5..4e9ae19 100644
--- a/user/test/com/google/gwt/junit/client/TestManualAsync.java
+++ b/user/test/com/google/gwt/junit/client/TestManualAsync.java
@@ -18,6 +18,10 @@
 import static com.google.gwt.junit.client.GWTTestCaseTest.SetUpTearDownState.IS_SETUP;
 import static com.google.gwt.junit.client.GWTTestCaseTest.SetUpTearDownState.IS_TORNDOWN;
 
+import com.google.gwt.dom.client.DivElement;
+import com.google.gwt.dom.client.Document;
+import com.google.gwt.user.client.Event;
+import com.google.gwt.user.client.EventListener;
 import com.google.gwt.user.client.Timer;
 
 /**
@@ -27,6 +31,91 @@
  */
 public class TestManualAsync extends GWTTestCaseTest {
 
+  // The following tests (all prefixed with test_) are intended to test the
+  // interaction of synchronous failures (within event handlers) with various
+  // other types of failures and successes. All of them are expected to fail
+  // with the message "Expected failure".
+  //
+  // Nomenclature for these tests:
+  // DTF => delayTestFinish()
+  // SF => synchronous failure (from event handler)
+  // FT => finishTest()
+  // F => fail()
+  // R => return;
+
+  public void test_dtf_sf() {
+    System.out.println("test_dtf_sf");
+    delayTestFinish();
+    synchronousFailure("test_dtf_sf");
+  }
+
+  public void test_dtf_sf_f() {
+    System.out.println("test_dtf_sf_f");
+    delayTestFinish();
+    synchronousFailure("test_dtf_sf_f");
+    failNow("test_dtf_sf_f");
+  }
+
+  public void test_dtf_sf_ft() {
+    System.out.println("test_dtf_sf_ft");
+    delayTestFinish();
+    synchronousFailure("test_dtf_sf_ft");
+    finishTest();
+  }
+
+  public void test_dtf_sf_r_f() {
+    System.out.println("test_dtf_sf_r_f");
+    delayTestFinish();
+    synchronousFailure("test_dtf_sf_r_f");
+    failLater("test_dtf_sf_r_f");
+  }
+
+  public void test_dtf_sf_r_ft() {
+    System.out.println("test_dtf_sf_r_ft");
+    delayTestFinish();
+    synchronousFailure("test_dtf_sf_r_ft");
+    finishTestLater("test_dtf_sf_r_ft");
+  }
+
+  public void test_sf() {
+    System.out.println("test_sf");
+    synchronousFailure("test_sf");
+  }
+
+  public void test_sf_dtf_f() {
+    System.out.println("test_sf_dtf_f");
+    synchronousFailure("test_sf_dtf_f");
+    delayTestFinish();
+    failNow("test_sf_dtf_f");
+  }
+
+  public void test_sf_dtf_ft() {
+    System.out.println("test_sf_dtf_ft");
+    synchronousFailure("test_sf_dtf_ft");
+    delayTestFinish();
+    finishTest();
+  }
+
+  public void test_sf_dtf_r_f() {
+    System.out.println("test_sf_dtf_r_f()");
+    synchronousFailure("test_sf_dtf_r_f");
+    delayTestFinish();
+    failLater("test_sf_dtf_r_f");
+  }
+
+  public void test_sf_dtf_r_ft() {
+    System.out.println("test_sf_dtf_r_ft()");
+    synchronousFailure("test_sf_dtf_r_ft");
+    delayTestFinish(5 * 60 * 1000);
+    finishTestLater("test_sf_dtf_r_ft");
+  }
+
+  public void test_sf_f() {
+    System.out.println("test_sf_f()");
+    synchronousFailure("test_sf_f");
+    failNow("test_sf_f");
+  }
+
   /**
    * Fails normally.
    */
@@ -55,7 +144,7 @@
    * Async fails.
    */
   public void testFailAsync() {
-    delayTestFinish(200);
+    delayTestFinish(1000);
     new Timer() {
       public void run() {
         fail("Expected failure");
@@ -64,6 +153,41 @@
   }
 
   /**
+   * Tests the case where a JUnit exception is thrown from an event handler, but
+   * after this test method has completed successfully.
+   * 
+   * This test should *not* fail, but the next one should.
+   */
+  public void testLateFailPart1() {
+    // Leave the test in synchronous mode, but crank up a timer to fail in 2.5s.
+    new Timer() {
+      @Override
+      public void run() {
+        // This fail should be called during the next test.
+        fail();
+      }
+    }.schedule(2500);
+
+    // We don't actually assert anything here. This test exists solely to make
+    // the next one fail.
+  }
+
+  /**
+   * Second half of the previous test.
+   */
+  public void testLateFailPart2() {
+    // Go into async mode from 5s, finishing in 4. The timer from the previous
+    // test will go off and call fail() before finishTest() is called.
+    delayTestFinish(5000);
+    new Timer() {
+      @Override
+      public void run() {
+        finishTest();
+      }
+    }.schedule(4000);
+  }
+
+  /**
    * Completes normally.
    */
   public void testNormal() {
@@ -182,4 +306,55 @@
     }.schedule(200);
   }
 
+  // Call delayTestFinish() with enough time so that failLater() will
+  // definitely fail.
+  private void delayTestFinish() {
+    delayTestFinish(2500);
+  }
+
+  // Fail asynchronously after a small amount of time.
+  private void failLater(final String failMsg) {
+    new Timer() {
+      @Override
+      public void run() {
+        System.out.println("failLater(): " + failMsg);
+        failNow(failMsg);
+      }
+    }.schedule(100);
+  }
+
+  // Fail synchronously with an "expected failure" message.
+  private void failNow(String failMsg) {
+    fail("Expected failure (" + failMsg + ")");
+  }
+
+  // Finish the test asynchronously after a small amount of time.
+  private void finishTestLater(final String debugMsg) {
+    System.out.println("finishTestLater(): " + debugMsg);
+    new Timer() {
+      @Override
+      public void run() {
+        System.out.println("finishTestLater() done: " + debugMsg);
+        finishTest();
+      }
+    }.schedule(1);
+  }
+
+  // Trigger a test failure synchronously, but from within an event handler.
+  // (The exception thrown from fail() will get caught by the GWT
+  // UncaughtExceptionHandler).
+  private void synchronousFailure(final String failMsg) {
+    DivElement elem = Document.get().createDivElement();
+    Document.get().getBody().appendChild(elem);
+    Event.sinkEvents(elem, Event.ONSCROLL);
+
+    EventListener listener = new EventListener() {
+      public void onBrowserEvent(Event event) {
+        System.out.println("failNow(): " + failMsg);
+        failNow(failMsg);
+      }
+    };
+    Event.setEventListener(elem, listener);
+    Event.triggerScrollEvent(elem);
+  }
 }