diff --git a/extension/src/client/TaskServerClient.ts b/extension/src/client/TaskServerClient.ts index 55e8df06c..9587ad16a 100644 --- a/extension/src/client/TaskServerClient.ts +++ b/extension/src/client/TaskServerClient.ts @@ -10,6 +10,7 @@ import { GradleBuild, Environment, GradleConfig, + GradleTestEvent, RunBuildRequest, RunBuildReply, CancelBuildRequest, @@ -230,7 +231,8 @@ export class TaskServerClient implements vscode.Disposable { showOutputColors = true, additionalToolOptions = "", title?: string, - location?: vscode.ProgressLocation + location?: vscode.ProgressLocation, + onTestEvent?: (event: GradleTestEvent) => void ): Promise { await this.connectWaiter.wait(); this.statusBarItem.hide(); @@ -260,6 +262,7 @@ export class TaskServerClient implements vscode.Disposable { request.setJavaDebugPort(javaDebugPort); request.setInput(input); request.setAdditionalToolOptions(additionalToolOptions); + request.setStreamTestEvents(Boolean(onTestEvent)); if (javaDebugPort > 0) { const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(projectFolder)); @@ -285,6 +288,11 @@ export class TaskServerClient implements vscode.Disposable { case RunBuildReply.KindCase.CANCELLED: this.handleRunBuildCancelled(args, runBuildReply.getCancelled()!, task); break; + case RunBuildReply.KindCase.TEST_EVENT: + if (onTestEvent) { + onTestEvent(runBuildReply.getTestEvent()!); + } + break; } }) .on("error", reject) diff --git a/gradle-server/src/main/java/com/github/badsyntax/gradle/GradleBuildRunner.java b/gradle-server/src/main/java/com/github/badsyntax/gradle/GradleBuildRunner.java index 8c2335274..4e1186eef 100644 --- a/gradle-server/src/main/java/com/github/badsyntax/gradle/GradleBuildRunner.java +++ b/gradle-server/src/main/java/com/github/badsyntax/gradle/GradleBuildRunner.java @@ -79,9 +79,11 @@ public class GradleBuildRunner { private ProgressListener progressListener; private Boolean javaDebugCleanOutputCache; private String additionalToolOptions; + private Boolean streamTestEvents; public GradleBuildRunner(String projectDir, List args, GradleConfig gradleConfig, String cancellationKey, - Boolean colorOutput, int javaDebugPort, Boolean javaDebugCleanOutputCache, String additionalToolOptions) { + Boolean colorOutput, int javaDebugPort, Boolean javaDebugCleanOutputCache, String additionalToolOptions, + Boolean streamTestEvents) { this.projectDir = projectDir; this.args = args; this.gradleConfig = gradleConfig; @@ -90,6 +92,13 @@ public GradleBuildRunner(String projectDir, List args, GradleConfig grad this.javaDebugPort = javaDebugPort; this.javaDebugCleanOutputCache = javaDebugCleanOutputCache; this.additionalToolOptions = additionalToolOptions; + this.streamTestEvents = streamTestEvents; + } + + public GradleBuildRunner(String projectDir, List args, GradleConfig gradleConfig, String cancellationKey, + Boolean colorOutput, int javaDebugPort, Boolean javaDebugCleanOutputCache, String additionalToolOptions) { + this(projectDir, args, gradleConfig, cancellationKey, colorOutput, javaDebugPort, javaDebugCleanOutputCache, + additionalToolOptions, false); } public GradleBuildRunner(String projectDir, List args, GradleConfig gradleConfig, String cancellationKey) { @@ -133,6 +142,9 @@ private void runBuild(ProjectConnection connection) throws GradleBuildRunnerExce progressEvents.add(OperationType.PROJECT_CONFIGURATION); progressEvents.add(OperationType.TASK); progressEvents.add(OperationType.TRANSFORM); + if (Boolean.TRUE.equals(streamTestEvents)) { + progressEvents.add(OperationType.TEST); + } CancellationToken cancellationToken = GradleBuildCancellation.buildToken(cancellationKey); diff --git a/gradle-server/src/main/java/com/github/badsyntax/gradle/handlers/RunBuildHandler.java b/gradle-server/src/main/java/com/github/badsyntax/gradle/handlers/RunBuildHandler.java index 2fb6bccdf..7f17feba5 100644 --- a/gradle-server/src/main/java/com/github/badsyntax/gradle/handlers/RunBuildHandler.java +++ b/gradle-server/src/main/java/com/github/badsyntax/gradle/handlers/RunBuildHandler.java @@ -4,6 +4,7 @@ import com.github.badsyntax.gradle.Cancelled; import com.github.badsyntax.gradle.ErrorMessageBuilder; import com.github.badsyntax.gradle.GradleBuildRunner; +import com.github.badsyntax.gradle.GradleTestEvent; import com.github.badsyntax.gradle.Output; import com.github.badsyntax.gradle.Progress; import com.github.badsyntax.gradle.RunBuildReply; @@ -15,11 +16,25 @@ import io.grpc.stub.StreamObserver; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import org.gradle.tooling.BuildCancelledException; import org.gradle.tooling.BuildException; +import org.gradle.tooling.Failure; import org.gradle.tooling.UnsupportedVersionException; import org.gradle.tooling.events.ProgressEvent; import org.gradle.tooling.events.ProgressListener; +import org.gradle.tooling.events.test.Destination; +import org.gradle.tooling.events.test.JvmTestOperationDescriptor; +import org.gradle.tooling.events.test.TestFailureResult; +import org.gradle.tooling.events.test.TestFinishEvent; +import org.gradle.tooling.events.test.TestOperationDescriptor; +import org.gradle.tooling.events.test.TestOutputDescriptor; +import org.gradle.tooling.events.test.TestOutputEvent; +import org.gradle.tooling.events.test.TestSkippedResult; +import org.gradle.tooling.events.test.TestStartEvent; +import org.gradle.tooling.events.test.TestSuccessResult; import org.gradle.tooling.exceptions.UnsupportedBuildArgumentException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,29 +47,28 @@ public class RunBuildHandler { private ProgressListener progressListener; private ByteBufferOutputStream standardOutputListener; private ByteBufferOutputStream standardErrorListener; + private final Object responseLock = new Object(); public RunBuildHandler(RunBuildRequest req, StreamObserver responseObserver) { this.req = req; this.responseObserver = responseObserver; this.progressListener = (ProgressEvent event) -> { - synchronized (RunBuildHandler.class) { + if (req.getStreamTestEvents() && isTestEvent(event)) { + replyWithTestEvent(event); + } else { replyWithProgress(event); } }; this.standardOutputListener = new ByteBufferOutputStream() { @Override public void onFlush(byte[] bytes) { - synchronized (RunBuildHandler.class) { - replyWithStandardOutput(bytes); - } + replyWithStandardOutput(bytes); } }; this.standardErrorListener = new ByteBufferOutputStream() { @Override public void onFlush(byte[] bytes) { - synchronized (RunBuildHandler.class) { - replyWithStandardError(bytes); - } + replyWithStandardError(bytes); } }; } @@ -62,7 +76,7 @@ public void onFlush(byte[] bytes) { public void run() { GradleBuildRunner gradleRunner = new GradleBuildRunner(req.getProjectDir(), req.getArgsList(), req.getGradleConfig(), req.getCancellationKey(), req.getShowOutputColors(), req.getJavaDebugPort(), - req.getJavaDebugCleanOutputCache(), req.getAdditionalToolOptions()); + req.getJavaDebugCleanOutputCache(), req.getAdditionalToolOptions(), req.getStreamTestEvents()); gradleRunner.setProgressListener(progressListener).setStandardOutputStream(standardOutputListener) .setStandardErrorStream(standardErrorListener); @@ -73,10 +87,10 @@ public void run() { try { gradleRunner.run(); replyWithSuccess(); - responseObserver.onCompleted(); + completeResponse(); } catch (BuildCancelledException e) { replyWithCancelled(e); - responseObserver.onCompleted(); + completeResponse(); } catch (BuildException | UnsupportedVersionException | UnsupportedBuildArgumentException | IllegalStateException | IOException | GradleBuildRunnerException e) { logger.error(e.getMessage()); @@ -85,36 +99,152 @@ public void run() { } public void replyWithCancelled(BuildCancelledException e) { - responseObserver.onNext(RunBuildReply.newBuilder() + sendReply(RunBuildReply.newBuilder() .setCancelled(Cancelled.newBuilder().setMessage(e.getMessage()).setProjectDir(req.getProjectDir())) .build()); } public void replyWithError(Exception e) { - responseObserver.onError(ErrorMessageBuilder.build(e)); + synchronized (responseLock) { + responseObserver.onError(ErrorMessageBuilder.build(e)); + } } public void replyWithSuccess() { - responseObserver.onNext(RunBuildReply.newBuilder() + sendReply(RunBuildReply.newBuilder() .setRunBuildResult(RunBuildResult.newBuilder().setMessage("Successfully run build")).build()); } private void replyWithProgress(ProgressEvent progressEvent) { - responseObserver.onNext(RunBuildReply.newBuilder() + sendReply(RunBuildReply.newBuilder() .setProgress(Progress.newBuilder().setMessage(progressEvent.getDisplayName())).build()); } + private void replyWithTestEvent(ProgressEvent progressEvent) { + sendReply(RunBuildReply.newBuilder().setTestEvent(convertTestEvent(progressEvent)).build()); + } + + private void sendReply(RunBuildReply reply) { + synchronized (responseLock) { + responseObserver.onNext(reply); + } + } + + private void completeResponse() { + synchronized (responseLock) { + responseObserver.onCompleted(); + } + } + + private static boolean isTestEvent(ProgressEvent event) { + return event instanceof TestStartEvent || event instanceof TestFinishEvent || event instanceof TestOutputEvent; + } + + private static GradleTestEvent convertTestEvent(ProgressEvent event) { + GradleTestEvent.Builder builder = GradleTestEvent.newBuilder().setEventTime(event.getEventTime()) + .setDisplayName(event.getDisplayName()); + + if (event instanceof TestOutputEvent) { + TestOutputDescriptor descriptor = ((TestOutputEvent) event).getDescriptor(); + fillDescriptor(builder, descriptor); + builder.setEventType(GradleTestEvent.EventType.OUTPUT).setMessage(descriptor.getMessage()); + if (Destination.StdOut.equals(descriptor.getDestination())) { + builder.setOutputDestination(GradleTestEvent.OutputDestination.STDOUT); + } else if (Destination.StdErr.equals(descriptor.getDestination())) { + builder.setOutputDestination(GradleTestEvent.OutputDestination.STDERR); + } + return builder.build(); + } + + if (event instanceof TestStartEvent) { + builder.setEventType(GradleTestEvent.EventType.STARTED); + } else if (event instanceof TestFinishEvent) { + TestFinishEvent finishEvent = (TestFinishEvent) event; + if (finishEvent.getResult() instanceof TestSuccessResult) { + builder.setEventType(GradleTestEvent.EventType.SUCCEEDED); + } else if (finishEvent.getResult() instanceof TestSkippedResult) { + builder.setEventType(GradleTestEvent.EventType.SKIPPED); + } else if (finishEvent.getResult() instanceof TestFailureResult) { + builder.setEventType(GradleTestEvent.EventType.FAILED); + String failureMessage = failureMessage((TestFailureResult) finishEvent.getResult()); + if (!failureMessage.isEmpty()) { + builder.setMessage(failureMessage); + } + } + } + + fillDescriptor(builder, ((org.gradle.tooling.events.test.TestProgressEvent) event).getDescriptor()); + return builder.build(); + } + + private static void fillDescriptor(GradleTestEvent.Builder builder, + org.gradle.tooling.events.OperationDescriptor descriptor) { + builder.setId(descriptorPath(descriptor)).setName(descriptor.getName()) + .setDisplayName(descriptor.getDisplayName()); + if (descriptor.getParent() != null) { + builder.setParentId(descriptorPath(descriptor.getParent())); + } + if (descriptor instanceof TestOperationDescriptor) { + builder.setDisplayName(((TestOperationDescriptor) descriptor).getTestDisplayName()); + } + if (descriptor instanceof JvmTestOperationDescriptor) { + JvmTestOperationDescriptor jvmDescriptor = (JvmTestOperationDescriptor) descriptor; + if (jvmDescriptor.getClassName() != null) { + builder.setClassName(jvmDescriptor.getClassName()); + } + if (jvmDescriptor.getMethodName() != null) { + builder.setMethodName(jvmDescriptor.getMethodName()); + } + if (jvmDescriptor.getSuiteName() != null) { + builder.setSuiteName(jvmDescriptor.getSuiteName()); + } + } + } + + private static String descriptorPath(org.gradle.tooling.events.OperationDescriptor descriptor) { + List names = new ArrayList<>(); + org.gradle.tooling.events.OperationDescriptor current = descriptor; + while (current != null) { + names.add(current.getName()); + current = current.getParent(); + } + Collections.reverse(names); + return String.join("/", names); + } + private void replyWithStandardOutput(byte[] bytes) { ByteString byteString = ByteString.copyFrom(bytes); - responseObserver.onNext(RunBuildReply.newBuilder() + sendReply(RunBuildReply.newBuilder() .setOutput(Output.newBuilder().setOutputType(Output.OutputType.STDOUT).setOutputBytes(byteString)) .build()); } private void replyWithStandardError(byte[] bytes) { ByteString byteString = ByteString.copyFrom(bytes); - responseObserver.onNext(RunBuildReply.newBuilder() + sendReply(RunBuildReply.newBuilder() .setOutput(Output.newBuilder().setOutputType(Output.OutputType.STDERR).setOutputBytes(byteString)) .build()); } + + private static String failureMessage(TestFailureResult failureResult) { + List messages = new ArrayList<>(); + for (Failure failure : failureResult.getFailures()) { + collectFailureMessages(failure, messages); + } + return String.join("\n---\n", messages); + } + + private static void collectFailureMessages(Failure failure, List messages) { + String message = failure.getMessage(); + String description = failure.getDescription(); + if (!Strings.isNullOrEmpty(message)) { + messages.add(message); + } + if (!Strings.isNullOrEmpty(description) && !description.equals(message)) { + messages.add(description); + } + for (Failure cause : failure.getCauses()) { + collectFailureMessages(cause, messages); + } + } } diff --git a/gradle-server/src/test/java/com/github/badsyntax/gradle/GradleServerTest.java b/gradle-server/src/test/java/com/github/badsyntax/gradle/GradleServerTest.java index c2a3fcd60..eb19a9fa8 100644 --- a/gradle-server/src/test/java/com/github/badsyntax/gradle/GradleServerTest.java +++ b/gradle-server/src/test/java/com/github/badsyntax/gradle/GradleServerTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.powermock.api.mockito.PowerMockito.*; @@ -24,7 +25,14 @@ import java.util.HashMap; import java.util.List; import java.util.Set; +import org.gradle.tooling.Failure; +import org.gradle.tooling.events.OperationDescriptor; import org.gradle.tooling.events.OperationType; +import org.gradle.tooling.events.ProgressListener; +import org.gradle.tooling.events.test.JvmTestOperationDescriptor; +import org.gradle.tooling.events.test.TestFailureResult; +import org.gradle.tooling.events.test.TestFinishEvent; +import org.gradle.tooling.events.test.TestStartEvent; import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -475,4 +483,105 @@ public void runBuild_shouldStreamCorrectProgressEvents() throws IOException { assertTrue(onAddProgressListener.getValue().contains(OperationType.TASK)); assertTrue(onAddProgressListener.getValue().contains(OperationType.TRANSFORM)); } + + @Test + public void runBuild_shouldStreamTestProgressEventsWhenRequested() throws IOException { + StreamObserver mockResponseObserver = (StreamObserver) mock(StreamObserver.class); + + RunBuildRequest req = RunBuildRequest.newBuilder().setProjectDir(mockProjectDir.getAbsolutePath().toString()) + .addAllArgs(mockBuildArgs).setGradleConfig(GradleConfig.newBuilder().setWrapperEnabled(true)) + .setStreamTestEvents(true).build(); + + ArgumentCaptor> onAddProgressListener = ArgumentCaptor.forClass(Set.class); + + OperationDescriptor parentDescriptor = mock(OperationDescriptor.class); + when(parentDescriptor.getName()).thenReturn("test"); + when(parentDescriptor.getDisplayName()).thenReturn("test"); + + JvmTestOperationDescriptor descriptor = mock(JvmTestOperationDescriptor.class); + when(descriptor.getName()).thenReturn("testMethod"); + when(descriptor.getDisplayName()).thenReturn("testMethod()"); + when(descriptor.getTestDisplayName()).thenReturn("testMethod()"); + when(descriptor.getClassName()).thenReturn("com.example.FooTest"); + when(descriptor.getMethodName()).thenReturn("testMethod"); + when(descriptor.getParent()).thenReturn(parentDescriptor); + + TestStartEvent event = mock(TestStartEvent.class); + when(event.getEventTime()).thenReturn(123L); + when(event.getDisplayName()).thenReturn("testMethod() started"); + when(event.getDescriptor()).thenReturn(descriptor); + + when(mockBuildLauncher.addProgressListener(any(ProgressListener.class), + ArgumentMatchers.>any())).thenAnswer(invocation -> { + ProgressListener listener = invocation.getArgument(0); + listener.statusChanged(event); + return mockBuildLauncher; + }); + + stub.runBuild(req, mockResponseObserver); + verify(mockResponseObserver, never()).onError(any()); + verify(mockBuildLauncher).addProgressListener(any(ProgressListener.class), onAddProgressListener.capture()); + + assertEquals(4, onAddProgressListener.getValue().size()); + assertTrue(onAddProgressListener.getValue().contains(OperationType.PROJECT_CONFIGURATION)); + assertTrue(onAddProgressListener.getValue().contains(OperationType.TASK)); + assertTrue(onAddProgressListener.getValue().contains(OperationType.TRANSFORM)); + assertTrue(onAddProgressListener.getValue().contains(OperationType.TEST)); + + ArgumentCaptor replyCaptor = ArgumentCaptor.forClass(RunBuildReply.class); + verify(mockResponseObserver, times(2)).onNext(replyCaptor.capture()); + RunBuildReply testEventReply = replyCaptor.getAllValues().get(0); + assertEquals(RunBuildReply.KindCase.TEST_EVENT, testEventReply.getKindCase()); + assertEquals(GradleTestEvent.EventType.STARTED, testEventReply.getTestEvent().getEventType()); + assertEquals("test/testMethod", testEventReply.getTestEvent().getId()); + assertEquals("test", testEventReply.getTestEvent().getParentId()); + assertEquals("com.example.FooTest", testEventReply.getTestEvent().getClassName()); + assertEquals("testMethod", testEventReply.getTestEvent().getMethodName()); + } + + @Test + public void runBuild_shouldIncludeFailureMessageInFailedTestEvent() throws IOException { + StreamObserver mockResponseObserver = (StreamObserver) mock(StreamObserver.class); + + RunBuildRequest req = RunBuildRequest.newBuilder().setProjectDir(mockProjectDir.getAbsolutePath().toString()) + .addAllArgs(mockBuildArgs).setGradleConfig(GradleConfig.newBuilder().setWrapperEnabled(true)) + .setStreamTestEvents(true).build(); + + JvmTestOperationDescriptor descriptor = mock(JvmTestOperationDescriptor.class); + when(descriptor.getName()).thenReturn("testMethod"); + when(descriptor.getDisplayName()).thenReturn("testMethod()"); + when(descriptor.getTestDisplayName()).thenReturn("testMethod()"); + + Failure failure = mock(Failure.class); + when(failure.getMessage()).thenReturn("expected:<1> but was:<2>"); + when(failure.getDescription()).thenReturn("java.lang.AssertionError: expected:<1> but was:<2>"); + when(failure.getCauses()).thenReturn(List.of()); + + TestFailureResult failureResult = mock(TestFailureResult.class); + doReturn(List.of(failure)).when(failureResult).getFailures(); + + TestFinishEvent event = mock(TestFinishEvent.class); + when(event.getEventTime()).thenReturn(123L); + when(event.getDisplayName()).thenReturn("testMethod() failed"); + when(event.getDescriptor()).thenReturn(descriptor); + when(event.getResult()).thenReturn(failureResult); + + when(mockBuildLauncher.addProgressListener(any(ProgressListener.class), + ArgumentMatchers.>any())).thenAnswer(invocation -> { + ProgressListener listener = invocation.getArgument(0); + listener.statusChanged(event); + return mockBuildLauncher; + }); + + stub.runBuild(req, mockResponseObserver); + verify(mockResponseObserver, never()).onError(any()); + + ArgumentCaptor replyCaptor = ArgumentCaptor.forClass(RunBuildReply.class); + verify(mockResponseObserver, times(2)).onNext(replyCaptor.capture()); + RunBuildReply testEventReply = replyCaptor.getAllValues().get(0); + assertEquals(RunBuildReply.KindCase.TEST_EVENT, testEventReply.getKindCase()); + assertEquals(GradleTestEvent.EventType.FAILED, testEventReply.getTestEvent().getEventType()); + assertEquals("expected:<1> but was:<2>\n---\njava.lang.AssertionError: expected:<1> but was:<2>", + testEventReply.getTestEvent().getMessage()); + } } diff --git a/proto/gradle.proto b/proto/gradle.proto index 84693fee3..240bb90b3 100644 --- a/proto/gradle.proto +++ b/proto/gradle.proto @@ -77,6 +77,7 @@ message RunBuildRequest { bool show_output_colors = 7; bool java_debug_clean_output_cache = 8; string additional_tool_options = 9; + bool stream_test_events = 10; } message RunBuildResult { @@ -89,9 +90,39 @@ message RunBuildReply { Progress progress = 2; Output output = 3; Cancelled cancelled = 4; + GradleTestEvent test_event = 5; } } +message GradleTestEvent { + enum EventType { + UNKNOWN_EVENT_TYPE = 0; + STARTED = 1; + SUCCEEDED = 2; + FAILED = 3; + SKIPPED = 4; + OUTPUT = 5; + } + + enum OutputDestination { + UNKNOWN = 0; + STDOUT = 1; + STDERR = 2; + } + + EventType event_type = 1; + string id = 2; + string parent_id = 3; + string name = 4; + string display_name = 5; + string class_name = 6; + string method_name = 7; + string suite_name = 8; + int64 event_time = 9; + string message = 10; + OutputDestination output_destination = 11; +} + message CancelBuildRequest { string cancellation_key = 1; }