SLING-1984 - support for running individual tests remotely from an IDE. Based on a contribution by Pooja Kothari, thanks!

git-svn-id: https://svn.apache.org/repos/asf/sling/trunk@1074632 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/pom.xml b/pom.xml
index 2e06348..81102e1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -28,7 +28,7 @@
 
     <artifactId>org.apache.sling.junit.remote</artifactId>
     <version>0.1.1-SNAPSHOT</version>
-    <packaging>jar</packaging>
+    <packaging>bundle</packaging>
 
     <name>Apache Sling JUnit Remote Tests Runners</name>
     <description>Utilities to run server-side JUnit tests remotely</description>
@@ -39,13 +39,49 @@
         <url>http://svn.apache.org/viewvc/sling/trunk/testing/junit/remote</url>
     </scm>
 
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.felix</groupId>
+                <artifactId>maven-scr-plugin</artifactId>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.felix</groupId>
+                <artifactId>maven-bundle-plugin</artifactId>
+                <extensions>true</extensions>
+                <configuration>
+                    <instructions>
+                        <Export-Package>org.apache.sling.junit.remote.exported.*</Export-Package>
+                        <Import-Package>
+                            org.apache.http.*; resolution:=optional,
+                            org.apache.sling.testing.tools.http; resolution:=optional,
+                            org.junit.internal.*; resolution:=optional,
+                            *
+                        </Import-Package>
+                        <Sling-Test-Regexp>.*Test</Sling-Test-Regexp>
+                    </instructions>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+    
     <dependencies>
         <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.core</artifactId>
+        </dependency>
+        <dependency>
             <groupId>org.apache.sling</groupId>
             <artifactId>org.apache.sling.junit.core</artifactId>
             <version>0.1.1-SNAPSHOT</version>
         </dependency>
         <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.scr.annotations</artifactId>
+            <version>1.4.0</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
             <groupId>junit</groupId>
             <artifactId>junit</artifactId>
             <version>4.8.2</version>
@@ -57,6 +93,12 @@
             <version>1.5.11</version>
         </dependency>
         <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>1.5.11</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
             <groupId>org.apache.sling</groupId>
             <artifactId>org.apache.sling.testing.tools</artifactId>
             <version>0.1.1-SNAPSHOT</version>
@@ -66,5 +108,9 @@
             <artifactId>org.apache.sling.commons.json</artifactId>
             <version>2.0.6</version>
         </dependency>
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>servlet-api</artifactId>
+        </dependency>
     </dependencies>
 </project>
