├── .github ├── CODEOWNERS ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── cd.yaml │ └── jenkins-security-scan.yml ├── .gitignore ├── .mvn ├── extensions.xml └── maven.config ├── Jenkinsfile ├── LICENSE.md ├── README.md ├── images ├── image-attachment.png └── junit-attachments.png ├── pom.xml └── src ├── main ├── java │ └── hudson │ │ └── plugins │ │ └── junitattachments │ │ ├── AttachmentPublisher.java │ │ ├── AttachmentTestAction.java │ │ ├── GetTestDataMethodObject.java │ │ ├── TestCaseAttachmentTestAction.java │ │ └── TestClassAttachmentTestAction.java └── resources │ ├── hudson │ └── plugins │ │ └── junitattachments │ │ ├── AttachmentPublisher │ │ └── config.jelly │ │ ├── AttachmentTestAction │ │ └── sidepanel.jelly │ │ ├── TestCaseAttachmentTestAction │ │ └── summary.jelly │ │ └── TestClassAttachmentTestAction │ │ └── summary.jelly │ └── index.jelly └── test ├── java └── hudson │ └── plugins │ └── junitattachments │ ├── AttachmentPublisherPipelineTest.java │ └── AttachmentPublisherTest.java └── resources └── hudson └── plugins └── junitattachments ├── pipelineTest.groovy ├── workspace.zip ├── workspace2.zip ├── workspace3.zip ├── workspace4.zip └── workspace5.zip /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jenkinsci/junit-attachments-plugin-developers 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: maven 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: monthly 11 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | _extends: .github 2 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | # Note: additional setup is required, see https://www.jenkins.io/redirect/continuous-delivery-of-plugins 2 | 3 | name: cd 4 | on: 5 | workflow_dispatch: 6 | check_run: 7 | types: 8 | - completed 9 | 10 | jobs: 11 | maven-cd: 12 | uses: jenkins-infra/github-reusable-workflows/.github/workflows/maven-cd.yml@v1 13 | secrets: 14 | MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} 15 | MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/jenkins-security-scan.yml: -------------------------------------------------------------------------------- 1 | name: Jenkins Security Scan 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [ opened, synchronize, reopened ] 9 | workflow_dispatch: 10 | 11 | permissions: 12 | security-events: write 13 | contents: read 14 | actions: read 15 | 16 | jobs: 17 | security-scan: 18 | uses: jenkins-infra/jenkins-security-scan/.github/workflows/jenkins-security-scan.yaml@v2 19 | with: 20 | java-cache: 'maven' # Optionally enable use of a build dependency cache. Specify 'maven' or 'gradle' as appropriate. 21 | # java-version: 21 # Optionally specify what version of Java to set up for the build, or remove to use a recent default. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .classpath 2 | .project 3 | .settings 4 | target 5 | work 6 | .idea 7 | junit-attachments.iml -------------------------------------------------------------------------------- /.mvn/extensions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | io.jenkins.tools.incrementals 4 | git-changelist-maven-extension 5 | 1.8 6 | 7 | 8 | -------------------------------------------------------------------------------- /.mvn/maven.config: -------------------------------------------------------------------------------- 1 | -Pconsume-incrementals 2 | -Pmight-produce-incrementals 3 | -Dchangelist.format=%d.v%s 4 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | /* 2 | See the documentation for more options: 3 | https://github.com/jenkins-infra/pipeline-library/ 4 | */ 5 | buildPlugin( 6 | forkCount: '1C', // run this number of tests in parallel for faster feedback. If the number terminates with a 'C', the value will be multiplied by the number of available CPU cores 7 | useContainerAgent: true, // Set to `false` if you need to use Docker for containerized tests 8 | configurations: [ 9 | [platform: 'linux', jdk: 21], 10 | [platform: 'windows', jdk: 17], 11 | ]) 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2023 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JUnit Attachments Plugin 2 | 3 | [![Build Status](https://ci.jenkins.io/job/Plugins/job/junit-attachments-plugin/job/main/badge/icon)](https://ci.jenkins.io/job/Plugins/job/junit-attachments-plugin/job/main/) 4 | [![Jenkins Plugin](https://img.shields.io/jenkins/plugin/v/junit-attachments.svg)](https://plugins.jenkins.io/junit-attachments) 5 | [![GitHub release](https://img.shields.io/github/release/jenkinsci/junit-attachments-plugin.svg?label=changelog)](https://github.com/jenkinsci/junit-attachments-plugin/releases/latest) 6 | [![GitHub license](https://img.shields.io/github/license/jenkinsci/junit-attachments-plugin)](https://github.com/jenkinsci/junit-attachments-plugin/blob/main/LICENSE.md) 7 | 8 | This plugin can archive certain files (attachments) together with your JUnit results. 9 | ![](images/junit-attachments.png) 10 | 11 | Attached files are shown in the JUnit results. 12 | 13 | Image attachments are shown inline. 14 | 15 | ![](images/image-attachment.png) 16 | 17 | To activate this plugin, configure your job with "Additional test report features" and select "Publish test attachments". 18 | 19 | ## How to attach files 20 | ### By putting them into a known location 21 | 22 | One way to do this is to produce files from your tests into a known location. 23 | 24 | * Jenkins looks for the JUnit XML report. 25 | * Then it looks for a directory with the name of the test class, in the same directory as the XML report. 26 | * Every file in this directory will be archived as an attachment to that test class. 27 | 28 | #### Example: 29 | 30 | * test report in `.../target/surefire-reports/TEST-foo.bar.MyTest.xml` 31 | * test class is `foo.bar.MyTest` 32 | * test attachment directory: `.../target/surefire-reports/foo.bar.MyTest/` 33 | 34 | ### By printing out the file name in a format that Jenkins will understand 35 | 36 | The above mechanism has a problem that your test needs to know about where your test driver is producing reports to. This 2nd approach eliminates that problem by simply letting you print out arbitrary file names to stdout/stderr in the following format: 37 | 38 | `[[ATTACHMENT|/absolute/path/to/some/file]]` 39 | 40 | Each `ATTACHMENT` should be on its own line, without any text before or after. 41 | See [Kohsuke's post](https://kohsuke.org/2012/03/13/attaching-files-to-junit-tests/) for more details. 42 | 43 | ## License 44 | 45 | Licensed under MIT, see [LICENSE](LICENSE.md) 46 | -------------------------------------------------------------------------------- /images/image-attachment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/junit-attachments-plugin/2d3169b5d0d1b4be3b272de42c4dd4dcf71e113f/images/image-attachment.png -------------------------------------------------------------------------------- /images/junit-attachments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/junit-attachments-plugin/2d3169b5d0d1b4be3b272de42c4dd4dcf71e113f/images/junit-attachments.png -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4.0.0 3 | 4 | 5 | org.jenkins-ci.plugins 6 | plugin 7 | 5.17 8 | 9 | 10 | 11 | junit-attachments 12 | ${changelist} 13 | hpi 14 | JUnit Attachments Plugin 15 | https://github.com/jenkinsci/junit-attachments-plugin 16 | 17 | 18 | scm:git:https://github.com/${gitHubRepo}.git 19 | scm:git:git@github.com:${gitHubRepo}.git 20 | https://github.com/${gitHubRepo} 21 | ${scmTag} 22 | 23 | 24 | 25 | 999999-SNAPSHOT 26 | 27 | 2.479 28 | ${jenkins.baseline}.3 29 | jenkinsci/${project.artifactId}-plugin 30 | 31 | 32 | 33 | 34 | io.jenkins.tools.bom 35 | bom-${jenkins.baseline}.x 36 | 4862.vc32a_71c3e731 37 | pom 38 | import 39 | 40 | 41 | 42 | 43 | 44 | org.jenkins-ci.plugins 45 | junit 46 | 47 | 48 | 49 | 50 | org.jenkins-ci.plugins.workflow 51 | workflow-api 52 | test 53 | 54 | 55 | org.jenkins-ci.plugins.workflow 56 | workflow-job 57 | test 58 | 59 | 60 | org.jenkins-ci.plugins.workflow 61 | workflow-basic-steps 62 | test 63 | 64 | 65 | org.jenkins-ci.plugins.workflow 66 | workflow-cps 67 | test 68 | 69 | 70 | org.jenkins-ci.plugins.workflow 71 | workflow-durable-task-step 72 | test 73 | 74 | 75 | org.jenkins-ci.plugins.workflow 76 | workflow-support 77 | test 78 | 79 | 80 | org.jenkins-ci.plugins.workflow 81 | workflow-support 82 | tests 83 | test 84 | 85 | 86 | 87 | 88 | 89 | repo.jenkins-ci.org 90 | https://repo.jenkins-ci.org/public/ 91 | 92 | 93 | 94 | 95 | 96 | repo.jenkins-ci.org 97 | https://repo.jenkins-ci.org/public/ 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /src/main/java/hudson/plugins/junitattachments/AttachmentPublisher.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.junitattachments; 2 | 3 | import hudson.model.Run; 4 | import hudson.model.TaskListener; 5 | import org.apache.commons.lang.StringUtils; 6 | import org.jenkinsci.Symbol; 7 | import org.kohsuke.stapler.DataBoundConstructor; 8 | 9 | import hudson.Extension; 10 | import hudson.FilePath; 11 | import hudson.Launcher; 12 | import hudson.model.Descriptor; 13 | import hudson.tasks.junit.TestAction; 14 | import hudson.tasks.junit.TestDataPublisher; 15 | import hudson.tasks.junit.TestResult; 16 | import hudson.tasks.junit.TestResultAction; 17 | import hudson.tasks.junit.CaseResult; 18 | import hudson.tasks.junit.ClassResult; 19 | import hudson.tasks.test.TestObject; 20 | import org.kohsuke.stapler.DataBoundSetter; 21 | 22 | import java.io.File; 23 | import java.io.IOException; 24 | import java.util.Collections; 25 | import java.util.HashMap; 26 | import java.util.List; 27 | import java.util.Map; 28 | import java.util.TreeMap; 29 | 30 | public class AttachmentPublisher extends TestDataPublisher { 31 | 32 | private Boolean showAttachmentsAtClassLevel = true; 33 | private Boolean showAttachmentsInStdOut = true; 34 | 35 | @DataBoundConstructor 36 | public AttachmentPublisher() { 37 | } 38 | 39 | public boolean isShowAttachmentsAtClassLevel() { 40 | return showAttachmentsAtClassLevel != null ? showAttachmentsAtClassLevel : true; 41 | } 42 | 43 | public boolean isShowAttachmentsInStdOut() { 44 | return showAttachmentsInStdOut != null ? showAttachmentsInStdOut : true; 45 | } 46 | 47 | @DataBoundSetter 48 | public void setShowAttachmentsAtClassLevel(Boolean showAttachmentsAtClassLevel) { 49 | this.showAttachmentsAtClassLevel = showAttachmentsAtClassLevel; 50 | } 51 | 52 | @DataBoundSetter 53 | public void setShowAttachmentsInStdOut(Boolean showAttachmentsInStdOut) { 54 | this.showAttachmentsInStdOut = showAttachmentsInStdOut; 55 | } 56 | 57 | public static FilePath getAttachmentPath(Run build) { 58 | return new FilePath(new File(build.getRootDir().getAbsolutePath())) 59 | .child("junit-attachments"); 60 | } 61 | 62 | public static FilePath getAttachmentPath(FilePath root, String className, String testName) { 63 | FilePath dir = root; 64 | if (!StringUtils.isEmpty(className)) { 65 | dir = dir.child(TestObject.safe(className)); 66 | 67 | if (!StringUtils.isEmpty(testName)) { 68 | dir = dir.child(TestObject.safe(testName).replace("\"", "")); 69 | } 70 | } 71 | return dir; 72 | } 73 | 74 | @Override 75 | public Data contributeTestData(Run build, FilePath workspace, Launcher launcher, 76 | TaskListener listener, TestResult testResult) throws IOException, 77 | InterruptedException { 78 | final GetTestDataMethodObject methodObject = new GetTestDataMethodObject(build, workspace, launcher, listener, testResult); 79 | Map>> attachments = methodObject.getAttachments(); 80 | 81 | if (attachments.isEmpty()) { 82 | return null; 83 | } 84 | 85 | return new Data(attachments, isShowAttachmentsAtClassLevel(), isShowAttachmentsInStdOut()); 86 | } 87 | 88 | public static class Data extends TestResultAction.Data { 89 | 90 | @Deprecated 91 | private transient Map> attachments; 92 | private Map>> attachmentsMap; 93 | private Boolean showAttachmentsAtClassLevel; 94 | private Boolean showAttachmentsInStdOut; 95 | 96 | /** 97 | * @param attachmentsMap { fully-qualified test class name → { test method name → [ attachment file name ] } } 98 | * @param showAttachmentsAtClassLevel Whether to display test case attachments at the test class level 99 | */ 100 | public Data( 101 | Map>> attachmentsMap, 102 | Boolean showAttachmentsAtClassLevel, 103 | Boolean showAttachmentsInStdOut) { 104 | this.attachmentsMap = attachmentsMap; 105 | this.showAttachmentsAtClassLevel = showAttachmentsAtClassLevel; 106 | this.showAttachmentsInStdOut = showAttachmentsInStdOut; 107 | } 108 | 109 | @Override 110 | @SuppressWarnings("deprecation") 111 | public List getTestAction(hudson.tasks.junit.TestObject t) { 112 | TestObject testObject = (TestObject) t; 113 | 114 | final String packageName; 115 | final String className; 116 | final String testName; 117 | 118 | if (testObject instanceof ClassResult) { 119 | // We're looking at the page for a test class (i.e. a single TestCase) 120 | if (!showAttachmentsAtClassLevel) { 121 | return Collections.emptyList(); 122 | } 123 | 124 | packageName = testObject.getParent().getName(); 125 | className = testObject.getName(); 126 | testName = null; 127 | } else if (testObject instanceof CaseResult) { 128 | // We're looking at the page for an individual test (i.e. a single @Test method) 129 | packageName = testObject.getParent().getParent().getName(); 130 | className = testObject.getParent().getName(); 131 | testName = testObject.getName(); 132 | } else { 133 | // Otherwise, we don't want to show any attachments (e.g. at the package level) 134 | return Collections.emptyList(); 135 | } 136 | 137 | // Determine the fully-qualified test class (i.e. com.example.foo.MyTestCase) 138 | String fullName = getFullyQualifiedTestClassName(packageName, className); 139 | 140 | // Get the mapping of individual test -> attachment names 141 | Map> tests = attachmentsMap.get(fullName); 142 | if (tests == null) { 143 | return Collections.emptyList(); 144 | } 145 | 146 | FilePath root = getAttachmentPath(testObject.getRun()); 147 | // Historical builds might have attachments stored in class level directories 148 | boolean attachmentsStoredAtClassLevel = areAttachmentsStoredAtClassLevel(root, fullName, tests); 149 | 150 | // Return a single TestAction which will display the attached files 151 | AttachmentTestAction action; 152 | if (testObject instanceof ClassResult) { 153 | // Ensure attachments are shown in the same order as the tests 154 | TreeMap> sortedTests = new TreeMap>(tests); 155 | 156 | action = new TestClassAttachmentTestAction( 157 | (ClassResult) testObject, 158 | getAttachmentPath(root, fullName, null), 159 | sortedTests, 160 | attachmentsStoredAtClassLevel); 161 | } 162 | else { 163 | List attachmentPaths = tests.get(testName); 164 | if (attachmentPaths == null || attachmentPaths.isEmpty()) { 165 | return Collections.emptyList(); 166 | } 167 | 168 | FilePath attachmentsDirectory = attachmentsStoredAtClassLevel ? 169 | getAttachmentPath(root, fullName, null) : 170 | getAttachmentPath(root, fullName, testName); 171 | 172 | action = new TestCaseAttachmentTestAction( 173 | (CaseResult) testObject, attachmentsDirectory, attachmentPaths, showAttachmentsInStdOut); 174 | } 175 | 176 | return Collections. singletonList(action); 177 | } 178 | 179 | /** Handles migration from the old serialisation format. */ 180 | private Object readResolve() { 181 | if (this.showAttachmentsAtClassLevel == null) { 182 | this.showAttachmentsAtClassLevel = true; 183 | } 184 | 185 | if (this.showAttachmentsInStdOut == null) { 186 | this.showAttachmentsInStdOut = true; 187 | } 188 | 189 | if (attachments != null && attachmentsMap == null) { 190 | // Migrate from the flat list per test class to a map of 191 | attachmentsMap = new HashMap>>(); 192 | 193 | // Previously, there was no mapping between individual tests and their attachments, 194 | // so here we just associate all attachments with an empty-named test method. 195 | // 196 | // This means that all attachments will appear on the test class page as before, 197 | // but they won't also be repeated on each individual test method's page 198 | for (Map.Entry> entry : attachments.entrySet()) { 199 | HashMap> testMap = new HashMap>(); 200 | testMap.put("", entry.getValue()); 201 | attachmentsMap.put(entry.getKey(), testMap); 202 | } 203 | attachments = null; 204 | } 205 | 206 | return this; 207 | } 208 | 209 | private static String getFullyQualifiedTestClassName(String packageName, String className) { 210 | String fullName = ""; 211 | if (!packageName.equals("(root)")) { 212 | fullName += packageName; 213 | fullName += "."; 214 | } 215 | fullName += className; 216 | 217 | return fullName; 218 | } 219 | 220 | private boolean areAttachmentsStoredAtClassLevel( 221 | FilePath root, String fullName, Map> classAttachments) { 222 | 223 | for (Map.Entry> entry : classAttachments.entrySet()) { 224 | for (String attachment : entry.getValue()) { 225 | FilePath testCaseAttachmentsDirectory = getAttachmentPath(root, fullName, entry.getKey()); 226 | var testCaseAttachmentPath = new FilePath(testCaseAttachmentsDirectory, attachment); 227 | try { 228 | if (testCaseAttachmentPath.exists()) { 229 | return false; 230 | } 231 | } catch (IOException | InterruptedException e) { 232 | throw new RuntimeException(e); 233 | } 234 | } 235 | } 236 | 237 | return true; 238 | } 239 | } 240 | 241 | @Extension 242 | @Symbol("attachments") 243 | public static class DescriptorImpl extends Descriptor { 244 | 245 | @Override 246 | public String getDisplayName() { 247 | return "Publish test attachments"; 248 | } 249 | 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/main/java/hudson/plugins/junitattachments/AttachmentTestAction.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.junitattachments; 2 | 3 | import hudson.FilePath; 4 | import hudson.model.DirectoryBrowserSupport; 5 | import hudson.tasks.junit.TestAction; 6 | import hudson.tasks.test.TestObject; 7 | 8 | public abstract class AttachmentTestAction extends TestAction { 9 | 10 | final FilePath storage; 11 | final TestObject testObject; 12 | 13 | public AttachmentTestAction(TestObject testObject, FilePath storage) { 14 | this.storage = storage; 15 | this.testObject = testObject; 16 | } 17 | 18 | public String getDisplayName() { 19 | return "Attachments"; 20 | } 21 | 22 | public String getIconFileName() { 23 | return "symbol-cube"; 24 | } 25 | 26 | public String getUrlName() { 27 | return "attachments"; 28 | } 29 | 30 | public DirectoryBrowserSupport doDynamic() { 31 | return new DirectoryBrowserSupport(this, storage, "Attachments", "symbol-cube", true); 32 | } 33 | 34 | public TestObject getTestObject() { 35 | return testObject; 36 | } 37 | 38 | public static boolean isImageFile(String filename) { 39 | return filename.matches("(?i).+\\.(gif|jpe?g|png)$"); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/hudson/plugins/junitattachments/GetTestDataMethodObject.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2010-2011 Mirko Friedenhagen, Kohsuke Kawaguchi 3 | */ 4 | 5 | package hudson.plugins.junitattachments; 6 | 7 | import hudson.FilePath; 8 | import hudson.Launcher; 9 | import hudson.Util; 10 | import hudson.model.AbstractBuild; 11 | import hudson.model.Run; 12 | import hudson.model.TaskListener; 13 | import hudson.tasks.junit.CaseResult; 14 | import hudson.tasks.junit.SuiteResult; 15 | import hudson.tasks.junit.TestResult; 16 | import org.apache.tools.ant.DirectoryScanner; 17 | 18 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 19 | import edu.umd.cs.findbugs.annotations.NonNull; 20 | import java.io.IOException; 21 | import java.util.ArrayList; 22 | import java.util.Arrays; 23 | import java.util.HashMap; 24 | import java.util.List; 25 | import java.util.Map; 26 | import java.util.logging.Logger; 27 | import java.util.regex.Matcher; 28 | import java.util.regex.Pattern; 29 | 30 | /** 31 | * This class is a helper for {@code hudson.tasks.junit.TestDataPublisher.getTestData(AbstractBuild, Launcher, 32 | * BuildListener, TestResult)}. 33 | * 34 | * @author mfriedenhagen 35 | * @author Kohsuke Kawaguchi 36 | */ 37 | public class GetTestDataMethodObject { 38 | 39 | /** Our logger. */ 40 | private static final Logger LOG = Logger.getLogger(GetTestDataMethodObject.class.getName()); 41 | 42 | /** the build to inspect. */ 43 | private final Run build; 44 | 45 | /** the test results associated with the build. */ 46 | private final TestResult testResult; 47 | 48 | /** 49 | * Map from class names to a list of attachment path names on the controller. 50 | * The path names are relative to the {@linkplain #getAttachmentStorageFor(String) class-specific attachment storage} 51 | */ 52 | private final Map>> attachments = new HashMap>>(); 53 | private final FilePath attachmentsStorage; 54 | private final TaskListener listener; 55 | 56 | /** 57 | * The workspace to check in for attachments. 58 | */ 59 | private final FilePath workspace; 60 | 61 | /** 62 | * @param build 63 | * see {@link GetTestDataMethodObject#build} 64 | * @param testResult 65 | * see {@link GetTestDataMethodObject#testResult} 66 | */ 67 | @Deprecated 68 | public GetTestDataMethodObject(AbstractBuild build, @SuppressWarnings("unused") Launcher launcher, 69 | TaskListener listener, TestResult testResult) { 70 | this.build = build; 71 | this.testResult = testResult; 72 | this.listener = listener; 73 | attachmentsStorage = AttachmentPublisher.getAttachmentPath(build); 74 | workspace = build.getWorkspace(); 75 | } 76 | 77 | /** 78 | * @param build 79 | * see {@link GetTestDataMethodObject#build} 80 | * @param testResult 81 | * see {@link GetTestDataMethodObject#testResult} 82 | */ 83 | public GetTestDataMethodObject(Run build, @NonNull FilePath workspace, 84 | @SuppressWarnings("unused") Launcher launcher, 85 | TaskListener listener, TestResult testResult) { 86 | this.build = build; 87 | this.testResult = testResult; 88 | this.listener = listener; 89 | attachmentsStorage = AttachmentPublisher.getAttachmentPath(build); 90 | this.workspace = workspace; 91 | } 92 | 93 | /** 94 | * Returns a Map of classname vs. the stored attachments in a directory named as the test class. 95 | * 96 | * @return the map 97 | * @throws InterruptedException 98 | * @throws IOException 99 | * @throws IllegalStateException 100 | * @throws InterruptedException 101 | * 102 | */ 103 | public Map>> getAttachments() throws IllegalStateException, IOException, InterruptedException { 104 | // build a map of className -> result xml file 105 | Map reports = getReports(); 106 | LOG.fine("reports: " + reports); 107 | for (Map.Entry report : reports.entrySet()) { 108 | final String className = report.getKey(); 109 | final FilePath reportFile = workspace.child(report.getValue()); 110 | final FilePath target = AttachmentPublisher.getAttachmentPath(attachmentsStorage, className, null); 111 | attachFilesForReport(className, reportFile, target); 112 | attachStdInAndOut(className, reportFile); 113 | } 114 | return attachments; 115 | } 116 | 117 | @SuppressFBWarnings(value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE", justification = "TODO needs triage") 118 | private void attachFilesForReport(final String className, final FilePath reportFile, final FilePath target) 119 | throws IOException, InterruptedException { 120 | final FilePath testDir = reportFile.getParent().child(className); 121 | if (testDir.exists()) { 122 | target.mkdirs(); 123 | if (testDir.copyRecursiveTo(target) > 0) { 124 | DirectoryScanner d = new DirectoryScanner(); 125 | d.setBasedir(target.getRemote()); 126 | d.scan(); 127 | 128 | // Associate any included files with the test class, rather than an individual test case 129 | Map> tests = attachments.getOrDefault(className, new HashMap>()); 130 | tests.put("", new ArrayList(Arrays.asList(d.getIncludedFiles()))); 131 | attachments.put(className, tests); 132 | } 133 | } 134 | } 135 | 136 | /** 137 | * Creates a map of the all classNames to their corresponding result file. 138 | */ 139 | private Map getReports() throws IOException, InterruptedException { 140 | Map reports = new HashMap(); 141 | for (SuiteResult suiteResult : testResult.getSuites()) { 142 | String f = suiteResult.getFile(); 143 | if (f != null) { 144 | for (String className : suiteResult.getClassNames()) { 145 | reports.put(className, f); 146 | } 147 | } 148 | 149 | // Due to the way that CaseResult.getStd(out|err) works, we need to compare each test 150 | // cases's output with the test suite's output to determine if its output is unique 151 | String suiteStdout = Util.fixNull(suiteResult.getStdout()); 152 | String suiteStderr = Util.fixNull(suiteResult.getStderr()); 153 | 154 | for (CaseResult cr : suiteResult.getCases()) { 155 | String stdout = Util.fixNull(cr.getStdout()); 156 | String caseStdout = suiteStdout.equals(stdout) ? null : stdout; 157 | 158 | String stderr = Util.fixNull(cr.getStderr()); 159 | String caseStderr = suiteStderr.equals(stderr) ? null : stderr; 160 | 161 | // Add a newline so that we detect attachments if stdout has no trailing newline 162 | // and stderr is null (as otherwise we'd try and parse "[[ATTACHMENT|foo]]null") 163 | findAttachmentsInOutput(cr.getClassName(), cr.getName(), caseStdout + "\n" + caseStderr); 164 | } 165 | 166 | // Capture stdout and stderr for the testsuite as a whole, if they exist 167 | findAttachmentsInOutput(suiteResult.getName(), null, suiteStdout); 168 | findAttachmentsInOutput(suiteResult.getName(), null, suiteStderr); 169 | } 170 | return reports; 171 | } 172 | 173 | /** 174 | * Finds attachments from a test's stdout/stderr, i.e. instances of: 175 | *
[[ATTACHMENT|/path/to/attached-file.xyz|...reserved...]]
176 | */ 177 | private void findAttachmentsInOutput(String className, String testName, String output) throws IOException, InterruptedException { 178 | if (Util.fixEmpty(output) == null) { 179 | return; 180 | } 181 | 182 | Matcher matcher = ATTACHMENT_PATTERN.matcher(output); 183 | while (matcher.find()) { 184 | String line = matcher.group().trim(); // Be more tolerant about where ATTACHMENT lines start/end 185 | // compute the file name 186 | line = line.substring(PREFIX.length(), line.length() - SUFFIX.length()); 187 | int idx = line.indexOf('|'); 188 | if (idx >= 0) { 189 | line = line.substring(0, idx); 190 | } 191 | 192 | String fileName = line; 193 | if (fileName != null) { 194 | FilePath src = workspace.child(fileName); // even though we use child(), this should be absolute 195 | if (src.isDirectory()) { 196 | listener.getLogger().println("Attachment " + fileName + " was referenced from the test '" + className + "' but it is a directory, not a file. Skipping."); 197 | } else if (src.exists()) { 198 | captureAttachment(className, testName, src); 199 | } else { 200 | listener.getLogger().println("Attachment "+fileName+" was referenced from the test '"+className+"' but it doesn't exist. Skipping."); 201 | } 202 | } 203 | } 204 | } 205 | 206 | private static final String PREFIX = "[[ATTACHMENT|"; 207 | private static final String SUFFIX = "]]"; 208 | private static final Pattern ATTACHMENT_PATTERN = Pattern.compile("\\[\\[ATTACHMENT\\|.+\\]\\]"); 209 | 210 | @SuppressFBWarnings(value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE", justification = "TODO needs triage") 211 | private void attachStdInAndOut(String className, FilePath reportFile) 212 | throws IOException, InterruptedException { 213 | final FilePath stdInAndOut = reportFile.getParent().child( 214 | className + "-output.txt"); 215 | LOG.fine("stdInAndOut: " + stdInAndOut.absolutize()); 216 | if (stdInAndOut.exists()) { 217 | captureAttachment(className, stdInAndOut); 218 | } 219 | } 220 | 221 | /** 222 | * Captures a single file as an attachment by copying it and recording it. 223 | * 224 | * @param src 225 | * File on the build workspace to be copied back to the controller and captured. 226 | */ 227 | private void captureAttachment(String className, FilePath src) throws IOException, InterruptedException { 228 | captureAttachment(className, null, src); 229 | } 230 | 231 | private void captureAttachment(String className, String testName, FilePath src) throws IOException, InterruptedException { 232 | Map> tests = attachments.get(className); 233 | if (tests == null) { 234 | tests = new HashMap>(); 235 | attachments.put(className, tests); 236 | } 237 | List testFiles = tests.get(Util.fixNull(testName)); 238 | if (testFiles == null) { 239 | testFiles = new ArrayList(); 240 | tests.put(Util.fixNull(testName), testFiles); 241 | } 242 | 243 | String filename = src.getName(); 244 | if (!testFiles.contains(filename)) { 245 | // Only need to copy the file if it hasn't already been handled for this test class 246 | FilePath target = AttachmentPublisher.getAttachmentPath(attachmentsStorage, className, testName); 247 | target.mkdirs(); 248 | FilePath dst = new FilePath(target, filename); 249 | src.copyTo(dst); 250 | testFiles.add(filename); 251 | } 252 | } 253 | 254 | /** Determines whether the given mapping for a test class contains a certain filename. */ 255 | private static boolean containsFilename(Map> map, String filename) { 256 | for (List list : map.values()) { 257 | if (list.contains(filename)) { 258 | return true; 259 | } 260 | } 261 | return false; 262 | } 263 | 264 | } 265 | -------------------------------------------------------------------------------- /src/main/java/hudson/plugins/junitattachments/TestCaseAttachmentTestAction.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.junitattachments; 2 | 3 | import hudson.FilePath; 4 | import hudson.Util; 5 | import hudson.tasks.junit.CaseResult; 6 | import jenkins.model.Jenkins; 7 | 8 | import java.util.List; 9 | import java.util.regex.Pattern; 10 | 11 | public class TestCaseAttachmentTestAction extends AttachmentTestAction { 12 | 13 | private static final Pattern ATTACHMENT_PATTERN = Pattern.compile("\\[\\[ATTACHMENT\\|.+]]"); 14 | 15 | private final List attachments; 16 | private final boolean showAttachmentsInStdOut; 17 | 18 | public TestCaseAttachmentTestAction( 19 | CaseResult caseResult, FilePath storage, List attachments, boolean showAttachmentsInStdOut) { 20 | super(caseResult, storage); 21 | 22 | this.attachments = attachments; 23 | this.showAttachmentsInStdOut = showAttachmentsInStdOut; 24 | } 25 | 26 | public List getAttachments() { 27 | return attachments; 28 | } 29 | 30 | @Override 31 | public String annotate(String text) { 32 | 33 | if (!showAttachmentsInStdOut) { 34 | text = ATTACHMENT_PATTERN.matcher(text).replaceAll("").stripTrailing(); 35 | } 36 | 37 | String url = Jenkins.get().getRootUrl() + testObject.getUrl() + "/attachments/"; 38 | for (String attachment : attachments) { 39 | text = text.replace(attachment, "" + attachment + ""); 41 | } 42 | 43 | return text; 44 | } 45 | 46 | public static String getUrl(String filename) { 47 | return "attachments/" + Util.rawEncode(filename); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/hudson/plugins/junitattachments/TestClassAttachmentTestAction.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.junitattachments; 2 | 3 | import hudson.FilePath; 4 | import hudson.Util; 5 | import hudson.tasks.junit.ClassResult; 6 | 7 | import java.util.List; 8 | import java.util.Map; 9 | 10 | public class TestClassAttachmentTestAction extends AttachmentTestAction { 11 | 12 | private final Map> attachments; 13 | private final boolean attachmentsStoredAtClassLevel; 14 | 15 | public TestClassAttachmentTestAction( 16 | ClassResult classResult, 17 | FilePath storage, 18 | Map> attachments, 19 | boolean attachmentsStoredAtClassLevel) { 20 | super(classResult, storage); 21 | 22 | this.attachments = attachments; 23 | this.attachmentsStoredAtClassLevel = attachmentsStoredAtClassLevel; 24 | } 25 | 26 | public Map> getAttachments() { 27 | return attachments; 28 | } 29 | 30 | public String getUrl(String testCase, String filename) { 31 | if (this.attachmentsStoredAtClassLevel) { 32 | return "attachments/" + Util.rawEncode(filename); 33 | } 34 | 35 | return "attachments/" + Util.rawEncode(testCase) + "/" + Util.rawEncode(filename); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/resources/hudson/plugins/junitattachments/AttachmentPublisher/config.jelly: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/main/resources/hudson/plugins/junitattachments/AttachmentTestAction/sidepanel.jelly: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/main/resources/hudson/plugins/junitattachments/TestCaseAttachmentTestAction/summary.jelly: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

${%Attachments}

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 |
${%Files}
15 | ${attachment} 18 |
22 | 23 |
24 | -------------------------------------------------------------------------------- /src/main/resources/hudson/plugins/junitattachments/TestClassAttachmentTestAction/summary.jelly: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

${%Attachments}

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 23 | 24 | 25 | 26 |
${%Test Case}${%Files}
${entry.key} 18 | 19 | ${file} 22 |
27 | 28 |
29 | -------------------------------------------------------------------------------- /src/main/resources/index.jelly: -------------------------------------------------------------------------------- 1 | 2 |
3 | This plugin can archive certain files (attachments) together with your JUnit results. 4 | Attached files are shown in the JUnit results. 5 |
6 | -------------------------------------------------------------------------------- /src/test/java/hudson/plugins/junitattachments/AttachmentPublisherPipelineTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2016, CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | * 24 | */ 25 | package hudson.plugins.junitattachments; 26 | 27 | import hudson.FilePath; 28 | import hudson.model.Result; 29 | import hudson.tasks.junit.CaseResult; 30 | import hudson.tasks.junit.ClassResult; 31 | import hudson.tasks.junit.TestResultAction; 32 | import hudson.tasks.test.TestResult; 33 | import org.apache.commons.io.IOUtils; 34 | import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; 35 | import org.jenkinsci.plugins.workflow.job.WorkflowJob; 36 | import org.jenkinsci.plugins.workflow.job.WorkflowRun; 37 | import org.junit.jupiter.api.Test; 38 | import org.jvnet.hudson.test.Issue; 39 | import org.jvnet.hudson.test.JenkinsRule; 40 | import org.jvnet.hudson.test.junit.jupiter.WithJenkins; 41 | 42 | import java.io.IOException; 43 | import java.net.URL; 44 | import java.nio.file.Paths; 45 | import java.nio.charset.StandardCharsets; 46 | import java.util.Collection; 47 | import java.util.Collections; 48 | import java.util.List; 49 | import java.util.Map; 50 | 51 | import static hudson.plugins.junitattachments.AttachmentPublisherTest.getClassResult; 52 | import static org.junit.jupiter.api.Assertions.assertEquals; 53 | import static org.junit.jupiter.api.Assertions.assertNotNull; 54 | 55 | @WithJenkins 56 | class AttachmentPublisherPipelineTest { 57 | // Package name used in tests in workspace2.zip 58 | private static final String TEST_PACKAGE = "com.example.test"; 59 | 60 | @Test 61 | void testWellKnownFilenamesAreAttached(JenkinsRule jenkinsRule) throws Exception { 62 | TestResultAction action = getTestResultActionForPipeline(jenkinsRule, "workspace.zip", "pipelineTest.groovy", Result.SUCCESS); 63 | 64 | ClassResult cr = getClassResult(action, "test.foo.bar", "DefaultIntegrationTest"); 65 | 66 | TestClassAttachmentTestAction ata = cr.getTestAction(TestClassAttachmentTestAction.class); 67 | assertNotNull(ata); 68 | 69 | Map> attachmentsByTestCase = ata.getAttachments(); 70 | assertNotNull(attachmentsByTestCase); 71 | assertEquals(1, attachmentsByTestCase.size()); 72 | 73 | List testCaseAttachments = attachmentsByTestCase.get(""); 74 | assertEquals(2, testCaseAttachments.size()); 75 | Collections.sort(testCaseAttachments); 76 | assertEquals("file", testCaseAttachments.get(0)); 77 | assertEquals("test.foo.bar.DefaultIntegrationTest-output.txt", testCaseAttachments.get(1)); 78 | } 79 | 80 | @Issue("JENKINS-36504") 81 | @Test 82 | void annotationDoesNotFailForPipeline(JenkinsRule jenkinsRule) throws Exception { 83 | TestResultAction action = getTestResultActionForPipeline(jenkinsRule, "workspace2.zip", "pipelineTest.groovy", Result.UNSTABLE); 84 | 85 | ClassResult cr = getClassResult(action, TEST_PACKAGE, "SignupTest"); 86 | Collection caseResults = cr.getChildren(); 87 | assertEquals(3, caseResults.size()); 88 | 89 | CaseResult failingCase = cr.getCaseResult("A_003_Type_the_text__jenkins__into_the_field__username_"); 90 | assertNotNull(failingCase); 91 | assertEquals("Timed out after 10 seconds", failingCase.annotate(failingCase.getErrorDetails())); 92 | 93 | TestCaseAttachmentTestAction ata = failingCase.getTestAction(TestCaseAttachmentTestAction.class); 94 | assertNotNull(ata); 95 | 96 | final List attachments = ata.getAttachments(); 97 | assertNotNull(attachments); 98 | assertEquals(1, attachments.size()); 99 | 100 | Collections.sort(attachments); 101 | assertEquals("signup-username", attachments.get(0)); 102 | } 103 | 104 | @Test 105 | void testBothWellKnownFilenamesAndPatternAreAttached(JenkinsRule jenkinsRule) throws Exception { 106 | TestResultAction action = getTestResultActionForPipeline(jenkinsRule, "workspace4.zip", "pipelineTest.groovy", Result.SUCCESS); 107 | 108 | ClassResult cr = getClassResult(action, "test.foo.bar", "DefaultIntegrationTest"); 109 | { 110 | TestClassAttachmentTestAction ata = cr.getTestAction(TestClassAttachmentTestAction.class); 111 | assertNotNull(ata); 112 | final Map> attachmentsByTestCase = ata.getAttachments(); 113 | assertNotNull(attachmentsByTestCase); 114 | assertEquals(2, attachmentsByTestCase.size()); 115 | 116 | List testClassAttachments = attachmentsByTestCase.get(""); 117 | assertEquals(3, testClassAttachments.size()); 118 | Collections.sort(testClassAttachments); 119 | assertEquals(Paths.get("experimentsWithJavaElements", "attachment.txt").toString(), testClassAttachments.get(0)); 120 | assertEquals("file", testClassAttachments.get(1)); 121 | assertEquals("test.foo.bar.DefaultIntegrationTest-output.txt", testClassAttachments.get(2)); 122 | } 123 | 124 | CaseResult caseResult = cr.getCaseResult("experimentsWithJavaElements"); 125 | { 126 | TestCaseAttachmentTestAction caseAta = caseResult.getTestAction(TestCaseAttachmentTestAction.class); 127 | assertNotNull(caseAta); 128 | final List caseAttachments = caseAta.getAttachments(); 129 | assertNotNull(caseAttachments); 130 | assertEquals(1, caseAttachments.size()); 131 | assertEquals("attachment.txt", caseAttachments.get(0)); 132 | } 133 | } 134 | 135 | // Creates a job from the given workspace zip file, builds it and retrieves the TestResultAction 136 | private static TestResultAction getTestResultActionForPipeline(JenkinsRule jenkinsRule, String workspaceZip, String pipelineFile, Result expectedStatus) throws Exception { 137 | WorkflowJob project = jenkinsRule.jenkins.createProject(WorkflowJob.class, "test-job"); 138 | FilePath workspace = jenkinsRule.jenkins.getWorkspaceFor(project); 139 | FilePath wsZip = workspace.child("workspace.zip"); 140 | wsZip.copyFrom(AttachmentPublisherPipelineTest.class.getResource(workspaceZip)); 141 | wsZip.unzip(workspace); 142 | for (FilePath f : workspace.list()) { 143 | f.touch(System.currentTimeMillis()); 144 | } 145 | 146 | project.setDefinition(new CpsFlowDefinition(fileContentsFromResources(pipelineFile), true)); 147 | 148 | WorkflowRun r = jenkinsRule.assertBuildStatus(expectedStatus, project.scheduleBuild2(0).get()); 149 | 150 | TestResultAction action = r.getAction(TestResultAction.class); 151 | assertNotNull(action); 152 | 153 | return action; 154 | } 155 | 156 | private static String fileContentsFromResources(String fileName) throws IOException { 157 | String fileContents = null; 158 | 159 | URL url = AttachmentPublisherPipelineTest.class.getResource(fileName); 160 | if (url != null) { 161 | fileContents = IOUtils.toString(url, StandardCharsets.UTF_8); 162 | } 163 | 164 | return fileContents; 165 | } 166 | 167 | } 168 | -------------------------------------------------------------------------------- /src/test/java/hudson/plugins/junitattachments/AttachmentPublisherTest.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.junitattachments; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertNotNull; 5 | import static org.junit.jupiter.api.Assertions.assertNull; 6 | 7 | import org.htmlunit.html.HtmlAnchor; 8 | import org.htmlunit.html.HtmlPage; 9 | import hudson.FilePath; 10 | import hudson.Launcher; 11 | import hudson.model.AbstractBuild; 12 | import hudson.model.BuildListener; 13 | import hudson.model.Descriptor; 14 | import hudson.model.FreeStyleBuild; 15 | import hudson.model.FreeStyleProject; 16 | import hudson.model.Result; 17 | import hudson.tasks.Builder; 18 | import hudson.tasks.junit.ClassResult; 19 | import hudson.tasks.junit.CaseResult; 20 | import hudson.tasks.junit.JUnitResultArchiver; 21 | import hudson.tasks.junit.PackageResult; 22 | import hudson.tasks.junit.TestDataPublisher; 23 | import hudson.tasks.junit.TestResultAction; 24 | import hudson.tasks.test.TabulatedResult; 25 | import hudson.tasks.test.TestResult; 26 | import hudson.util.DescribableList; 27 | import java.io.IOException; 28 | import java.io.Serializable; 29 | import java.util.ArrayList; 30 | import java.util.Collections; 31 | import java.util.List; 32 | 33 | import org.junit.jupiter.api.Test; 34 | import java.util.Map; 35 | 36 | import org.jvnet.hudson.test.ExtractResourceSCM; 37 | import org.jvnet.hudson.test.JenkinsRule; 38 | import org.jvnet.hudson.test.junit.jupiter.WithJenkins; 39 | 40 | @WithJenkins 41 | class AttachmentPublisherTest { 42 | 43 | // Package name used in tests in workspace2.zip 44 | private static final String TEST_PACKAGE = "com.example.test"; 45 | 46 | @Test 47 | void testWellKnownFilenamesAreAttached(JenkinsRule j) throws Exception { 48 | TestResultAction action = getTestResultActionForBuild(j, "workspace.zip", Result.SUCCESS); 49 | 50 | ClassResult cr = getClassResult(action, "test.foo.bar", "DefaultIntegrationTest"); 51 | 52 | TestClassAttachmentTestAction ata = cr.getTestAction(TestClassAttachmentTestAction.class); 53 | assertNotNull(ata); 54 | 55 | final Map> attachmentsByTestCase = ata.getAttachments(); 56 | assertNotNull(attachmentsByTestCase); 57 | assertEquals(1, attachmentsByTestCase.size()); 58 | 59 | List testCaseAttachments = attachmentsByTestCase.get(""); 60 | assertEquals(2, testCaseAttachments.size()); 61 | Collections.sort(testCaseAttachments); 62 | assertEquals("file", testCaseAttachments.get(0)); 63 | assertEquals("test.foo.bar.DefaultIntegrationTest-output.txt", testCaseAttachments.get(1)); 64 | } 65 | 66 | @Test 67 | void testNoAttachmentsShownForPackage(JenkinsRule j) throws Exception { 68 | TestResultAction action = getTestResultActionForBuild(j, "workspace2.zip", Result.UNSTABLE); 69 | 70 | // At the package level, attachments shouldn't be shown 71 | PackageResult pr = action.getResult().byPackage(TEST_PACKAGE); 72 | AttachmentTestAction ata = pr.getTestAction(AttachmentTestAction.class); 73 | assertNull(ata); 74 | } 75 | 76 | //------------------------------------------------------------------------------------- 77 | 78 | // Tests that the correct summary of attachments are shown at the class level 79 | @Test 80 | void testAttachmentsShownForClass_SignupTest(JenkinsRule j) throws Exception { 81 | // There should be 5 attachments: 3 from the test methods, and 2 from the test suite 82 | // 83 | // The two testsuite files should come first, in order of appearance, 84 | // while the remaining files should appear in order of the test method name 85 | String[] expectedFiles = { "signup-suite-1", "signup-suite-2", 86 | "signup-reset", "signup-login", "signup-username" }; 87 | runBuildAndAssertAttachmentsExist(j, "SignupTest", expectedFiles); 88 | } 89 | 90 | // Tests that the correct attachments are shown for individual test methods 91 | @Test 92 | void testAttachmentsShownForTestcases_SignupTest(JenkinsRule j) throws Exception { 93 | TestResultAction action = getTestResultActionForBuild(j, "workspace2.zip", Result.UNSTABLE); 94 | 95 | TabulatedResult classResult = getClassResult(action, "SignupTest"); 96 | List cases = new ArrayList<>(classResult.getChildren()); 97 | assertEquals(3, cases.size()); 98 | 99 | // Each test case should have the respective one attachment 100 | String[] names = { "signup-reset", "signup-login", "signup-username" }; 101 | for (int i = 0; i < cases.size(); i++) { 102 | assertAttachmentsExist(cases.get(i), new String[] { names[i] }); 103 | } 104 | } 105 | 106 | // Tests that the correct attachments are shown for individual test methods with additional output prefix by ant/maven 107 | @Test 108 | void testAttachmentsShownForTestcases_SignupTest_WithRunnerPrefix(JenkinsRule j) throws Exception { 109 | TestResultAction action = getTestResultActionForBuild(j, "workspace3.zip", Result.UNSTABLE); 110 | 111 | TabulatedResult classResult = getClassResult(action, "SignupTest"); 112 | List cases = new ArrayList<>(classResult.getChildren()); 113 | assertEquals(3, cases.size()); 114 | 115 | // Each test case should have the respective one attachment 116 | String[] names = { "signup-reset", "signup-login", "signup-username" }; 117 | for (int i = 0; i < cases.size(); i++) { 118 | assertAttachmentsExist(cases.get(i), new String[] { names[i] }); 119 | } 120 | } 121 | 122 | //------------------------------------------------------------------------------------- 123 | 124 | @Test 125 | void testAttachmentsShownForClass_LoginTest(JenkinsRule j) throws Exception { 126 | // There should be 2 attachments from the test methods 127 | String[] expectedFiles = { "login-reset", "login-password", "login-reset" }; 128 | runBuildAndAssertAttachmentsExist(j, "LoginTest", expectedFiles); 129 | } 130 | 131 | @Test 132 | void testAttachmentsShownForTestcases_LoginTest(JenkinsRule j) throws Exception { 133 | TestResultAction action = getTestResultActionForBuild(j, "workspace2.zip", Result.UNSTABLE); 134 | 135 | TabulatedResult classResult = getClassResult(action, "LoginTest"); 136 | List cases = new ArrayList<>(classResult.getChildren()); 137 | assertEquals(4, cases.size()); 138 | 139 | // Each test case should have the respective one (or zero) attachments 140 | String[] expectedFiles = { "login-reset", null, "login-password", "login-reset" }; 141 | for (int i = 0; i < cases.size(); i++) { 142 | String expectedFile = expectedFiles[i]; 143 | String[] files = expectedFile == null ? null : new String[] { expectedFile }; 144 | assertAttachmentsExist(cases.get(i), files); 145 | } 146 | } 147 | 148 | //------------------------------------------------------------------------------------- 149 | 150 | @Test 151 | void testAttachmentsShownForClass_MiscTest1(JenkinsRule j) throws Exception { 152 | // There should be 2 attachments from the test suite 153 | String[] expectedFiles = { "misc-suite-1", "misc-suite-2" }; 154 | runBuildAndAssertAttachmentsExist(j, "MiscTest1", expectedFiles); 155 | } 156 | 157 | // Individual test case should have no attachments, i.e. not overridden by class system-out 158 | @Test 159 | void testAttachmentsShownForTestcases_MiscTest1(JenkinsRule j) throws Exception { 160 | TestResultAction action = getTestResultActionForBuild(j, "workspace2.zip", Result.UNSTABLE); 161 | 162 | TabulatedResult classResult = getClassResult(action, "MiscTest1"); 163 | List cases = new ArrayList<>(classResult.getChildren()); 164 | assertEquals(1, cases.size()); 165 | 166 | // Attachment should not be inherited from testsuite 167 | assertAttachmentsExist(cases.get(0), null); 168 | } 169 | 170 | //------------------------------------------------------------------------------------- 171 | 172 | @Test 173 | void testAttachmentsShownForClass_MiscTest2(JenkinsRule j) throws Exception { 174 | // There should be 6 attachments from the test suite, first stdout, then stderr, 175 | // followed by two from a single test case 176 | String[] expectedFiles = { "misc-suite-3", "misc-suite-4", "misc-suite-1", "misc-suite-2", 177 | "misc-something-1", "misc-something-2" }; 178 | runBuildAndAssertAttachmentsExist(j, "MiscTest2", expectedFiles); 179 | } 180 | 181 | @Test 182 | void testAttachmentsShownForTestcases_MiscTest2(JenkinsRule j) throws Exception { 183 | TestResultAction action = getTestResultActionForBuild(j, "workspace2.zip", Result.UNSTABLE); 184 | 185 | TabulatedResult classResult = getClassResult(action, "MiscTest2"); 186 | List cases = new ArrayList<>(classResult.getChildren()); 187 | assertEquals(2, cases.size()); 188 | 189 | // Alphabetically first comes the "doNothing" test 190 | assertAttachmentsExist(cases.get(0), null); 191 | // Followed by the "doSomething" test 192 | assertAttachmentsExist(cases.get(1), new String[] { "misc-something-1", "misc-something-2" }); 193 | } 194 | 195 | @Test 196 | void testAttachmentsWithStrangeFileNames(JenkinsRule j) throws Exception { 197 | FreeStyleBuild build = getBuild(j, "workspace5.zip"); 198 | 199 | HtmlPage page = j.createWebClient().withJavaScriptEnabled(false) 200 | .getPage(build, "testReport/com.example.test/SignupTest/"); 201 | HtmlAnchor anchor1 = page.getAnchorByText("unicödeAndかわいいStuff"); 202 | assertNotNull(anchor1.click()); 203 | HtmlAnchor anchor2 = page.getAnchorByText("with space"); 204 | assertNotNull(anchor2.click()); 205 | HtmlAnchor anchor3 = page.getAnchorByText("special%§$_-%&[;]{}()char"); 206 | assertNotNull(anchor3.click()); 207 | } 208 | 209 | //------------------------------------------------------------------------------------- 210 | 211 | private static void runBuildAndAssertAttachmentsExist(JenkinsRule j, String className, String[] expectedFiles) throws Exception { 212 | TestResultAction action = getTestResultActionForBuild(j, "workspace2.zip", Result.UNSTABLE); 213 | 214 | ClassResult cr = getClassResult(action, className); 215 | assertAttachmentsExist(cr, expectedFiles); 216 | } 217 | 218 | // Asserts that, for the given TestResult, the given attachments exist 219 | private static void assertAttachmentsExist(TestResult result, String[] expectedFiles) { 220 | AttachmentTestAction ata = null; 221 | if (result instanceof ClassResult) { 222 | ata = result.getTestAction(AttachmentTestAction.class); 223 | } 224 | else if (result instanceof CaseResult) { 225 | ata = result.getTestAction(AttachmentTestAction.class); 226 | } 227 | 228 | if (expectedFiles == null) { 229 | assertNull(ata); 230 | return; 231 | } 232 | assertNotNull(ata); 233 | 234 | // Assert that attachments exist for this TestResult 235 | List attachments; 236 | if (result instanceof ClassResult) { 237 | Map> attachmentsByTestCase = ((TestClassAttachmentTestAction)ata).getAttachments(); 238 | attachments = new ArrayList<>(); 239 | for (List list : attachmentsByTestCase.values()) { 240 | attachments.addAll(list); 241 | } 242 | } 243 | else { 244 | attachments = ((TestCaseAttachmentTestAction)ata).getAttachments(); 245 | } 246 | 247 | assertNotNull(attachments); 248 | assertEquals(expectedFiles.length, attachments.size()); 249 | 250 | // Assert that the expected files are there in the given order 251 | for (int i = 0; i < expectedFiles.length; i++) { 252 | assertEquals(expectedFiles[i], attachments.get(i)); 253 | } 254 | } 255 | 256 | static ClassResult getClassResult(TestResultAction action, String className) { 257 | return getClassResult(action, TEST_PACKAGE, className); 258 | } 259 | 260 | static ClassResult getClassResult(TestResultAction action, String packageName, String className) { 261 | return action.getResult().byPackage(packageName).getClassResult(className); 262 | } 263 | 264 | private static FreeStyleBuild getBuild(JenkinsRule j, String workspaceZip) throws Exception { 265 | FreeStyleProject project = j.createFreeStyleProject(); 266 | 267 | DescribableList> publishers = 268 | new DescribableList<>(project); 269 | publishers.add(new AttachmentPublisher()); 270 | 271 | project.setScm(new ExtractResourceSCM(AttachmentPublisherTest.class.getResource(workspaceZip))); 272 | project.getBuildersList().add(new TouchBuilder()); 273 | JUnitResultArchiver archiver = new JUnitResultArchiver("*.xml"); 274 | archiver.setTestDataPublishers(publishers); 275 | project.getPublishersList().add(archiver); 276 | 277 | return project.scheduleBuild2(0).get(); 278 | } 279 | 280 | // Creates a job from the given workspace zip file, builds it and retrieves the TestResultAction 281 | private static TestResultAction getTestResultActionForBuild(JenkinsRule j, String workspaceZip, Result expectedStatus) throws Exception { 282 | FreeStyleBuild b = getBuild(j, workspaceZip); 283 | j.assertBuildStatus(expectedStatus, b); 284 | 285 | TestResultAction action = b.getAction(TestResultAction.class); 286 | assertNotNull(action); 287 | 288 | return action; 289 | } 290 | 291 | public static final class TouchBuilder extends Builder implements Serializable { 292 | @Override 293 | public boolean perform(AbstractBuild build, Launcher launcher, 294 | BuildListener listener) throws InterruptedException, 295 | IOException { 296 | for (FilePath f : build.getWorkspace().list()) { 297 | f.touch(System.currentTimeMillis()); 298 | } 299 | return true; 300 | } 301 | } 302 | 303 | } 304 | -------------------------------------------------------------------------------- /src/test/resources/hudson/plugins/junitattachments/pipelineTest.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright (c) 2016, CloudBees, Inc. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | * 24 | */ 25 | node { 26 | junit testResults: '*.xml', testDataPublishers: [attachments()] 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/test/resources/hudson/plugins/junitattachments/workspace.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/junit-attachments-plugin/2d3169b5d0d1b4be3b272de42c4dd4dcf71e113f/src/test/resources/hudson/plugins/junitattachments/workspace.zip -------------------------------------------------------------------------------- /src/test/resources/hudson/plugins/junitattachments/workspace2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/junit-attachments-plugin/2d3169b5d0d1b4be3b272de42c4dd4dcf71e113f/src/test/resources/hudson/plugins/junitattachments/workspace2.zip -------------------------------------------------------------------------------- /src/test/resources/hudson/plugins/junitattachments/workspace3.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/junit-attachments-plugin/2d3169b5d0d1b4be3b272de42c4dd4dcf71e113f/src/test/resources/hudson/plugins/junitattachments/workspace3.zip -------------------------------------------------------------------------------- /src/test/resources/hudson/plugins/junitattachments/workspace4.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/junit-attachments-plugin/2d3169b5d0d1b4be3b272de42c4dd4dcf71e113f/src/test/resources/hudson/plugins/junitattachments/workspace4.zip -------------------------------------------------------------------------------- /src/test/resources/hudson/plugins/junitattachments/workspace5.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/junit-attachments-plugin/2d3169b5d0d1b4be3b272de42c4dd4dcf71e113f/src/test/resources/hudson/plugins/junitattachments/workspace5.zip --------------------------------------------------------------------------------