From 45a170abeb9cefabf4a27377137bf1b8a0e741c0 Mon Sep 17 00:00:00 2001 From: epszaw Date: Wed, 17 Sep 2025 11:13:44 +0200 Subject: [PATCH 1/2] Add workflow-wide gh actions permissions (#1187) Co-authored-by: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> --- .github/workflows/labeler.yml | 5 + .github/workflows/labels-verify.yml | 5 + .github/workflows/publish.yml | 3 + .github/workflows/release.yml | 5 + allure-grpc/build.gradle.kts | 6 +- .../io/qameta/allure/grpc/AllureGrpc.java | 442 +++++++++++++----- .../io/qameta/allure/grpc/AllureGrpcTest.java | 274 ++++++++--- allure-grpc/src/test/proto/api.proto | 3 + 8 files changed, 556 insertions(+), 187 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index e0d99fc81..f49976b59 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -3,9 +3,14 @@ name: "Set theme labels" on: - pull_request_target +permissions: + contents: read + jobs: triage: runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - uses: actions/labeler@v4 with: diff --git a/.github/workflows/labels-verify.yml b/.github/workflows/labels-verify.yml index 0c18ecb77..7315a905a 100644 --- a/.github/workflows/labels-verify.yml +++ b/.github/workflows/labels-verify.yml @@ -4,9 +4,14 @@ on: pull_request_target: types: [opened, labeled, unlabeled, synchronize] +permissions: + contents: none + jobs: triage: runs-on: ubuntu-latest + permissions: + pull-requests: read steps: - uses: baev/action-label-verify@main with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f98763d24..97d4d7fbd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,9 @@ on: release: types: [ published ] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e31f9b54..6b08eb0e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,14 @@ on: description: "The next version in . format WITHOUT SNAPSHOT SUFFIX" required: true +permissions: + contents: read + jobs: triage: runs-on: ubuntu-latest + permissions: + contents: write steps: - name: "Check release version" run: | diff --git a/allure-grpc/build.gradle.kts b/allure-grpc/build.gradle.kts index 4bc8d3567..6f1933f95 100644 --- a/allure-grpc/build.gradle.kts +++ b/allure-grpc/build.gradle.kts @@ -9,13 +9,15 @@ description = "Allure gRPC Integration" val agent: Configuration by configurations.creating val grpcVersion = "1.57.2" -val protobufVersion = "4.27.3" +val protobufVersion = "4.32.1" +val jacksonVersion = "2.17.2" dependencies { agent("org.aspectj:aspectjweaver") api(project(":allure-attachments")) + compileOnly("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion") compileOnly("com.google.protobuf:protobuf-java-util:$protobufVersion") - compileOnly("io.grpc:grpc-core:$grpcVersion") + compileOnly("io.grpc:grpc-api:$grpcVersion") testImplementation("com.google.protobuf:protobuf-java-util:$protobufVersion") testImplementation("com.google.protobuf:protobuf-java:$protobufVersion") testImplementation("io.grpc:grpc-core:$grpcVersion") diff --git a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java index 35fc4977e..a29258d04 100644 --- a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java +++ b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java @@ -27,42 +27,56 @@ import io.grpc.Metadata; import io.grpc.MethodDescriptor; import io.qameta.allure.Allure; +import io.qameta.allure.AllureLifecycle; import io.qameta.allure.attachment.AttachmentData; -import io.qameta.allure.attachment.AttachmentProcessor; -import io.qameta.allure.attachment.DefaultAttachmentProcessor; +import io.qameta.allure.attachment.AttachmentRenderer; import io.qameta.allure.attachment.FreemarkerAttachmentRenderer; +import io.qameta.allure.model.Attachment; import io.qameta.allure.model.Status; import io.qameta.allure.model.StepResult; -import io.qameta.allure.util.ResultsUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import static java.util.Objects.requireNonNull; - -/** - * Allure interceptor logger for gRPC. - * - * @author dtuchs (Dmitry Tuchs). - */ -@SuppressWarnings({ - "checkstyle:ClassFanOutComplexity", - "checkstyle:AnonInnerLength", - "checkstyle:JavaNCSS" -}) +@SuppressWarnings("all") public class AllureGrpc implements ClientInterceptor { private static final Logger LOGGER = LoggerFactory.getLogger(AllureGrpc.class); - private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer(); + private static final String UNKNOWN = "unknown"; + private static final JsonFormat.Printer GRPC_TO_JSON_PRINTER = JsonFormat.printer(); private String requestTemplatePath = "grpc-request.ftl"; private String responseTemplatePath = "grpc-response.ftl"; - private boolean markStepFailedOnNonZeroCode = true; - private boolean interceptResponseMetadata; + private volatile boolean markStepFailedOnNonZeroCode = true; + private volatile boolean interceptResponseMetadata; + + private AllureLifecycle lifecycle; + + private static final ConcurrentLinkedQueue> PENDING_COMPLETIONS = + new ConcurrentLinkedQueue<>(); + + public AllureGrpc() { + this(Allure.getLifecycle()); + } + + public AllureGrpc(final AllureLifecycle allureLifecycle) { + this.lifecycle = allureLifecycle; + } + + public void setLifecycle(final AllureLifecycle allureLifecycle) { + this.lifecycle = allureLifecycle; + } public AllureGrpc setRequestTemplate(final String templatePath) { this.requestTemplatePath = templatePath; @@ -84,123 +98,299 @@ public AllureGrpc interceptResponseMetadata(final boolean value) { return this; } + public static void await() { + CompletableFuture completion; + while ((completion = PENDING_COMPLETIONS.poll()) != null) { + try { + completion.join(); + } catch (RuntimeException runtimeException) { + LOGGER.warn("Await interrupted with exception", runtimeException); + } + } + } + @Override - public ClientCall interceptCall(final MethodDescriptor method, - final CallOptions callOptions, - final Channel next) { - final AttachmentProcessor processor = new DefaultAttachmentProcessor(); + public ClientCall interceptCall( + final MethodDescriptor methodDescriptor, + final CallOptions callOptions, + final Channel nextChannel + ) { + final AllureLifecycle current = Allure.getLifecycle(); + final String parent = current.getCurrentTestCaseOrStep().orElse(null); + final String stepUuid = UUID.randomUUID().toString(); - return new ForwardingClientCall.SimpleForwardingClientCall( - next.newCall(method, callOptions.withoutWaitForReady())) { + final List clientMessages = Collections.synchronizedList(new ArrayList<>()); + final List serverMessages = Collections.synchronizedList(new ArrayList<>()); + final Map initialHeaders = new LinkedHashMap<>(); + final Map trailers = new LinkedHashMap<>(); + final CompletableFuture completion = new CompletableFuture<>(); + PENDING_COMPLETIONS.add(completion); - private String stepUuid; - private final List parsedResponses = new ArrayList<>(); + final String stepName = buildStepName(nextChannel, methodDescriptor); + if (parent != null) { + current.startStep(parent, stepUuid, new StepResult().setName(stepName)); + } else { + current.startStep(stepUuid, new StepResult().setName(stepName)); + } - @Override - public void sendMessage(final T message) { - stepUuid = UUID.randomUUID().toString(); - Allure.getLifecycle().startStep(stepUuid, (new StepResult()).setName( - "Send gRPC request to " - + next.authority() - + trimGrpcMethodName(method.getFullMethodName()) - )); - try { - final GrpcRequestAttachment rpcRequestAttach = GrpcRequestAttachment.Builder - .create("gRPC request", method.getFullMethodName()) - .setBody(JSON_PRINTER.print((MessageOrBuilder) message)) - .build(); - processor.addAttachment(rpcRequestAttach, new FreemarkerAttachmentRenderer(requestTemplatePath)); - super.sendMessage(message); - } catch (InvalidProtocolBufferException e) { - LOGGER.warn("Can`t parse gRPC request", e); - } catch (Throwable e) { - Allure.getLifecycle().updateStep(stepResult -> - stepResult.setStatus(ResultsUtils.getStatus(e).orElse(Status.BROKEN)) - .setStatusDetails(ResultsUtils.getStatusDetails(e).orElse(null)) - ); - Allure.getLifecycle().stopStep(stepUuid); - stepUuid = null; - } - } + final StepContext ctx = new StepContext<>( + stepUuid, methodDescriptor, current, clientMessages, serverMessages, initialHeaders, trailers, completion + ); + return new ForwardingClientCall.SimpleForwardingClientCall( + nextChannel.newCall(methodDescriptor, callOptions) + ) { @Override - public void start(final Listener responseListener, final Metadata headers) { - final ClientCall.Listener listener = new ForwardingClientCallListener() { - @Override - protected Listener delegate() { - return responseListener; + public void start(final Listener rl, final Metadata rh) { + final Listener l = new ForwardingClientCallListener() { + @Override protected Listener delegate() { return rl; } + @Override public void onHeaders(final Metadata h) { + handleHeaders(h, ctx.initialHeaders); super.onHeaders(h); } - - @Override - public void onClose(final io.grpc.Status status, final Metadata trailers) { - GrpcResponseAttachment.Builder responseAttachmentBuilder = null; - - if (parsedResponses.size() == 1) { - responseAttachmentBuilder = GrpcResponseAttachment.Builder - .create("gRPC response") - .setBody(parsedResponses.iterator().next()); - } else if (parsedResponses.size() > 1) { - responseAttachmentBuilder = GrpcResponseAttachment.Builder - .create("gRPC response (collection of elements from Server stream)") - .setBody("[" + String.join(",\n", parsedResponses) + "]"); - } - if (!status.isOk()) { - String description = status.getDescription(); - if (description == null) { - description = "No description provided"; - } - responseAttachmentBuilder = GrpcResponseAttachment.Builder - .create(status.getCode().name()) - .setStatus(description); - } - - requireNonNull(responseAttachmentBuilder).setStatus(status.toString()); - if (interceptResponseMetadata) { - for (String key : headers.keys()) { - requireNonNull(responseAttachmentBuilder).setMetadata( - key, - headers.get(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)) - ); - } - } - processor.addAttachment( - requireNonNull(responseAttachmentBuilder).build(), - new FreemarkerAttachmentRenderer(responseTemplatePath) - ); - - if (status.isOk() || !markStepFailedOnNonZeroCode) { - Allure.getLifecycle().updateStep(stepUuid, step -> step.setStatus(Status.PASSED)); - } else { - Allure.getLifecycle().updateStep(stepUuid, step -> step.setStatus(Status.FAILED)); - } - Allure.getLifecycle().stopStep(stepUuid); - stepUuid = null; - super.onClose(status, trailers); + @Override public void onMessage(final R m) { + handleServerMessage(m, ctx.serverMessages); super.onMessage(m); } - - @Override - public void onMessage(final A message) { - try { - parsedResponses.add(JSON_PRINTER.print((MessageOrBuilder) message)); - super.onMessage(message); - } catch (InvalidProtocolBufferException e) { - LOGGER.warn("Can`t parse gRPC response", e); - } catch (Throwable e) { - Allure.getLifecycle().updateStep(step -> - step.setStatus(ResultsUtils.getStatus(e).orElse(Status.BROKEN)) - .setStatusDetails(ResultsUtils.getStatusDetails(e).orElse(null)) - ); - Allure.getLifecycle().stopStep(stepUuid); - stepUuid = null; - } + @Override public void onClose(final io.grpc.Status s, final Metadata t) { + handleClose(s, t, ctx); super.onClose(s, t); } }; - super.start(listener, headers); + super.start(l, rh); } - - private String trimGrpcMethodName(final String source) { - return source.substring(source.lastIndexOf('/')); + @Override + public void sendMessage(final T m) { + handleClientMessage(m, ctx.clientMessages); + super.sendMessage(m); } }; } + + private static final class StepContext { + final String stepUuid; + final MethodDescriptor method; + final AllureLifecycle lifecycleRef; + final List clientMessages; + final List serverMessages; + final Map initialHeaders; + final Map trailers; + final CompletableFuture done; + + StepContext( + final String stepUuid, + final MethodDescriptor method, + final AllureLifecycle lifecycleRef, + final List clientMessages, + final List serverMessages, + final Map initialHeaders, + final Map trailers, + final CompletableFuture done + ) { + this.stepUuid = stepUuid; + this.method = method; + this.lifecycleRef = lifecycleRef; + this.clientMessages = clientMessages; + this.serverMessages = serverMessages; + this.initialHeaders = initialHeaders; + this.trailers = trailers; + this.done = done; + } + } + + private void handleHeaders(final Metadata headers, final Map dst) { + try { + if (interceptResponseMetadata && headers != null) { + copyAsciiResponseMetadata(headers, dst); + } + } catch (Throwable t) { + LOGGER.warn("Failed to capture initial response headers", t); + } + } + + private void handleClientMessage(final T message, final List dst) { + try { + dst.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); + } catch (InvalidProtocolBufferException e) { + LOGGER.error("Could not serialize gRPC request message to JSON", e); + } catch (Throwable t) { + LOGGER.error("Unexpected error while serializing gRPC request message", t); + } + } + + private void handleServerMessage(final R message, final List dst) { + try { + dst.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); + } catch (InvalidProtocolBufferException e) { + LOGGER.error("Could not serialize gRPC response message to JSON", e); + } catch (Throwable t) { + LOGGER.error("Unexpected error while serializing gRPC response message", t); + } + } + + private void handleClose( + final io.grpc.Status status, + final Metadata trailers, + final StepContext ctx + ) { + try { + if (interceptResponseMetadata && trailers != null) { + copyAsciiResponseMetadata(trailers, ctx.trailers); + } + attachRequestIfPresent(ctx.stepUuid, ctx.method, ctx.clientMessages, ctx.lifecycleRef); + attachResponse(ctx.stepUuid, ctx.serverMessages, status, ctx.initialHeaders, ctx.trailers, ctx.lifecycleRef); + ctx.lifecycleRef.updateStep(ctx.stepUuid, s -> s.setStatus(convertStatus(status))); + } catch (Throwable t) { + LOGGER.error("Failed to finalize Allure step for gRPC call", t); + ctx.lifecycleRef.updateStep(ctx.stepUuid, s -> s.setStatus(Status.BROKEN)); + } finally { + stopStepSafely(ctx.lifecycleRef, ctx.stepUuid); + ctx.done.complete(null); + PENDING_COMPLETIONS.remove(ctx.done); + } + } + + private void attachRequestIfPresent( + final String stepUuid, + final MethodDescriptor method, + final List clientMessages, + final AllureLifecycle lifecycleRef + ) { + final String body = toJsonBody(clientMessages); + if (body == null) { + return; + } + final String name = clientMessages.size() > 1 + ? "gRPC request (collection of elements from Client stream)" + : "gRPC request"; + final GrpcRequestAttachment req = GrpcRequestAttachment.Builder + .create(name, method.getFullMethodName()) + .setBody(body) + .build(); + addRenderedAttachmentToStep(stepUuid, req.getName(), req, requestTemplatePath, lifecycleRef); + } + + private void attachResponse( + final String stepUuid, + final List serverMessages, + final io.grpc.Status status, + final Map initialHeaders, + final Map trailers, + final AllureLifecycle lifecycleRef + ) { + final String body = toJsonBody(serverMessages); + final String name = serverMessages.size() > 1 + ? "gRPC response (collection of elements from Server stream)" + : "gRPC response"; + + final Map meta = new LinkedHashMap<>(); + if (interceptResponseMetadata) { + meta.putAll(initialHeaders); + meta.putAll(trailers); + } + + final GrpcResponseAttachment.Builder b = GrpcResponseAttachment.Builder + .create(name) + .setStatus(status.toString()); + if (body != null) { + b.setBody(body); + } + if (!meta.isEmpty()) { + b.addMetadata(meta); + } + final GrpcResponseAttachment res = b.build(); + addRenderedAttachmentToStep(stepUuid, res.getName(), res, responseTemplatePath, lifecycleRef); + } + + private void stopStepSafely(final AllureLifecycle lc, final String stepUuid) { + try { + lc.stopStep(stepUuid); + } catch (Throwable t) { + LOGGER.warn("Failed to stop Allure step {}", stepUuid, t); + } + } + + private Status convertStatus(final io.grpc.Status grpcStatus) { + if (grpcStatus.isOk() || !markStepFailedOnNonZeroCode) { + return Status.PASSED; + } + return Status.FAILED; + } + + private static String buildStepName( + final Channel channel, + final MethodDescriptor methodDescriptor + ) { + final String authority = channel != null ? channel.authority() : null; + final String safeAuthority = authority != null ? authority : UNKNOWN; + final String type = toSnakeCase(methodDescriptor.getType()); + return "Send " + type + " gRPC request to " + safeAuthority + "/" + methodDescriptor.getFullMethodName(); + } + + private static String toSnakeCase(final MethodDescriptor.MethodType methodType) { + if (methodType == null) { + return UNKNOWN; + } + return methodType.name().toLowerCase(Locale.ROOT); + } + + private void addRenderedAttachmentToStep( + final String stepUuid, + final String attachmentName, + final AttachmentData data, + final String templatePath, + final AllureLifecycle lifecycleRef + ) { + final AttachmentRenderer renderer = new FreemarkerAttachmentRenderer(templatePath); + final io.qameta.allure.attachment.AttachmentContent content; + try { + content = renderer.render(data); + } catch (Throwable t) { + LOGGER.warn("Could not render attachment '{}' using template '{}'", attachmentName, templatePath, t); + return; + } + if (content == null || content.getContent() == null) { + LOGGER.warn("Rendered attachment '{}' is empty; skipping", attachmentName); + return; + } + String ext = content.getFileExtension(); + if (ext == null || ext.isEmpty()) { + ext = ".html"; + } + final String source = UUID.randomUUID() + ext; + lifecycleRef.updateStep( + stepUuid, + s -> s.getAttachments().add( + new Attachment() + .setName(attachmentName) + .setSource(source) + .setType(content.getContentType() != null ? content.getContentType() : "text/html") + ) + ); + lifecycleRef.writeAttachment(source, new ByteArrayInputStream(content.getContent().getBytes(StandardCharsets.UTF_8))); + } + + private static String toJsonBody(final List items) { + if (items == null || items.isEmpty()) { + return null; + } + if (items.size() == 1) { + return items.get(0); + } + final String joined = String.join(",\n", items); + return "[" + joined + "]"; + } + + private static void copyAsciiResponseMetadata(final Metadata source, final Map target) { + for (String key : source.keys()) { + if (key == null) { + continue; + } + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + continue; + } + final Metadata.Key k = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + final String v = source.get(k); + if (v != null) { + target.put(key, v); + } + } + } } diff --git a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java index 6c83b9f03..03865ac6b 100644 --- a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java +++ b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java @@ -19,6 +19,7 @@ import io.grpc.ManagedChannelBuilder; import io.grpc.Status; import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; import io.qameta.allure.model.Attachment; import io.qameta.allure.model.StepResult; import io.qameta.allure.test.AllureResults; @@ -29,135 +30,290 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import java.util.ArrayList; import java.util.Iterator; +import java.util.List; import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.grpcmock.GrpcMock.bidiStreamingMethod; +import static org.grpcmock.GrpcMock.clientStreamingMethod; import static org.grpcmock.GrpcMock.serverStreamingMethod; import static org.grpcmock.GrpcMock.unaryMethod; -/** - * @author dtuchs (Dmitry Tuchs). - */ @ExtendWith(GrpcMockExtension.class) class AllureGrpcTest { private static final String RESPONSE_MESSAGE = "Hello world!"; - private ManagedChannel channel; - private TestServiceGrpc.TestServiceBlockingStub blockingStub; + private ManagedChannel managedChannel; + private TestServiceGrpc.TestServiceBlockingStub blockingServiceStub; @BeforeEach void configureMock() { - channel = ManagedChannelBuilder.forAddress("localhost", GrpcMock.getGlobalPort()) - .usePlaintext() - .build(); - blockingStub = TestServiceGrpc.newBlockingStub(channel) - .withInterceptors(new AllureGrpc()); + managedChannel = ManagedChannelBuilder.forAddress("localhost", GrpcMock.getGlobalPort()) + .usePlaintext() + .build(); + + blockingServiceStub = TestServiceGrpc.newBlockingStub(managedChannel) + .withInterceptors(new AllureGrpc()); GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()) - .willReturn(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build())); + .willReturn(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build())); + GrpcMock.stubFor(serverStreamingMethod(TestServiceGrpc.getCalculateServerStreamMethod()) - .willReturn(asList( - Response.newBuilder().setMessage(RESPONSE_MESSAGE).build(), - Response.newBuilder().setMessage(RESPONSE_MESSAGE).build() - ))); + .willReturn(asList( + Response.newBuilder().setMessage(RESPONSE_MESSAGE).build(), + Response.newBuilder().setMessage(RESPONSE_MESSAGE).build() + ))); + + GrpcMock.stubFor(clientStreamingMethod(TestServiceGrpc.getCalculateClientStreamMethod()) + .willReturn(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build())); + + GrpcMock.stubFor(bidiStreamingMethod(TestServiceGrpc.getCalculateBidiStreamMethod()) + .willProxyTo(responseObserver -> new StreamObserver<>() { + @Override + public void onNext(Request request) { + responseObserver.onNext(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build()); + } + + @Override + public void onError(Throwable throwable) { + } + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + })); } @AfterEach void shutdownChannel() { - Optional.ofNullable(channel).ifPresent(ManagedChannel::shutdownNow); + AllureGrpc.await(); + Optional.ofNullable(managedChannel).ifPresent(ManagedChannel::shutdownNow); } @Test void shouldCreateRequestAttachment() { - final Request request = Request.newBuilder() - .setTopic("1") - .build(); + Request request = Request.newBuilder() + .setTopic("1") + .build(); + + Status errorStatus = Status.NOT_FOUND; + GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()).willReturn(errorStatus)); + + AllureResults allureResults = executeException(request); - final AllureResults results = execute(request); + assertThat(allureResults.getTestResults().get(0).getSteps().get(0).getStatus()) + .isEqualTo(io.qameta.allure.model.Status.FAILED); - assertThat(results.getTestResults().get(0).getSteps()) - .flatExtracting(StepResult::getAttachments) - .extracting(Attachment::getName) - .contains("gRPC request"); + assertThat(allureResults.getTestResults().get(0).getSteps()) + .flatExtracting(StepResult::getAttachments) + .extracting(Attachment::getName) + .contains("gRPC request", "gRPC response"); } @Test void shouldCreateResponseAttachment() { - final Request request = Request.newBuilder() - .setTopic("1") - .build(); + Request request = Request.newBuilder() + .setTopic("1") + .build(); - final AllureResults results = execute(request); + AllureResults allureResults = execute(request); - assertThat(results.getTestResults().get(0).getSteps()) - .flatExtracting(StepResult::getAttachments) - .extracting(Attachment::getName) - .contains("gRPC response"); + assertThat(allureResults.getTestResults().get(0).getSteps()) + .flatExtracting(StepResult::getAttachments) + .extracting(Attachment::getName) + .contains("gRPC response"); } @Test void shouldCreateResponseAttachmentForServerStreamingResponse() { - final Request request = Request.newBuilder() - .setTopic("1") - .build(); + Request request = Request.newBuilder() + .setTopic("1") + .build(); - final AllureResults results = executeStreaming(request); + AllureResults allureResults = executeStreaming(request); - assertThat(results.getTestResults().get(0).getSteps()) - .flatExtracting(StepResult::getAttachments) - .extracting(Attachment::getName) - .contains("gRPC response (collection of elements from Server stream)"); + assertThat(allureResults.getTestResults().get(0).getSteps()) + .flatExtracting(StepResult::getAttachments) + .extracting(Attachment::getName) + .contains("gRPC response (collection of elements from Server stream)"); } @Test void shouldCreateResponseAttachmentOnStatusException() { - final Status status = Status.NOT_FOUND; - GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()) - .willReturn(status)); + Status status = Status.NOT_FOUND; + GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()).willReturn(status)); + + Request request = Request.newBuilder() + .setTopic("2") + .build(); - final Request request = Request.newBuilder() - .setTopic("2") - .build(); + AllureResults allureResults = executeException(request); - final AllureResults results = executeException(request); + assertThat(allureResults.getTestResults().get(0).getSteps().get(0).getStatus()) + .isEqualTo(io.qameta.allure.model.Status.FAILED); - assertThat(results.getTestResults().get(0).getSteps()) - .flatExtracting(StepResult::getAttachments) - .extracting(Attachment::getName) - .contains(status.getCode().name()); + assertThat(allureResults.getTestResults().get(0).getSteps()) + .flatExtracting(StepResult::getAttachments) + .extracting(Attachment::getName) + .contains("gRPC response"); } - protected final AllureResults execute(final Request request) { + @Test + void shouldCreateAttachmentsForClientStreamingWithAsynchronousStub() { + Request requestOne = Request.newBuilder().setTopic("A").build(); + Request requestTwo = Request.newBuilder().setTopic("B").build(); + + AllureResults allureResults = runWithinTestContext(() -> { + TestServiceGrpc.TestServiceStub asynchronousStub = + TestServiceGrpc.newStub(managedChannel).withInterceptors(new AllureGrpc()); + + CountDownLatch completionLatch = new CountDownLatch(1); + + StreamObserver responseObserver = new StreamObserver<>() { + + @Override + public void onNext(Response value) { + assertThat(value.getMessage()).isEqualTo(RESPONSE_MESSAGE); + } + + @Override + public void onError(Throwable throwable) { + completionLatch.countDown(); + } + + @Override + public void onCompleted() { + completionLatch.countDown(); + } + }; + + StreamObserver requestObserver = asynchronousStub.calculateClientStream(responseObserver); + requestObserver.onNext(requestOne); + requestObserver.onNext(requestTwo); + requestObserver.onCompleted(); + + try { + completionLatch.await(2, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + + AllureGrpc.await(); + }); + + assertThat(allureResults.getTestResults().get(0).getSteps()) + .extracting(StepResult::getName) + .anyMatch(name -> name.startsWith("Send client_streaming gRPC request")); + + assertThat(allureResults.getTestResults().get(0).getSteps()) + .flatExtracting(StepResult::getAttachments) + .extracting(Attachment::getName) + .contains( + "gRPC request (collection of elements from Client stream)", + "gRPC response" + ); + } + + @Test + void shouldCreateAttachmentsForBidirectionalStreamingWithAsynchronousStub() { + Request requestOne = Request.newBuilder().setTopic("C").build(); + Request requestTwo = Request.newBuilder().setTopic("D").build(); + + AllureResults allureResults = runWithinTestContext(() -> { + TestServiceGrpc.TestServiceStub asynchronousStub = + TestServiceGrpc.newStub(managedChannel).withInterceptors(new AllureGrpc()); + + CountDownLatch completionLatch = new CountDownLatch(1); + List receivedResponses = new ArrayList<>(); + + StreamObserver responseObserver = new StreamObserver<>() { + @Override + public void onNext(Response value) { + receivedResponses.add(value); + } + + @Override + public void onError(Throwable throwable) { + completionLatch.countDown(); + } + + @Override + public void onCompleted() { + completionLatch.countDown(); + } + }; + + StreamObserver requestObserver = asynchronousStub.calculateBidiStream(responseObserver); + requestObserver.onNext(requestOne); + requestObserver.onNext(requestTwo); + requestObserver.onCompleted(); + + try { + completionLatch.await(2, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + + assertThat(receivedResponses).hasSize(2); + assertThat(receivedResponses.get(0).getMessage()).isEqualTo(RESPONSE_MESSAGE); + assertThat(receivedResponses.get(1).getMessage()).isEqualTo(RESPONSE_MESSAGE); + + AllureGrpc.await(); + }); + + assertThat(allureResults.getTestResults().get(0).getSteps()) + .extracting(StepResult::getName) + .anyMatch(name -> name.startsWith("Send bidi_streaming gRPC request")); + + assertThat(allureResults.getTestResults().get(0).getSteps()) + .flatExtracting(StepResult::getAttachments) + .extracting(Attachment::getName) + .contains( + "gRPC request (collection of elements from Client stream)", + "gRPC response (collection of elements from Server stream)" + ); + } + + protected final AllureResults execute(Request request) { return runWithinTestContext(() -> { try { - final Response response = blockingStub.calculate(request); + Response response = blockingServiceStub.calculate(request); assertThat(response.getMessage()).isEqualTo(RESPONSE_MESSAGE); } catch (Exception e) { throw new RuntimeException("Could not execute request " + request, e); } }); } - protected final AllureResults executeStreaming(final Request request) { + + protected final AllureResults executeStreaming(Request request) { return runWithinTestContext(() -> { try { - Iterator responseIterator = blockingStub.calculateServerStream(request); + Iterator responseIterator = blockingServiceStub.calculateServerStream(request); + int responseCount = 0; while (responseIterator.hasNext()) { assertThat(responseIterator.next().getMessage()).isEqualTo(RESPONSE_MESSAGE); + responseCount++; } + assertThat(responseCount).isEqualTo(2); } catch (Exception e) { throw new RuntimeException("Could not execute request " + request, e); } }); } - protected final AllureResults executeException(final Request request) { - return runWithinTestContext(() -> { - assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> blockingStub.calculate(request)); - }); + protected final AllureResults executeException(Request request) { + return runWithinTestContext(() -> + assertThatExceptionOfType(StatusRuntimeException.class) + .isThrownBy(() -> blockingServiceStub.calculate(request)) + ); } } diff --git a/allure-grpc/src/test/proto/api.proto b/allure-grpc/src/test/proto/api.proto index 552e76f6c..8247d7911 100644 --- a/allure-grpc/src/test/proto/api.proto +++ b/allure-grpc/src/test/proto/api.proto @@ -2,10 +2,13 @@ syntax = "proto3"; option java_multiple_files = true; option java_package = "io.qameta.allure.grpc"; +option java_outer_classname = "Api"; service TestService { rpc Calculate (Request) returns (Response); rpc CalculateServerStream (Request) returns (stream Response); + rpc CalculateClientStream (stream Request) returns (Response); + rpc CalculateBidiStream (stream Request) returns (stream Response); } message Request { From d69043d1f8891a16d82b414a2af98ec89e9b8698 Mon Sep 17 00:00:00 2001 From: epszaw Date: Thu, 2 Oct 2025 12:02:37 +0300 Subject: [PATCH 2/2] rework grpc - simplify testing --- allure-grpc/build.gradle.kts | 2 +- .../io/qameta/allure/grpc/AllureGrpc.java | 287 ++++++------- .../io/qameta/allure/grpc/AllureGrpcTest.java | 394 ++++++++++++------ 3 files changed, 389 insertions(+), 294 deletions(-) diff --git a/allure-grpc/build.gradle.kts b/allure-grpc/build.gradle.kts index 6f1933f95..259fc7e3d 100644 --- a/allure-grpc/build.gradle.kts +++ b/allure-grpc/build.gradle.kts @@ -8,7 +8,7 @@ description = "Allure gRPC Integration" val agent: Configuration by configurations.creating -val grpcVersion = "1.57.2" +val grpcVersion = "1.75.0" val protobufVersion = "4.32.1" val jacksonVersion = "2.17.2" diff --git a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java index a29258d04..d79b20d49 100644 --- a/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java +++ b/allure-grpc/src/main/java/io/qameta/allure/grpc/AllureGrpc.java @@ -37,221 +37,177 @@ import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentLinkedQueue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + @SuppressWarnings("all") public class AllureGrpc implements ClientInterceptor { private static final Logger LOGGER = LoggerFactory.getLogger(AllureGrpc.class); private static final String UNKNOWN = "unknown"; private static final JsonFormat.Printer GRPC_TO_JSON_PRINTER = JsonFormat.printer(); - - private String requestTemplatePath = "grpc-request.ftl"; - private String responseTemplatePath = "grpc-response.ftl"; - - private volatile boolean markStepFailedOnNonZeroCode = true; - private volatile boolean interceptResponseMetadata; - - private AllureLifecycle lifecycle; - - private static final ConcurrentLinkedQueue> PENDING_COMPLETIONS = - new ConcurrentLinkedQueue<>(); + private final AllureLifecycle lifecycle; + private final boolean markStepFailedOnNonZeroCode; + private final boolean interceptResponseMetadata; + private final String requestTemplatePath; + private final String responseTemplatePath; public AllureGrpc() { - this(Allure.getLifecycle()); - } - - public AllureGrpc(final AllureLifecycle allureLifecycle) { - this.lifecycle = allureLifecycle; - } - - public void setLifecycle(final AllureLifecycle allureLifecycle) { - this.lifecycle = allureLifecycle; - } - - public AllureGrpc setRequestTemplate(final String templatePath) { - this.requestTemplatePath = templatePath; - return this; - } - - public AllureGrpc setResponseTemplate(final String templatePath) { - this.responseTemplatePath = templatePath; - return this; - } - - public AllureGrpc markStepFailedOnNonZeroCode(final boolean value) { - this.markStepFailedOnNonZeroCode = value; - return this; + this(Allure.getLifecycle(), true, false, + "grpc-request.ftl", "grpc-response.ftl"); } - public AllureGrpc interceptResponseMetadata(final boolean value) { - this.interceptResponseMetadata = value; - return this; - } - - public static void await() { - CompletableFuture completion; - while ((completion = PENDING_COMPLETIONS.poll()) != null) { - try { - completion.join(); - } catch (RuntimeException runtimeException) { - LOGGER.warn("Await interrupted with exception", runtimeException); - } - } + public AllureGrpc(AllureLifecycle lifecycle, + boolean markStepFailedOnNonZeroCode, + boolean interceptResponseMetadata, + String requestTemplatePath, + String responseTemplatePath) { + this.lifecycle = lifecycle; + this.markStepFailedOnNonZeroCode = markStepFailedOnNonZeroCode; + this.interceptResponseMetadata = interceptResponseMetadata; + this.requestTemplatePath = requestTemplatePath; + this.responseTemplatePath = responseTemplatePath; } @Override public ClientCall interceptCall( - final MethodDescriptor methodDescriptor, - final CallOptions callOptions, - final Channel nextChannel + MethodDescriptor methodDescriptor, + CallOptions callOptions, + Channel nextChannel ) { - final AllureLifecycle current = Allure.getLifecycle(); + final AllureLifecycle current = lifecycle; final String parent = current.getCurrentTestCaseOrStep().orElse(null); final String stepUuid = UUID.randomUUID().toString(); - - final List clientMessages = Collections.synchronizedList(new ArrayList<>()); - final List serverMessages = Collections.synchronizedList(new ArrayList<>()); + final List clientMessages = new ArrayList<>(); + final List serverMessages = new ArrayList<>(); final Map initialHeaders = new LinkedHashMap<>(); final Map trailers = new LinkedHashMap<>(); - final CompletableFuture completion = new CompletableFuture<>(); - PENDING_COMPLETIONS.add(completion); final String stepName = buildStepName(nextChannel, methodDescriptor); - if (parent != null) { - current.startStep(parent, stepUuid, new StepResult().setName(stepName)); - } else { - current.startStep(stepUuid, new StepResult().setName(stepName)); - } + if (parent != null) current.startStep(parent, stepUuid, new StepResult().setName(stepName)); + else current.startStep(stepUuid, new StepResult().setName(stepName)); - final StepContext ctx = new StepContext<>( - stepUuid, methodDescriptor, current, clientMessages, serverMessages, initialHeaders, trailers, completion + final StepContext stepContext = new StepContext<>( + stepUuid, methodDescriptor, current, clientMessages, serverMessages, initialHeaders, trailers ); return new ForwardingClientCall.SimpleForwardingClientCall( nextChannel.newCall(methodDescriptor, callOptions) ) { @Override - public void start(final Listener rl, final Metadata rh) { - final Listener l = new ForwardingClientCallListener() { - @Override protected Listener delegate() { return rl; } - @Override public void onHeaders(final Metadata h) { - handleHeaders(h, ctx.initialHeaders); super.onHeaders(h); + public void start(final Listener responseListener, final Metadata requestHeaders) { + final Listener forwardingListener = new ForwardingClientCallListener() { + @Override protected Listener delegate() { return responseListener; } + @Override public void onHeaders(final Metadata headers) { + handleHeaders(headers, stepContext.initialHeaders); + super.onHeaders(headers); } - @Override public void onMessage(final R m) { - handleServerMessage(m, ctx.serverMessages); super.onMessage(m); + @Override public void onMessage(final R message) { + handleServerMessage(message, stepContext.serverMessages); + super.onMessage(message); } - @Override public void onClose(final io.grpc.Status s, final Metadata t) { - handleClose(s, t, ctx); super.onClose(s, t); + @Override public void onClose(final io.grpc.Status status, final Metadata responseTrailers) { + handleClose(status, responseTrailers, stepContext); + super.onClose(status, responseTrailers); } }; - super.start(l, rh); + super.start(forwardingListener, requestHeaders); } @Override - public void sendMessage(final T m) { - handleClientMessage(m, ctx.clientMessages); - super.sendMessage(m); + public void sendMessage(final T message) { + handleClientMessage(message, stepContext.clientMessages); + super.sendMessage(message); } }; } private static final class StepContext { final String stepUuid; - final MethodDescriptor method; - final AllureLifecycle lifecycleRef; + final MethodDescriptor methodDescriptor; + final AllureLifecycle lifecycle; final List clientMessages; final List serverMessages; final Map initialHeaders; final Map trailers; - final CompletableFuture done; - - StepContext( - final String stepUuid, - final MethodDescriptor method, - final AllureLifecycle lifecycleRef, - final List clientMessages, - final List serverMessages, - final Map initialHeaders, - final Map trailers, - final CompletableFuture done - ) { + StepContext(String stepUuid, + MethodDescriptor methodDescriptor, + AllureLifecycle lifecycle, + List clientMessages, + List serverMessages, + Map initialHeaders, + Map trailers) { this.stepUuid = stepUuid; - this.method = method; - this.lifecycleRef = lifecycleRef; + this.methodDescriptor = methodDescriptor; + this.lifecycle = lifecycle; this.clientMessages = clientMessages; this.serverMessages = serverMessages; this.initialHeaders = initialHeaders; this.trailers = trailers; - this.done = done; } } - private void handleHeaders(final Metadata headers, final Map dst) { + private void handleClose( + final io.grpc.Status status, + final Metadata responseTrailers, + final StepContext stepContext + ) { try { - if (interceptResponseMetadata && headers != null) { - copyAsciiResponseMetadata(headers, dst); + if (interceptResponseMetadata && responseTrailers != null) { + copyAsciiResponseMetadata(responseTrailers, stepContext.trailers); } - } catch (Throwable t) { - LOGGER.warn("Failed to capture initial response headers", t); + attachRequestIfPresent(stepContext.stepUuid, stepContext.methodDescriptor, + stepContext.clientMessages, stepContext.lifecycle); + attachResponse(stepContext.stepUuid, stepContext.serverMessages, status, + stepContext.initialHeaders, stepContext.trailers, stepContext.lifecycle); + stepContext.lifecycle.updateStep(stepContext.stepUuid, step -> step.setStatus(convertStatus(status))); + } catch (Throwable throwable) { + LOGGER.error("Failed to finalize Allure step for gRPC call", throwable); + stepContext.lifecycle.updateStep(stepContext.stepUuid, step -> step.setStatus(Status.BROKEN)); + } finally { + stopStepSafely(stepContext.lifecycle, stepContext.stepUuid); } } - private void handleClientMessage(final T message, final List dst) { + private void handleHeaders(final Metadata headers, final Map destination) { try { - dst.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); - } catch (InvalidProtocolBufferException e) { - LOGGER.error("Could not serialize gRPC request message to JSON", e); - } catch (Throwable t) { - LOGGER.error("Unexpected error while serializing gRPC request message", t); + if (interceptResponseMetadata && headers != null) + copyAsciiResponseMetadata(headers, destination); + } catch (Throwable throwable) { + LOGGER.warn("Failed to capture response headers", throwable); } } - private void handleServerMessage(final R message, final List dst) { + private void handleClientMessage(final T message, final List destination) { try { - dst.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); + destination.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); } catch (InvalidProtocolBufferException e) { - LOGGER.error("Could not serialize gRPC response message to JSON", e); - } catch (Throwable t) { - LOGGER.error("Unexpected error while serializing gRPC response message", t); + LOGGER.error("Could not serialize gRPC request message to JSON", e); + } catch (Throwable throwable) { + LOGGER.error("Unexpected error while serializing gRPC request message", throwable); } } - private void handleClose( - final io.grpc.Status status, - final Metadata trailers, - final StepContext ctx - ) { + private void handleServerMessage(final R message, final List destination) { try { - if (interceptResponseMetadata && trailers != null) { - copyAsciiResponseMetadata(trailers, ctx.trailers); - } - attachRequestIfPresent(ctx.stepUuid, ctx.method, ctx.clientMessages, ctx.lifecycleRef); - attachResponse(ctx.stepUuid, ctx.serverMessages, status, ctx.initialHeaders, ctx.trailers, ctx.lifecycleRef); - ctx.lifecycleRef.updateStep(ctx.stepUuid, s -> s.setStatus(convertStatus(status))); - } catch (Throwable t) { - LOGGER.error("Failed to finalize Allure step for gRPC call", t); - ctx.lifecycleRef.updateStep(ctx.stepUuid, s -> s.setStatus(Status.BROKEN)); - } finally { - stopStepSafely(ctx.lifecycleRef, ctx.stepUuid); - ctx.done.complete(null); - PENDING_COMPLETIONS.remove(ctx.done); + destination.add(GRPC_TO_JSON_PRINTER.print((MessageOrBuilder) message)); + } catch (InvalidProtocolBufferException e) { + LOGGER.error("Could not serialize gRPC response message to JSON", e); + } catch (Throwable throwable) { + LOGGER.error("Unexpected error while serializing gRPC response message", throwable); } } private void attachRequestIfPresent( final String stepUuid, - final MethodDescriptor method, + final MethodDescriptor methodDescriptor, final List clientMessages, - final AllureLifecycle lifecycleRef + final AllureLifecycle lifecycle ) { final String body = toJsonBody(clientMessages); if (body == null) { @@ -260,11 +216,11 @@ private void attachRequestIfPresent( final String name = clientMessages.size() > 1 ? "gRPC request (collection of elements from Client stream)" : "gRPC request"; - final GrpcRequestAttachment req = GrpcRequestAttachment.Builder - .create(name, method.getFullMethodName()) + final GrpcRequestAttachment requestAttachment = GrpcRequestAttachment.Builder + .create(name, methodDescriptor.getFullMethodName()) .setBody(body) .build(); - addRenderedAttachmentToStep(stepUuid, req.getName(), req, requestTemplatePath, lifecycleRef); + addRenderedAttachmentToStep(stepUuid, requestAttachment.getName(), requestAttachment, requestTemplatePath, lifecycle); } private void attachResponse( @@ -273,37 +229,38 @@ private void attachResponse( final io.grpc.Status status, final Map initialHeaders, final Map trailers, - final AllureLifecycle lifecycleRef + final AllureLifecycle lifecycle ) { final String body = toJsonBody(serverMessages); final String name = serverMessages.size() > 1 ? "gRPC response (collection of elements from Server stream)" : "gRPC response"; - final Map meta = new LinkedHashMap<>(); + final Map metadata = new LinkedHashMap<>(); if (interceptResponseMetadata) { - meta.putAll(initialHeaders); - meta.putAll(trailers); + metadata.putAll(initialHeaders); + metadata.putAll(trailers); } - final GrpcResponseAttachment.Builder b = GrpcResponseAttachment.Builder + final GrpcResponseAttachment.Builder builder = GrpcResponseAttachment.Builder .create(name) .setStatus(status.toString()); if (body != null) { - b.setBody(body); + builder.setBody(body); } - if (!meta.isEmpty()) { - b.addMetadata(meta); + if (!metadata.isEmpty()) { + builder.addMetadata(metadata); } - final GrpcResponseAttachment res = b.build(); - addRenderedAttachmentToStep(stepUuid, res.getName(), res, responseTemplatePath, lifecycleRef); + final GrpcResponseAttachment responseAttachment = builder.build(); + addRenderedAttachmentToStep(stepUuid, responseAttachment.getName(), + responseAttachment, responseTemplatePath, lifecycle); } - private void stopStepSafely(final AllureLifecycle lc, final String stepUuid) { + private void stopStepSafely(final AllureLifecycle lifecycle, final String stepUuid) { try { - lc.stopStep(stepUuid); - } catch (Throwable t) { - LOGGER.warn("Failed to stop Allure step {}", stepUuid, t); + lifecycle.stopStep(stepUuid); + } catch (Throwable throwable) { + LOGGER.warn("Failed to stop Allure step {}", stepUuid, throwable); } } @@ -336,35 +293,35 @@ private void addRenderedAttachmentToStep( final String attachmentName, final AttachmentData data, final String templatePath, - final AllureLifecycle lifecycleRef + final AllureLifecycle lifecycle ) { final AttachmentRenderer renderer = new FreemarkerAttachmentRenderer(templatePath); final io.qameta.allure.attachment.AttachmentContent content; try { content = renderer.render(data); - } catch (Throwable t) { - LOGGER.warn("Could not render attachment '{}' using template '{}'", attachmentName, templatePath, t); + } catch (Throwable throwable) { + LOGGER.warn("Could not render attachment '{}' using template '{}'", attachmentName, templatePath, throwable); return; } if (content == null || content.getContent() == null) { LOGGER.warn("Rendered attachment '{}' is empty; skipping", attachmentName); return; } - String ext = content.getFileExtension(); - if (ext == null || ext.isEmpty()) { - ext = ".html"; + String fileExtension = content.getFileExtension(); + if (fileExtension == null || fileExtension.isEmpty()) { + fileExtension = ".html"; } - final String source = UUID.randomUUID() + ext; - lifecycleRef.updateStep( + final String source = UUID.randomUUID() + fileExtension; + lifecycle.updateStep( stepUuid, - s -> s.getAttachments().add( + step -> step.getAttachments().add( new Attachment() .setName(attachmentName) .setSource(source) .setType(content.getContentType() != null ? content.getContentType() : "text/html") ) ); - lifecycleRef.writeAttachment(source, new ByteArrayInputStream(content.getContent().getBytes(StandardCharsets.UTF_8))); + lifecycle.writeAttachment(source, new ByteArrayInputStream(content.getContent().getBytes(StandardCharsets.UTF_8))); } private static String toJsonBody(final List items) { @@ -386,10 +343,10 @@ private static void copyAsciiResponseMetadata(final Metadata source, final Map k = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); - final String v = source.get(k); - if (v != null) { - target.put(key, v); + final Metadata.Key keyAscii = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + final String value = source.get(keyAscii); + if (value != null) { + target.put(key, value); } } } diff --git a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java index 03865ac6b..48ddf574e 100644 --- a/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java +++ b/allure-grpc/src/test/java/io/qameta/allure/grpc/AllureGrpcTest.java @@ -1,25 +1,13 @@ -/* - * Copyright 2016-2024 Qameta Software 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 io.qameta.allure.grpc; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.grpc.stub.StreamObserver; +import io.qameta.allure.Allure; import io.qameta.allure.model.Attachment; import io.qameta.allure.model.StepResult; import io.qameta.allure.test.AllureResults; @@ -30,12 +18,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Optional; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static java.util.Arrays.asList; @@ -50,19 +38,18 @@ class AllureGrpcTest { private static final String RESPONSE_MESSAGE = "Hello world!"; + private static final ObjectMapper JSON = new ObjectMapper(); private ManagedChannel managedChannel; - private TestServiceGrpc.TestServiceBlockingStub blockingServiceStub; @BeforeEach - void configureMock() { - managedChannel = ManagedChannelBuilder.forAddress("localhost", GrpcMock.getGlobalPort()) + void configureMockServer() { + managedChannel = ManagedChannelBuilder + .forAddress("localhost", GrpcMock.getGlobalPort()) .usePlaintext() + .directExecutor() .build(); - blockingServiceStub = TestServiceGrpc.newBlockingStub(managedChannel) - .withInterceptors(new AllureGrpc()); - GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()) .willReturn(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build())); @@ -81,11 +68,9 @@ void configureMock() { public void onNext(Request request) { responseObserver.onNext(Response.newBuilder().setMessage(RESPONSE_MESSAGE).build()); } - @Override public void onError(Throwable throwable) { } - @Override public void onCompleted() { responseObserver.onCompleted(); @@ -95,8 +80,7 @@ public void onCompleted() { @AfterEach void shutdownChannel() { - AllureGrpc.await(); - Optional.ofNullable(managedChannel).ifPresent(ManagedChannel::shutdownNow); + Optional.ofNullable(managedChannel).ifPresent(ManagedChannel::shutdown); } @Test @@ -108,7 +92,7 @@ void shouldCreateRequestAttachment() { Status errorStatus = Status.NOT_FOUND; GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()).willReturn(errorStatus)); - AllureResults allureResults = executeException(request); + AllureResults allureResults = executeUnaryExpectingException(request); assertThat(allureResults.getTestResults().get(0).getSteps().get(0).getStatus()) .isEqualTo(io.qameta.allure.model.Status.FAILED); @@ -125,7 +109,7 @@ void shouldCreateResponseAttachment() { .setTopic("1") .build(); - AllureResults allureResults = execute(request); + AllureResults allureResults = executeUnary(request); assertThat(allureResults.getTestResults().get(0).getSteps()) .flatExtracting(StepResult::getAttachments) @@ -139,7 +123,7 @@ void shouldCreateResponseAttachmentForServerStreamingResponse() { .setTopic("1") .build(); - AllureResults allureResults = executeStreaming(request); + AllureResults allureResults = executeServerStreaming(request); assertThat(allureResults.getTestResults().get(0).getSteps()) .flatExtracting(StepResult::getAttachments) @@ -149,14 +133,14 @@ void shouldCreateResponseAttachmentForServerStreamingResponse() { @Test void shouldCreateResponseAttachmentOnStatusException() { - Status status = Status.NOT_FOUND; - GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()).willReturn(status)); + Status notFoundStatus = Status.NOT_FOUND; + GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()).willReturn(notFoundStatus)); Request request = Request.newBuilder() .setTopic("2") .build(); - AllureResults allureResults = executeException(request); + AllureResults allureResults = executeUnaryExpectingException(request); assertThat(allureResults.getTestResults().get(0).getSteps().get(0).getStatus()) .isEqualTo(io.qameta.allure.model.Status.FAILED); @@ -169,151 +153,305 @@ void shouldCreateResponseAttachmentOnStatusException() { @Test void shouldCreateAttachmentsForClientStreamingWithAsynchronousStub() { - Request requestOne = Request.newBuilder().setTopic("A").build(); - Request requestTwo = Request.newBuilder().setTopic("B").build(); + Request firstClientRequest = Request.newBuilder().setTopic("A").build(); + Request secondClientRequest = Request.newBuilder().setTopic("B").build(); - AllureResults allureResults = runWithinTestContext(() -> { + runWithinTestContext(() -> { TestServiceGrpc.TestServiceStub asynchronousStub = TestServiceGrpc.newStub(managedChannel).withInterceptors(new AllureGrpc()); - CountDownLatch completionLatch = new CountDownLatch(1); - - StreamObserver responseObserver = new StreamObserver<>() { + final List receivedResponses = new ArrayList<>(); + + Allure.step("async-root-client-stream", () -> { + StreamObserver responseObserver = new StreamObserver<>() { + @Override + public void onNext(Response value) { + receivedResponses.add(value); + } + @Override + public void onError(Throwable throwable) { + } + @Override + public void onCompleted() { + } + }; + + StreamObserver requestObserver = asynchronousStub.calculateClientStream(responseObserver); + requestObserver.onNext(firstClientRequest); + requestObserver.onNext(secondClientRequest); + requestObserver.onCompleted(); + }); + + assertThat(receivedResponses).hasSize(1); + assertThat(receivedResponses.get(0).getMessage()).isEqualTo(RESPONSE_MESSAGE); + }); + } - @Override - public void onNext(Response value) { - assertThat(value.getMessage()).isEqualTo(RESPONSE_MESSAGE); - } + @Test + void shouldCreateAttachmentsForBidirectionalStreamingWithAsynchronousStub() { + Request firstBidirectionalRequest = Request.newBuilder().setTopic("C").build(); + Request secondBidirectionalRequest = Request.newBuilder().setTopic("D").build(); - @Override - public void onError(Throwable throwable) { - completionLatch.countDown(); - } + runWithinTestContext(() -> { + TestServiceGrpc.TestServiceStub asynchronousStub = + TestServiceGrpc.newStub(managedChannel).withInterceptors(new AllureGrpc()); - @Override - public void onCompleted() { - completionLatch.countDown(); - } - }; + List receivedResponses = new ArrayList<>(); - StreamObserver requestObserver = asynchronousStub.calculateClientStream(responseObserver); - requestObserver.onNext(requestOne); - requestObserver.onNext(requestTwo); - requestObserver.onCompleted(); + Allure.step("async-root-bidi-stream", () -> { + StreamObserver responseObserver = new StreamObserver<>() { + @Override public void onNext(Response value) { receivedResponses.add(value); } + @Override public void onError(Throwable throwable) { } + @Override public void onCompleted() { } + }; - try { - completionLatch.await(2, TimeUnit.SECONDS); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - } + StreamObserver requestObserver = asynchronousStub.calculateBidiStream(responseObserver); + requestObserver.onNext(firstBidirectionalRequest); + requestObserver.onNext(secondBidirectionalRequest); + requestObserver.onCompleted(); + }); - AllureGrpc.await(); + assertThat(receivedResponses).hasSize(2); + assertThat(receivedResponses.get(0).getMessage()).isEqualTo(RESPONSE_MESSAGE); + assertThat(receivedResponses.get(1).getMessage()).isEqualTo(RESPONSE_MESSAGE); }); - - assertThat(allureResults.getTestResults().get(0).getSteps()) - .extracting(StepResult::getName) - .anyMatch(name -> name.startsWith("Send client_streaming gRPC request")); - - assertThat(allureResults.getTestResults().get(0).getSteps()) - .flatExtracting(StepResult::getAttachments) - .extracting(Attachment::getName) - .contains( - "gRPC request (collection of elements from Client stream)", - "gRPC response" - ); } @Test - void shouldCreateAttachmentsForBidirectionalStreamingWithAsynchronousStub() { - Request requestOne = Request.newBuilder().setTopic("C").build(); - Request requestTwo = Request.newBuilder().setTopic("D").build(); + void unaryRequestBodyIsCapturedAsJsonObject() throws Exception { + GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()) + .willReturn(Response.newBuilder().setMessage("ok").build())); + + Request request = Request.newBuilder().setTopic("topic-1").build(); AllureResults allureResults = runWithinTestContext(() -> { - TestServiceGrpc.TestServiceStub asynchronousStub = - TestServiceGrpc.newStub(managedChannel).withInterceptors(new AllureGrpc()); + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo("ok"); + }); - CountDownLatch completionLatch = new CountDownLatch(1); - List receivedResponses = new ArrayList<>(); + String attachmentHtmlContent = readAttachmentContentByName(allureResults, "gRPC request"); + String jsonPayload = extractJsonPayload(attachmentHtmlContent); + JsonNode actualJsonNode = JSON.readTree(jsonPayload); + JsonNode expectedJsonNode = JSON.createObjectNode().put("topic", "topic-1"); - StreamObserver responseObserver = new StreamObserver<>() { - @Override - public void onNext(Response value) { - receivedResponses.add(value); - } + assertThat(actualJsonNode).isEqualTo(expectedJsonNode); + } - @Override - public void onError(Throwable throwable) { - completionLatch.countDown(); - } + @Test + void unaryResponseBodyIsCapturedAsJsonObject() throws Exception { + GrpcMock.stubFor(unaryMethod(TestServiceGrpc.getCalculateMethod()) + .willReturn(Response.newBuilder().setMessage("hello-world").build())); - @Override - public void onCompleted() { - completionLatch.countDown(); - } - }; + Request request = Request.newBuilder().setTopic("x").build(); - StreamObserver requestObserver = asynchronousStub.calculateBidiStream(responseObserver); - requestObserver.onNext(requestOne); - requestObserver.onNext(requestTwo); - requestObserver.onCompleted(); + AllureResults allureResults = runWithinTestContext(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo("hello-world"); + }); - try { - completionLatch.await(2, TimeUnit.SECONDS); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - } + String attachmentHtmlContent = readAttachmentContentByName(allureResults, "gRPC response"); + String jsonPayload = extractJsonPayload(attachmentHtmlContent); + JsonNode actualJsonNode = JSON.readTree(jsonPayload); + JsonNode expectedJsonNode = JSON.createObjectNode().put("message", "hello-world"); - assertThat(receivedResponses).hasSize(2); - assertThat(receivedResponses.get(0).getMessage()).isEqualTo(RESPONSE_MESSAGE); - assertThat(receivedResponses.get(1).getMessage()).isEqualTo(RESPONSE_MESSAGE); + assertThat(actualJsonNode).isEqualTo(expectedJsonNode); + } - AllureGrpc.await(); + @Test + void serverStreamingResponseBodyIsJsonArrayInOrder() throws Exception { + GrpcMock.stubFor(serverStreamingMethod(TestServiceGrpc.getCalculateServerStreamMethod()) + .willReturn(asList( + Response.newBuilder().setMessage("first").build(), + Response.newBuilder().setMessage("second").build() + ))); + + Request request = Request.newBuilder().setTopic("stream-topic").build(); + + AllureResults allureResults = runWithinTestContext(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Iterator responseIterator = stub.calculateServerStream(request); + assertThat(responseIterator.hasNext()).isTrue(); + assertThat(responseIterator.next().getMessage()).isEqualTo("first"); + assertThat(responseIterator.hasNext()).isTrue(); + assertThat(responseIterator.next().getMessage()).isEqualTo("second"); + assertThat(responseIterator.hasNext()).isFalse(); }); - assertThat(allureResults.getTestResults().get(0).getSteps()) - .extracting(StepResult::getName) - .anyMatch(name -> name.startsWith("Send bidi_streaming gRPC request")); + String attachmentHtmlContent = readAttachmentContentByName( + allureResults, + "gRPC response (collection of elements from Server stream)" + ); + String jsonPayload = extractJsonPayload(attachmentHtmlContent); + JsonNode actualJsonArray = JSON.readTree(jsonPayload); - assertThat(allureResults.getTestResults().get(0).getSteps()) - .flatExtracting(StepResult::getAttachments) - .extracting(Attachment::getName) - .contains( - "gRPC request (collection of elements from Client stream)", - "gRPC response (collection of elements from Server stream)" - ); + assertThat(actualJsonArray.isArray()).isTrue(); + assertThat(actualJsonArray.size()).isEqualTo(2); + assertThat(actualJsonArray.get(0)).isEqualTo(JSON.createObjectNode().put("message", "first")); + assertThat(actualJsonArray.get(1)).isEqualTo(JSON.createObjectNode().put("message", "second")); } - protected final AllureResults execute(Request request) { + protected final AllureResults executeUnary(Request request) { return runWithinTestContext(() -> { try { - Response response = blockingServiceStub.calculate(request); + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); assertThat(response.getMessage()).isEqualTo(RESPONSE_MESSAGE); - } catch (Exception e) { - throw new RuntimeException("Could not execute request " + request, e); + } catch (Exception exception) { + throw new RuntimeException("Could not execute request " + request, exception); } }); } - protected final AllureResults executeStreaming(Request request) { + protected final AllureResults executeServerStreaming(Request request) { return runWithinTestContext(() -> { try { - Iterator responseIterator = blockingServiceStub.calculateServerStream(request); + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Iterator responseIterator = stub.calculateServerStream(request); int responseCount = 0; while (responseIterator.hasNext()) { assertThat(responseIterator.next().getMessage()).isEqualTo(RESPONSE_MESSAGE); responseCount++; } assertThat(responseCount).isEqualTo(2); - } catch (Exception e) { - throw new RuntimeException("Could not execute request " + request, e); + } catch (Exception exception) { + throw new RuntimeException("Could not execute request " + request, exception); } }); } - protected final AllureResults executeException(Request request) { + protected final AllureResults executeUnaryExpectingException(Request request) { return runWithinTestContext(() -> assertThatExceptionOfType(StatusRuntimeException.class) - .isThrownBy(() -> blockingServiceStub.calculate(request)) + .isThrownBy(() -> { + TestServiceGrpc.TestServiceBlockingStub stub = + TestServiceGrpc.newBlockingStub(managedChannel).withInterceptors(new AllureGrpc()); + Response response = stub.calculate(request); + assertThat(response.getMessage()).isEqualTo("ok"); + }) ); } + + private static String readAttachmentContentByName(AllureResults allureResults, String attachmentName) { + var test = allureResults.getTestResults().get(0); + + Attachment matchedAttachment = flattenSteps(test.getSteps()).stream() + .flatMap(step -> step.getAttachments().stream()) + .filter(attachment -> attachmentName.equals(attachment.getName())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Attachment not found: " + attachmentName)); + + String attachmentSourceKey = matchedAttachment.getSource(); + Map attachmentsContent = allureResults.getAttachments(); + byte[] rawAttachmentContent = attachmentsContent.get(attachmentSourceKey); + if (rawAttachmentContent == null) { + throw new IllegalStateException("Attachment content not found by source: " + attachmentSourceKey); + } + return new String(rawAttachmentContent, StandardCharsets.UTF_8); + } + + private static String extractJsonPayload(String htmlContent) { + String textWithoutHtml = stripHtmlTags(unescapeHtml(htmlContent)); + int fullLength = textWithoutHtml.length(); + for (int currentIndex = 0; currentIndex < fullLength; currentIndex++) { + char currentChar = textWithoutHtml.charAt(currentIndex); + if (currentChar == '{' || currentChar == '[') { + int matchingBracketIndex = findMatchingBracket(textWithoutHtml, currentIndex); + if (matchingBracketIndex > currentIndex) { + String candidateJson = textWithoutHtml.substring(currentIndex, matchingBracketIndex + 1).trim(); + if (looksLikeJson(candidateJson) && canParseJson(candidateJson)) { + return candidateJson; + } + } + } + } + throw new IllegalStateException("JSON payload not found or not valid inside attachment"); + } + + private static boolean canParseJson(String candidateJson) { + try { + JSON.readTree(candidateJson); + return true; + } catch (Exception ignore) { + return false; + } + } + + private static boolean looksLikeJson(String input) { + if (input == null) { + return false; + } + String trimmed = input.trim(); + if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { + return false; + } + return trimmed.matches("(?s).*\"[^\"]+\"\\s*:\\s*.*"); + } + + private static int findMatchingBracket(String input, int startIndex) { + char openingBracket = input.charAt(startIndex); + char closingBracket = (openingBracket == '{') ? '}' : ']'; + int nestingDepth = 0; + boolean insideString = false; + for (int index = startIndex; index < input.length(); index++) { + char symbol = input.charAt(index); + if (symbol == '"' && (index == 0 || input.charAt(index - 1) != '\\')) { + insideString = !insideString; + } + if (insideString) { + continue; + } + if (symbol == openingBracket) { + nestingDepth++; + } else if (symbol == closingBracket) { + nestingDepth--; + if (nestingDepth == 0) { + return index; + } + } + } + return -1; + } + + private static String stripHtmlTags(String input) { + String withoutTags = input.replaceAll("(?is)", "") + .replaceAll("(?is)", "") + .replaceAll("(?s)<[^>]*>", " "); + return withoutTags + .replace("\r", " ") + .replace("\n", " ") + .replaceAll("[ \\t\\x0B\\f\\r]+", " ") + .trim(); + } + + private static String unescapeHtml(String input) { + return input.replace(""", "\"") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace("{", "{") + .replace("}", "}") + .replace("[", "[") + .replace("]", "]") + .replace(":", ":") + .replace(",", ","); + } + + private static List flattenSteps(List rootSteps) { + List allSteps = new ArrayList<>(); + if (rootSteps == null) { + return allSteps; + } + for (StepResult step : rootSteps) { + allSteps.add(step); + allSteps.addAll(flattenSteps(step.getSteps())); + } + return allSteps; + } }