diff --git a/src/main/java/org/apache/sling/junit/remote/exported/ExampleRemoteTest.java b/src/main/java/org/apache/sling/junit/remote/exported/ExampleRemoteTest.java
new file mode 100644
index 0000000..063a4f4
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/remote/exported/ExampleRemoteTest.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.sling.junit.remote.exported;
+
+import static org.junit.Assert.fail;
+import org.apache.sling.junit.remote.ide.SlingRemoteExecutionRule;
+import org.junit.Rule;
+import org.junit.Test;
+
+/** Test that can be run remotely on a Sling instance from an IDE, by
+ *  setting the {@link SlingRemoteExecutionRule.SLING_REMOTE_TEST_URL}
+ *  system property in the IDE setup, to the URL of 
+ *  the Sling JUnit servlet (like http://localhost:8080/system/sling/junit)
+ */
+public class ExampleRemoteTest {
+    
+    @Rule
+    public SlingRemoteExecutionRule execRule = new SlingRemoteExecutionRule();
+    
+    @Test
+    public void testAlwaysPasses() {
+    }
+    
+    @Test
+    public void testAlwaysFails() {
+        fail("This test always fails");
+    }
+    
+    @Test
+    public void testFailsSometimes() {
+        if(Math.random() < 0.5) {
+            fail("This test fails sometimes");
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/junit/remote/httpclient/RemoteTestHttpClient.java b/src/main/java/org/apache/sling/junit/remote/httpclient/RemoteTestHttpClient.java
new file mode 100644
index 0000000..007109a
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/remote/httpclient/RemoteTestHttpClient.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You 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 org.apache.sling.junit.remote.httpclient;
+
+import java.io.IOException;
+
+import org.apache.http.ParseException;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.sling.testing.tools.http.Request;
+import org.apache.sling.testing.tools.http.RequestBuilder;
+import org.apache.sling.testing.tools.http.RequestExecutor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** HTTP client that executes tests remotely */ 
+public class RemoteTestHttpClient {
+    private final Logger log = LoggerFactory.getLogger(getClass());
+    
+    private final String junitServletUrl;
+    private StringBuilder subpath;
+    private boolean consumeContent;
+    private static final String SLASH = "/";
+    private static final String DOT = ".";
+    
+    public RemoteTestHttpClient(String junitServletUrl, boolean consumeContent) {
+        if(junitServletUrl == null) {
+            throw new IllegalArgumentException("JUnit servlet URL is null, cannot run tests");
+        }
+        this.junitServletUrl = junitServletUrl;
+        this.consumeContent = consumeContent;
+    }
+    
+    public RequestExecutor runTests(String testClassesSelector, String testMethodSelector, String extension) 
+    throws ClientProtocolException, IOException {
+        final RequestBuilder builder = new RequestBuilder(junitServletUrl);
+
+        // Optionally let the client to consume the response entity
+        final RequestExecutor executor = new RequestExecutor(new DefaultHttpClient()) {
+            @Override
+            protected void consumeEntity() throws ParseException, IOException {
+                if(consumeContent) {
+                    super.consumeEntity();
+                }
+            }
+        };
+        
+        // POST request executes the tests
+        subpath = new StringBuilder();
+        if(!junitServletUrl.endsWith(SLASH)) {
+            subpath.append(SLASH);
+        }
+        subpath.append(testClassesSelector);
+        if(!extension.startsWith(DOT)) {
+            subpath.append(DOT);
+        }
+        subpath.append(extension);
+        if(testMethodSelector != null && testMethodSelector.length() > 0) {
+            subpath.append("/");
+            subpath.append(testMethodSelector);
+        }
+        
+        log.info("Executing test remotely, path={} JUnit servlet URL={}", 
+                subpath, junitServletUrl);
+        final Request r = builder.buildPostRequest(subpath.toString());
+        executor.execute(r).assertStatus(200);
+
+        return executor;
+    }
+    
+    /** If called after runTests, returns the path used to
+     *  run tests on the remote JUnit servlet
+     */
+    public String getTestExecutionPath() {
+        return subpath == null ? null : subpath.toString();
+    }
+}
diff --git a/src/main/java/org/apache/sling/junit/remote/ide/ExecutionResult.java b/src/main/java/org/apache/sling/junit/remote/ide/ExecutionResult.java
new file mode 100644
index 0000000..19cf76d
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/remote/ide/ExecutionResult.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.sling.junit.remote.ide;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.runner.Result;
+import org.junit.runner.notification.Failure;
+
+public class ExecutionResult implements Serializable {
+   private static final long serialVersionUID = 7935484811381524530L;
+   private final Throwable throwable;
+   
+   public ExecutionResult(Result result) {
+       if (result.getFailureCount() > 0) {
+           final List<Throwable> failures = new ArrayList<Throwable>(result.getFailureCount());
+           for (Failure f : result.getFailures()) {
+               failures.add(f.getException());
+           }
+           
+           // TODO MultipleFailureException is an internal JUnit class - 
+           // we don't have it when running server-side in Sling
+           // throwable = new MultipleFailureException(failures);
+           throwable = failures.get(0);
+       } else {
+           throwable = null;
+       }
+   }
+   
+   public Throwable getException() {
+       return throwable;
+   }
+   
+   public boolean isFailure() {
+       return throwable != null;
+   }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/junit/remote/ide/SerializedRenderer.java b/src/main/java/org/apache/sling/junit/remote/ide/SerializedRenderer.java
new file mode 100644
index 0000000..ddb0dc3
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/remote/ide/SerializedRenderer.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.sling.junit.remote.ide;
+
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.Collection;
+
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.junit.Renderer;
+import org.apache.sling.junit.RequestParser;
+import org.junit.runner.Result;
+import org.junit.runner.notification.RunListener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Renderer for Sling JUnit server-side testing, which
+ *  renders test results in binary form.
+ *  Used to send results, and especially Exceptions, as
+ *  is to a remote IDE.      
+ */
+@Component(immediate=false)
+@Service
+public class SerializedRenderer extends RunListener implements Renderer {
+
+    public static final String EXTENSION = "serialized";
+    private ObjectOutputStream outputStream;
+    private final Logger log = LoggerFactory.getLogger(getClass());
+
+    /** @inheritDoc */
+    public boolean appliesTo(RequestParser p) {
+        return EXTENSION.equals(p.getExtension());
+    }
+
+    /** @inheritDoc */
+    public void setup(HttpServletResponse response, String pageTitle) 
+    throws IOException, UnsupportedEncodingException {
+        response.setContentType("application/x-java-serialized-object");
+        outputStream = new ObjectOutputStream(response.getOutputStream());
+    }
+    
+    /** @inheritDoc */
+    public void cleanup() {
+        try {
+            outputStream.flush();
+            outputStream.close();
+        } catch (IOException e) {
+            log.warn("Exception in cleanup()", e);
+        }
+        outputStream = null;
+    }
+
+    /** @inheritDoc */
+    public RunListener getRunListener() {
+        return this;
+    }
+
+    /** @inheritDoc */
+    public void info(String role, String info) {
+    }
+
+    /** @inheritDoc */
+    public void link(String info, String url, String method) {
+    }
+
+    /** @inheritDoc */
+    public void list(String role, Collection<String> data) {
+    }
+
+    /** @inheritDoc */
+    public void title(int level, String title) {
+    }
+    
+    @Override 
+    public void testRunFinished(Result result) throws IOException {
+        final ExecutionResult er = new ExecutionResult(result);
+        outputStream.writeObject(er);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/junit/remote/ide/SlingRemoteExecutionRule.java b/src/main/java/org/apache/sling/junit/remote/ide/SlingRemoteExecutionRule.java
new file mode 100644
index 0000000..9590ab8
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/remote/ide/SlingRemoteExecutionRule.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.sling.junit.remote.ide;
+
+import java.io.ObjectInputStream;
+
+import org.apache.http.HttpEntity;
+import org.apache.sling.junit.remote.httpclient.RemoteTestHttpClient;
+import org.apache.sling.testing.tools.http.RequestExecutor;
+import org.junit.rules.MethodRule;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.Statement;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SlingRemoteExecutionRule implements MethodRule {
+   private static final Logger log = 
+       LoggerFactory.getLogger(SlingRemoteExecutionRule.class);
+
+   /** Name of the system property that activates remote test execution */
+   public static final String SLING_REMOTE_TEST_URL = "sling.remote.test.url";
+   
+   public Statement apply(final Statement base, final FrameworkMethod method, Object target) {
+       return new Statement() {
+           @Override
+           public void evaluate() throws Throwable {
+               if (tryRemoteEvaluation(method)) {
+                   return;
+               }
+               base.evaluate();
+           }
+       };
+   }
+
+   /**
+    * Execute test remotely if the corresponding system property is set
+    * 
+    * @return <code>true</code> if the method was executed remotely and passed.
+    *         If the test was <b>not</b> executed remotely then
+    *         <code>false</code> is returned to indicate that test should be
+    *         executed locally
+    */
+   private boolean tryRemoteEvaluation(FrameworkMethod method) throws Throwable {
+       String remoteUrl = System.getProperty(SLING_REMOTE_TEST_URL);
+       if(remoteUrl != null) {
+           remoteUrl = remoteUrl.trim();
+           if(remoteUrl.length() > 0) {
+               invokeRemote(remoteUrl, method);
+               return true;
+           }
+       }
+       return false;
+   }
+
+   private void invokeRemote(String remoteUrl, FrameworkMethod method) throws Throwable {
+       final String testClassesSelector = method.getMethod().getDeclaringClass().getName();
+       final String methodName = method.getMethod().getName();
+       
+       final RemoteTestHttpClient testHttpClient = new RemoteTestHttpClient(remoteUrl, false);
+       final RequestExecutor executor = testHttpClient.runTests(
+               testClassesSelector, methodName, "serialized"
+       );
+       log.debug("Ran test {} method {} at URL {}",
+               new Object[] { testClassesSelector, methodName, remoteUrl });
+       
+       final HttpEntity entity = executor.getResponse().getEntity();
+       if (entity != null) {
+           try {
+               final Object o = new ObjectInputStream(entity.getContent()).readObject();
+               if( !(o instanceof ExecutionResult) ) {
+                   throw new IllegalStateException("Expected an ExecutionResult, got a " + o.getClass().getName());
+               }
+               final ExecutionResult result = (ExecutionResult)o;
+               if (result.isFailure()) {
+                   throw result.getException();
+               }
+           } finally {
+               entity.consumeContent();
+           }
+       }
+   }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/junit/remote/testrunner/SlingRemoteTestRunner.java b/src/main/java/org/apache/sling/junit/remote/testrunner/SlingRemoteTestRunner.java
index 428f090..d8e9118 100644
--- a/src/main/java/org/apache/sling/junit/remote/testrunner/SlingRemoteTestRunner.java
+++ b/src/main/java/org/apache/sling/junit/remote/testrunner/SlingRemoteTestRunner.java
@@ -21,12 +21,10 @@
 import java.util.LinkedList;
 import java.util.List;
 
-import org.apache.http.impl.client.DefaultHttpClient;
 import org.apache.sling.commons.json.JSONArray;
 import org.apache.sling.commons.json.JSONObject;
 import org.apache.sling.commons.json.JSONTokener;
-import org.apache.sling.testing.tools.http.Request;
-import org.apache.sling.testing.tools.http.RequestBuilder;
+import org.apache.sling.junit.remote.httpclient.RemoteTestHttpClient;
 import org.apache.sling.testing.tools.http.RequestExecutor;
 import org.junit.internal.AssumptionViolatedException;
 import org.junit.internal.runners.model.EachTestNotifier;
@@ -47,8 +45,7 @@
 public class SlingRemoteTestRunner extends ParentRunner<SlingRemoteTest> {
     private static final Logger log = LoggerFactory.getLogger(SlingRemoteTestRunner.class);
     private final SlingRemoteTestParameters testParameters;
-    private RequestExecutor executor;
-    private RequestBuilder builder;
+    private RemoteTestHttpClient testHttpClient;
     private final Class<?> testClass;
     
     private final List<SlingRemoteTest> children = new LinkedList<SlingRemoteTest>();
@@ -72,32 +69,18 @@
     }
     
     private void maybeExecuteTests() throws Exception {
-        if(executor != null) {
+        if(testHttpClient != null) {
+            // Tests already ran
             return;
         }
         
-        // Setup request execution
-        executor = new RequestExecutor(new DefaultHttpClient());
-        if(testParameters.getJunitServletUrl() == null) {
-            throw new IllegalStateException("Server base URL is null, cannot run tests");
-        }
-        builder = new RequestBuilder(testParameters.getJunitServletUrl());
-        
-        // POST request executes the tests
-        final StringBuilder subpath = new StringBuilder();
-        subpath.append("/");
-        subpath.append(testParameters.getTestClassesSelector());
-        subpath.append(".json");
-        final String testMethodSelector = testParameters.getTestMethodSelector();
-        if(testMethodSelector != null && testMethodSelector.length() > 0) {
-            subpath.append("/");
-            subpath.append(testMethodSelector);
-        }
-        final Request r = builder.buildPostRequest(subpath.toString());
-        executor.execute(r)
-        .assertStatus(200)
-        .assertContentType("application/json");
-        
+        testHttpClient = new RemoteTestHttpClient(testParameters.getJunitServletUrl(), true);
+        final RequestExecutor executor = testHttpClient.runTests(
+                testParameters.getTestClassesSelector(),
+                testParameters.getTestMethodSelector(),
+                "json"
+                );
+        executor.assertContentType("application/json");
         final JSONArray json = new JSONArray(new JSONTokener((executor.getContent())));
 
         // Response contains an array of objects identified by 
@@ -111,7 +94,7 @@
         }
         
         log.info("Server-side tests executed at {} with path {}", 
-                testParameters.getJunitServletUrl(), subpath);
+                testParameters.getJunitServletUrl(), testHttpClient.getTestExecutionPath());
         
         // Check that number of tests is as expected
         assertEquals("Expecting " + testParameters.getExpectedNumberOfTests() + " tests",