diff --git a/README.md b/README.md index 1b1f10b..e936415 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,26 @@ Please ensure you set the following system variables (e.g. as part of the maven- ``` +### Attachments +A test can attach files to its results: console logs, network HAR files, videos, Playwright traces, page HTML snapshots and images. Angles shows each one on the test (or the step) with a viewer that suits it. The file extension decides how it is shown, so keep the real one: `.log`/`.txt`, `.json`, `.har`, `.webm`/`.mp4`, `.zip` (shown as a Playwright trace when the name contains "trace"), `.html`/`.htm`, `.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`. Requires an Angles server with test attachment support. + +```java +reporter.fail("Order confirmation", "Order confirmed", "Payment declined", ""); + +// attach to the step you just reported, e.g. the page as it was when the assertion failed +reporter.attachDataToLastStep(driver.getPageSource().getBytes(StandardCharsets.UTF_8), "page.html"); +reporter.attachFileToLastStep(screenshotFile); + +// attach to the whole test +reporter.attachFile(new File("target/videos/checkout.webm")); +reporter.attachFile(new File("target/network.har")); +reporter.attachData(consoleLog.getBytes(StandardCharsets.UTF_8), "console.log"); + +reporter.saveTest(); +``` + +Uploads happen straight away (they only need the build id), so this works in batch mode too. Like `storeScreenshot`, a failed upload throws an `Error`. + ### Batch mode By default every call to `saveTest()` sends the test execution to the Angles API straight away. If you'd rather send the whole test run in a single request at the end (e.g. for large runs), you can enable batch mode on the reporter. The build is still created up-front and screenshots are still uploaded individually as the tests run (they need the build id), but the executions are gathered by the reporter until you call `saveAllTests()`. diff --git a/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporter.java b/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporter.java index e13c276..0804df0 100644 --- a/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporter.java +++ b/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporter.java @@ -2,6 +2,7 @@ import com.github.angleshq.angles.api.exceptions.AnglesServerException; import com.github.angleshq.angles.api.models.Platform; +import com.github.angleshq.angles.api.models.attachment.TestAttachment; import com.github.angleshq.angles.api.models.build.Artifact; import com.github.angleshq.angles.api.models.build.Build; import com.github.angleshq.angles.api.models.build.CreateBuild; @@ -15,6 +16,7 @@ import com.github.angleshq.angles.api.requests.*; import org.apache.http.client.config.RequestConfig; +import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -38,6 +40,7 @@ public class AnglesReporter implements AnglesReporterInterface { private EnvironmentRequests environmentRequests; private TeamRequests teamRequests; private ScreenshotRequests screenshotRequests; + private AttachmentRequests attachmentRequests; protected static int connectionTimeout = 5000; protected static int socketTimeout = 10000; @@ -80,6 +83,7 @@ private AnglesReporter(String url, RequestConfig requestConfig) { environmentRequests = new EnvironmentRequests(baseUrl); teamRequests = new TeamRequests(baseUrl); screenshotRequests = new ScreenshotRequests(baseUrl); + attachmentRequests = new AttachmentRequests(baseUrl); } /** @@ -92,6 +96,7 @@ public void setApiKey(String apiKey) { environmentRequests.setApiKey(apiKey); teamRequests.setApiKey(apiKey); screenshotRequests.setApiKey(apiKey); + attachmentRequests.setApiKey(apiKey); } public synchronized void startBuild(String name, String environmentName, String teamName, String componentName) { @@ -317,6 +322,79 @@ public ImageCompareResponse compareScreenshotAgainstBaseline(String screenshotId return null; } + public TestAttachment attachFile(File file) { + return attachFile(file, null); + } + + public TestAttachment attachFile(File file, String fileName) { + CreateExecution execution = requireExecution("attachFile"); + TestAttachment attachment = upload("attachFile", () -> attachmentRequests.upload(currentBuild.get().getId(), file, fileName)); + execution.addAttachment(attachment.getId()); + return attachment; + } + + public TestAttachment attachData(byte[] data, String fileName) { + CreateExecution execution = requireExecution("attachData"); + TestAttachment attachment = upload("attachData", () -> attachmentRequests.upload(currentBuild.get().getId(), data, fileName)); + execution.addAttachment(attachment.getId()); + return attachment; + } + + public TestAttachment attachFileToLastStep(File file) { + return attachFileToLastStep(file, null); + } + + public TestAttachment attachFileToLastStep(File file, String fileName) { + Step step = requireLastStep("attachFileToLastStep"); + TestAttachment attachment = upload("attachFileToLastStep", () -> attachmentRequests.upload(currentBuild.get().getId(), file, fileName)); + step.addAttachment(attachment.getId()); + return attachment; + } + + public TestAttachment attachDataToLastStep(byte[] data, String fileName) { + Step step = requireLastStep("attachDataToLastStep"); + TestAttachment attachment = upload("attachDataToLastStep", () -> attachmentRequests.upload(currentBuild.get().getId(), data, fileName)); + step.addAttachment(attachment.getId()); + return attachment; + } + + private interface Upload { + TestAttachment run() throws IOException, AnglesServerException; + } + + // Errors are raised the same way storeScreenshot raises them. + private TestAttachment upload(String method, Upload upload) { + try { + TestAttachment attachment = upload.run(); + if (attachment == null || attachment.getId() == null) { + throw new Error(method + ": Angles did not return the stored attachment."); + } + return attachment; + } catch (IOException | AnglesServerException exception) { + throw new Error("Unable to store attachment due to [" + exception.getMessage() + "]"); + } + } + + private CreateExecution requireExecution(String method) { + if (currentBuild.get() == null || currentBuild.get().getId() == null) { + throw new Error(method + ": start a build before attaching files."); + } + if (currentExecution.get() == null) { + throw new Error(method + ": start a test before attaching files."); + } + return currentExecution.get(); + } + + private Step requireLastStep(String method) { + requireExecution(method); + Action action = currentAction.get(); + List steps = action != null ? action.getSteps() : null; + if (steps == null || steps.isEmpty()) { + throw new Error(method + ": add a step (pass, fail, info, ...) before attaching a file to it."); + } + return steps.get(steps.size() - 1); + } + public String getBuildId() { if (this.currentBuild.get() != null) { return this.currentBuild.get().getId(); diff --git a/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporterEmpty.java b/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporterEmpty.java index 3a2a6a9..699c777 100644 --- a/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporterEmpty.java +++ b/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporterEmpty.java @@ -1,11 +1,13 @@ package com.github.angleshq.angles; import com.github.angleshq.angles.api.models.Platform; +import com.github.angleshq.angles.api.models.attachment.TestAttachment; import com.github.angleshq.angles.api.models.build.Artifact; import com.github.angleshq.angles.api.models.screenshot.ImageCompareResponse; import com.github.angleshq.angles.api.models.screenshot.Screenshot; import com.github.angleshq.angles.api.models.screenshot.ScreenshotDetails; +import java.io.File; import java.util.List; public class AnglesReporterEmpty implements AnglesReporterInterface { @@ -124,4 +126,34 @@ public String getBuildId() { // do nothing return null; } + + public TestAttachment attachFile(File file) { + // do nothing + return null; + } + + public TestAttachment attachFile(File file, String fileName) { + // do nothing + return null; + } + + public TestAttachment attachData(byte[] data, String fileName) { + // do nothing + return null; + } + + public TestAttachment attachFileToLastStep(File file) { + // do nothing + return null; + } + + public TestAttachment attachFileToLastStep(File file, String fileName) { + // do nothing + return null; + } + + public TestAttachment attachDataToLastStep(byte[] data, String fileName) { + // do nothing + return null; + } } diff --git a/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporterInterface.java b/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporterInterface.java index 0e86a27..d688293 100644 --- a/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporterInterface.java +++ b/angles-java-core/src/main/java/com/github/angleshq/angles/AnglesReporterInterface.java @@ -1,11 +1,13 @@ package com.github.angleshq.angles; import com.github.angleshq.angles.api.models.Platform; +import com.github.angleshq.angles.api.models.attachment.TestAttachment; import com.github.angleshq.angles.api.models.build.Artifact; import com.github.angleshq.angles.api.models.screenshot.ImageCompareResponse; import com.github.angleshq.angles.api.models.screenshot.Screenshot; import com.github.angleshq.angles.api.models.screenshot.ScreenshotDetails; +import java.io.File; import java.util.List; public interface AnglesReporterInterface { @@ -61,4 +63,27 @@ public interface AnglesReporterInterface { Screenshot storeScreenshot(ScreenshotDetails details); ImageCompareResponse compareScreenshotAgainstBaseline(String screenshotId); + + /** + * Uploads a file and attaches it to the current test: a video, a Playwright trace, a HAR + * file, a console log and so on. The extension decides how Angles shows it: .log/.txt, + * .json, .har, .webm/.mp4, .zip (a trace when the name contains "trace"), .html/.htm, + * or an image. + */ + TestAttachment attachFile(File file); + + /** As {@link #attachFile(File)}, shown under {@code fileName} (keep the extension). */ + TestAttachment attachFile(File file, String fileName); + + /** Attaches in-memory content to the current test. The extension of {@code fileName} decides how it is shown. */ + TestAttachment attachData(byte[] data, String fileName); + + /** Uploads a file and attaches it to the most recent step, e.g. the page HTML when an assertion failed. */ + TestAttachment attachFileToLastStep(File file); + + /** As {@link #attachFileToLastStep(File)}, shown under {@code fileName} (keep the extension). */ + TestAttachment attachFileToLastStep(File file, String fileName); + + /** Attaches in-memory content to the most recent step, e.g. {@code driver.getPageSource()} as "page.html". */ + TestAttachment attachDataToLastStep(byte[] data, String fileName); } diff --git a/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/attachment/TestAttachment.java b/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/attachment/TestAttachment.java new file mode 100644 index 0000000..47d82a1 --- /dev/null +++ b/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/attachment/TestAttachment.java @@ -0,0 +1,28 @@ +package com.github.angleshq.angles.api.models.attachment; + +import com.github.angleshq.angles.api.models.BaseModel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A file an automated test uploaded against its build: a log, HAR file, video, Playwright + * trace, HTML snapshot or image. Listing its id on the execution (or a step) shows it there. + */ +@Getter @Setter @NoArgsConstructor +public class TestAttachment extends BaseModel { + + /** + * What the file holds, decided by the server from the file extension: image, log, json, + * har, video, trace, archive or html. + */ + private String kind; + /** The uploaded file name, for display. */ + private String originalName; + /** The type the file is served with, decided from its extension. */ + private String mimeType; + private Long size; + private String build; + /** The execution that listed this attachment, once it has been saved. */ + private String execution; +} diff --git a/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/CreateExecution.java b/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/CreateExecution.java index 5c4fcad..5082de2 100644 --- a/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/CreateExecution.java +++ b/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/CreateExecution.java @@ -24,11 +24,17 @@ public class CreateExecution implements Serializable { /* prevent any modifications by other threads to cause an issue. */ private List actions = new CopyOnWriteArrayList<>(); private List platforms = new ArrayList<>(); + /* Ids of files attached to the whole test, e.g. a video, trace, HAR file or console log. */ + private List attachments = new CopyOnWriteArrayList<>(); public void addTag(String tag) { this.tags.add(tag); } + public void addAttachment(String attachmentId) { + this.attachments.add(attachmentId); + } + public void addAction(Action action) { this.actions.add(action); } diff --git a/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/Execution.java b/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/Execution.java index e20f9de..e5308e2 100644 --- a/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/Execution.java +++ b/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/Execution.java @@ -20,6 +20,8 @@ public class Execution extends BaseModel { private Date start; private List actions = new ArrayList<>(); private List platforms = new ArrayList<>(); + /* Ids of the files attached to the whole test. Step-level ones are on the steps. */ + private List attachments = new ArrayList<>(); public Execution(String build, String title, String suite, String feature,Date start) { this.build = build; diff --git a/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/Step.java b/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/Step.java index b5ad4cb..946b5b9 100644 --- a/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/Step.java +++ b/angles-java-core/src/main/java/com/github/angleshq/angles/api/models/execution/Step.java @@ -5,7 +5,9 @@ import lombok.NoArgsConstructor; import lombok.Setter; +import java.util.ArrayList; import java.util.Date; +import java.util.List; @Setter @Getter @NoArgsConstructor public class Step { @@ -17,6 +19,15 @@ public class Step { private StepStatus status; private Date timestamp; private String screenshot; + /** Ids of files attached to this step (see AnglesReporter.attachFileToLastStep). */ + private List attachments; + + public void addAttachment(String attachmentId) { + if (attachments == null) { + attachments = new ArrayList<>(); + } + attachments.add(attachmentId); + } public Step(String name, String info, StepStatus status, Date timestamp) { this.name = name; diff --git a/angles-java-core/src/main/java/com/github/angleshq/angles/api/requests/AttachmentRequests.java b/angles-java-core/src/main/java/com/github/angleshq/angles/api/requests/AttachmentRequests.java new file mode 100644 index 0000000..5988327 --- /dev/null +++ b/angles-java-core/src/main/java/com/github/angleshq/angles/api/requests/AttachmentRequests.java @@ -0,0 +1,47 @@ +package com.github.angleshq.angles.api.requests; + +import com.github.angleshq.angles.api.exceptions.AnglesServerException; +import com.github.angleshq.angles.api.models.attachment.TestAttachment; +import org.apache.http.HttpEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.MultipartEntityBuilder; + +import java.io.File; +import java.io.IOException; +import java.util.HashMap; + +/** + * Uploads files that an automated test produced (POST /build/{buildId}/attachment). + * + * The server decides how a file is shown from its extension, so keep the real one: + * .log/.txt, .json, .har, .webm/.mp4, .zip (a Playwright trace when the name contains + * "trace"), .html/.htm, .png/.jpg/.jpeg/.gif/.webp. + */ +public class AttachmentRequests extends BaseRequests { + + public AttachmentRequests(String baseUrl) { + super(baseUrl); + } + + public TestAttachment upload(String buildId, File file, String fileName) throws IOException, AnglesServerException { + HttpEntity entity = MultipartEntityBuilder.create() + .addBinaryBody("attachment", file, ContentType.APPLICATION_OCTET_STREAM, + fileName != null ? fileName : file.getName()) + .build(); + return send(buildId, entity); + } + + public TestAttachment upload(String buildId, byte[] data, String fileName) throws IOException, AnglesServerException { + HttpEntity entity = MultipartEntityBuilder.create() + .addBinaryBody("attachment", data, ContentType.APPLICATION_OCTET_STREAM, fileName) + .build(); + return send(buildId, entity); + } + + private TestAttachment send(String buildId, HttpEntity entity) throws IOException, AnglesServerException { + try (CloseableHttpResponse response = sendMultiPartEntity("build/" + buildId + "/attachment", new HashMap<>(), entity)) { + return processResponse(response, TestAttachment.class); + } + } +} diff --git a/angles-java-core/src/main/java/com/github/angleshq/angles/api/requests/BaseRequests.java b/angles-java-core/src/main/java/com/github/angleshq/angles/api/requests/BaseRequests.java index b8e58d6..38fb097 100644 --- a/angles-java-core/src/main/java/com/github/angleshq/angles/api/requests/BaseRequests.java +++ b/angles-java-core/src/main/java/com/github/angleshq/angles/api/requests/BaseRequests.java @@ -133,19 +133,26 @@ protected CloseableHttpResponse sendJSONPut(String path, Object message) throws } protected String getDefaultErrorMessage(CloseableHttpResponse response) { - String errorMessage = ""; + // The body can only be read once, so it is read here and parsed from the string. + String bodyString; + try { + bodyString = EntityUtils.toString(response.getEntity()); + } catch (Exception exception) { + return "Unable to extract error message from response due to [" + exception.getMessage() + "]"; + } try { - String bodyString = EntityUtils.toString(response.getEntity()); JsonObject jsonResponse = gson.fromJson(bodyString, JsonObject.class); - errorMessage = jsonResponse.get("message").toString(); - } catch (IOException | JsonSyntaxException exc) { - try { - errorMessage = EntityUtils.toString(response.getEntity()); - } catch (Exception exception) { - errorMessage = "Unable to extract error message from response due to [" + exception.getMessage() + "]"; + // Most endpoints answer {"message": ...}; upload rejections answer {"error": ...}. + if (jsonResponse != null && jsonResponse.has("message")) { + return jsonResponse.get("message").toString(); + } + if (jsonResponse != null && jsonResponse.has("error")) { + return jsonResponse.get("error").toString(); } + } catch (JsonSyntaxException | IllegalStateException exception) { + // not JSON (or not an object): fall back to the raw body below } - return errorMessage; + return bodyString; } protected T processResponse(CloseableHttpResponse response, Class responseClass) throws IOException, AnglesServerException {