diff --git a/CHANGELOG.md b/CHANGELOG.md index f98db4e..d3267c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +### Added +- Attachment logging, by @HardNorth ## [5.3.0] ### Changed diff --git a/src/main/java/com/epam/reportportal/karate/ReportPortalHook.java b/src/main/java/com/epam/reportportal/karate/ReportPortalHook.java index 77e809b..850e7e9 100644 --- a/src/main/java/com/epam/reportportal/karate/ReportPortalHook.java +++ b/src/main/java/com/epam/reportportal/karate/ReportPortalHook.java @@ -26,6 +26,7 @@ import com.epam.reportportal.utils.MemoizingSupplier; import com.epam.reportportal.utils.StatusEvaluation; import com.epam.reportportal.utils.formatting.MarkdownUtils; +import com.epam.reportportal.utils.reflect.Accessible; import com.epam.ta.reportportal.ws.model.FinishExecutionRQ; import com.epam.ta.reportportal.ws.model.FinishTestItemRQ; import com.epam.ta.reportportal.ws.model.StartTestItemRQ; @@ -44,10 +45,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Collections; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; @@ -322,14 +320,47 @@ public void finishBackground(@Nullable StepResult stepResult, @Nonnull ScenarioR } } + /** + * Embed an attachment to ReportPortal. + * + * @param itemId item ID future + * @param embed Karate's Embed object + */ + protected void embedAttachment(@Nonnull Maybe itemId, @Nonnull Embed embed) { + ReportPortalUtils.embedAttachment(itemId, embed); + } + + /** + * Embed an attachment to ReportPortal. + * + * @param itemId item ID future + * @param embeddedEntities a list of Karate's Embed object + */ + protected void embedAttachments(@Nonnull Maybe itemId, @Nullable List embeddedEntities) { + ofNullable(embeddedEntities).ifPresent(embeds -> embeds.forEach(embed -> embedAttachment(itemId, embed))); + } + @Override public void afterScenario(ScenarioRuntime sr) { Maybe scenarioId = scenarioIdMap.get(sr.scenario.getUniqueId()); + finishBackground(null, sr); + if (scenarioId == null) { LOGGER.error("ERROR: Trying to finish unspecified scenario."); + return; + } + + try { + @SuppressWarnings("unchecked") + List embeddedEntities = (List) new Accessible(sr).field("embeds").getValue(); + embedAttachments(scenarioId, embeddedEntities); + } catch (Exception e) { + LOGGER.warn( + "Unable to retrieve scenario embeddings; attachments (such as screenshots or logs) will not be reported for this" // + + " scenario. Test execution and reporting will continue. Exception details:", e + ); } - finishBackground(null, sr); FinishTestItemRQ rq = buildFinishScenarioRq(sr); //noinspection ReactiveStreamsUnusedPublisher launch.get().finishTestItem(scenarioId, rq); @@ -415,6 +446,9 @@ public void sendStepResults(StepResult stepResult, ScenarioRuntime sr) { Maybe stepId = stepIdMap.get(sr.scenario.getUniqueId()); Step step = stepResult.getStep(); Result result = stepResult.getResult(); + + ofNullable(stepResult.getEmbeds()).ifPresent(embeds -> embeds.forEach(embed -> embedAttachment(stepId, embed))); + if (result.isFailed()) { String fullErrorMessage = step.getPrefix() + " " + step.getText(); String errorMessage = result.getErrorMessage(); diff --git a/src/main/java/com/epam/reportportal/karate/ReportPortalPublisher.java b/src/main/java/com/epam/reportportal/karate/ReportPortalPublisher.java index 27adacc..c12bd3a 100644 --- a/src/main/java/com/epam/reportportal/karate/ReportPortalPublisher.java +++ b/src/main/java/com/epam/reportportal/karate/ReportPortalPublisher.java @@ -400,6 +400,26 @@ protected void sendLog(Maybe itemId, String message, LogLevel level) { ReportPortalUtils.sendLog(itemId, message, level); } + /** + * Embed an attachment to ReportPortal. + * + * @param itemId item ID future + * @param embed Karate's Embed object + */ + protected void embedAttachment(Maybe itemId, Embed embed) { + ReportPortalUtils.embedAttachment(itemId, embed); + } + + /** + * Embed an attachment to ReportPortal. + * + * @param itemId item ID future + * @param embeddedEntities a list of Karate's Embed object + */ + protected void embedAttachments(@Nonnull Maybe itemId, @Nullable List embeddedEntities) { + ofNullable(embeddedEntities).ifPresent(embeds -> embeds.forEach(embed -> embedAttachment(itemId, embed))); + } + /** * Send Step execution results to ReportPortal. * @@ -412,6 +432,9 @@ public void sendStepResults(StepResult stepResult) { if (isNotBlank(stepLog)) { sendLog(stepId, stepLog, LogLevel.DEBUG); } + + embedAttachments(stepId, stepResult.getEmbeds()); + if (result.isFailed()) { String fullErrorMessage = step.getPrefix() + " " + step.getText(); String errorMessage = result.getErrorMessage(); diff --git a/src/main/java/com/epam/reportportal/karate/ReportPortalUtils.java b/src/main/java/com/epam/reportportal/karate/ReportPortalUtils.java index 618a67f..29d989f 100644 --- a/src/main/java/com/epam/reportportal/karate/ReportPortalUtils.java +++ b/src/main/java/com/epam/reportportal/karate/ReportPortalUtils.java @@ -465,6 +465,30 @@ public static void sendLog(Maybe itemId, String message, LogLevel level) sendLog(itemId, message, level, Instant.now()); } + /** + * Embed an attachment to ReportPortal. + * + * @param itemId item ID future + * @param embed Karate's Embed object + */ + public static void embedAttachment(@Nonnull Maybe itemId, @Nonnull Embed embed) { + ReportPortal.emitLog(itemId, id -> { + SaveLogRQ rq = new SaveLogRQ(); + rq.setItemUuid(id); + rq.setLevel(LogLevel.INFO.name()); + rq.setLogTime(Instant.now()); + rq.setMessage("Attachment: " + embed.getResourceType().contentType); + + SaveLogRQ.File file = new SaveLogRQ.File(); + file.setName(embed.getFile().getName()); + file.setContent(embed.getBytes()); + file.setContentType(embed.getResourceType().contentType); + rq.setFile(file); + + return rq; + }); + } + /** * Finish sending Launch data to ReportPortal. * diff --git a/src/test/java/com/epam/reportportal/karate/logging/EmbedLoggingTest.java b/src/test/java/com/epam/reportportal/karate/logging/EmbedLoggingTest.java new file mode 100644 index 0000000..bd2cb58 --- /dev/null +++ b/src/test/java/com/epam/reportportal/karate/logging/EmbedLoggingTest.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025 EPAM Systems + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.epam.reportportal.karate.logging; + +import com.epam.reportportal.karate.utils.TestUtils; +import com.epam.reportportal.listeners.LogLevel; +import com.epam.reportportal.service.ReportPortal; +import com.epam.reportportal.service.ReportPortalClient; +import com.epam.reportportal.util.test.CommonUtils; +import com.epam.ta.reportportal.ws.model.log.SaveLogRQ; +import com.intuit.karate.Results; +import okhttp3.MultipartBody; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static com.epam.reportportal.karate.utils.TestUtils.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.*; + +public class EmbedLoggingTest { + private static final String TEST_FEATURE = "classpath:feature/embed.feature"; + private final String launchUuid = CommonUtils.namedId("launch_"); + private final String featureId = CommonUtils.namedId("feature_"); + private final String scenarioId = CommonUtils.namedId("scenario_"); + private final List stepIds = Stream.generate(() -> CommonUtils.namedId("step_")).limit(2).collect(Collectors.toList()); + + private final ReportPortalClient client = mock(ReportPortalClient.class); + private final ReportPortal rp = ReportPortal.create(client, standardParameters(), testExecutor()); + + @BeforeEach + public void setupMock() { + mockLaunch(client, launchUuid, featureId, scenarioId, stepIds); + mockBatchLogging(client); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void test_embed_image_attachment(boolean report) { + Results results; + if (report) { + results = TestUtils.runAsReport(rp, TEST_FEATURE); + } else { + results = TestUtils.runAsHook(rp, TEST_FEATURE); + } + assertThat(results.getFailCount(), equalTo(0)); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(List.class); + verify(client).log(logCaptor.capture()); + + List logs = logCaptor.getAllValues() + .stream() + .flatMap(rq -> extractJsonParts((List) rq).stream()) + .collect(Collectors.toList()); + assertThat(logs, hasSize(2)); + + List attachmentLogs = logs.stream() + .filter(log -> log.getFile() != null) + .filter(log -> log.getMessage() != null && log.getMessage().startsWith("Attachment: ")) + .collect(Collectors.toList()); + + assertThat("Should have one attachment log message", attachmentLogs, hasSize(1)); + + // Verify the attachment log properties + SaveLogRQ attachmentLog = attachmentLogs.get(0); + assertThat("Attachment log should have INFO level", attachmentLog.getLevel(), equalTo(LogLevel.INFO.name())); + assertThat("Attachment log should have item UUID", attachmentLog.getItemUuid(), notNullValue()); + assertThat("Attachment log should have log time", attachmentLog.getLogTime(), notNullValue()); + assertThat("Attachment message should contain image/png", attachmentLog.getMessage(), equalTo("Attachment: image/png")); + + List> attachments = logCaptor.getAllValues() + .stream() + .flatMap(rq -> extractBinaryParts((List) rq).stream()) + .collect(Collectors.toList()); + assertThat(attachments, hasSize(1)); + assertThat(attachments.get(0).getKey(), equalTo("image/png")); + assertThat(attachments.get(0).getValue().length, greaterThan(0)); + } +} + diff --git a/src/test/java/com/epam/reportportal/karate/utils/TestUtils.java b/src/test/java/com/epam/reportportal/karate/utils/TestUtils.java index e847ade..1249c4d 100644 --- a/src/test/java/com/epam/reportportal/karate/utils/TestUtils.java +++ b/src/test/java/com/epam/reportportal/karate/utils/TestUtils.java @@ -35,6 +35,7 @@ import io.reactivex.Maybe; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; +import okhttp3.MediaType; import okhttp3.MultipartBody; import okio.Buffer; import org.apache.commons.lang3.tuple.Pair; @@ -232,4 +233,21 @@ public static List extractJsonParts(List parts) { .flatMap(Collection::stream) .collect(Collectors.toList()); } + + public static List> extractBinaryParts(List parts) { + return parts.stream() + .filter(p -> ofNullable(p.headers()).map(headers -> headers.get("Content-Disposition")) + .map(h -> h.contains(Constants.LOG_REQUEST_BINARY_PART)) + .orElse(false)) + .map(MultipartBody.Part::body) + .map(b -> { + Buffer buf = new Buffer(); + try { + b.writeTo(buf); + } catch (IOException ignore) { + } + return Pair.of(ofNullable(b.contentType()).map(MediaType::toString).orElse(null), buf.readByteArray()); + }) + .collect(Collectors.toList()); + } } diff --git a/src/test/resources/feature/embed.feature b/src/test/resources/feature/embed.feature new file mode 100644 index 0000000..b1343e4 --- /dev/null +++ b/src/test/resources/feature/embed.feature @@ -0,0 +1,5 @@ +Feature: Demonstrate image attachment + + Scenario: I attach image to report + When def bytes = karate.read('classpath:pug/lucky.png') + Then karate.embed(bytes, 'image/png') diff --git a/src/test/resources/pug/lucky.png b/src/test/resources/pug/lucky.png new file mode 100644 index 0000000..b54ca57 Binary files /dev/null and b/src/test/resources/pug/lucky.png differ diff --git a/src/test/resources/pug/unlucky.png b/src/test/resources/pug/unlucky.png new file mode 100644 index 0000000..404ee4e Binary files /dev/null and b/src/test/resources/pug/unlucky.png differ