├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── release-drafter.yml └── workflows │ ├── cd.yaml │ └── jenkins-security-scan.yml ├── .gitignore ├── .mvn ├── extensions.xml └── maven.config ├── Jenkinsfile ├── LICENSE ├── README.md ├── documentation └── images │ ├── action_link.png │ ├── build_time_trend.png │ ├── last_successful.png │ ├── mean_build_times.png │ └── timestamps.png ├── pom.xml └── src ├── main ├── groovy │ └── org │ │ └── jenkins │ │ └── ci │ │ └── plugins │ │ └── buildtimeblame │ │ ├── action │ │ ├── BlameAction.groovy │ │ └── BlameActionFactory.java │ │ ├── analysis │ │ ├── BlameReport.groovy │ │ ├── BuildResult.groovy │ │ ├── ConsoleLogMatch.groovy │ │ ├── LogParser.groovy │ │ ├── MapFixStackedAreaRenderer.groovy │ │ ├── RelevantStep.groovy │ │ └── TimedLog.groovy │ │ └── io │ │ ├── BlameFilePaths.groovy │ │ ├── ConfigIO.groovy │ │ ├── ReportConfiguration.groovy │ │ ├── ReportIO.groovy │ │ └── StaplerUtils.groovy ├── resources │ ├── index.jelly │ └── org │ │ └── jenkins │ │ └── ci │ │ └── plugins │ │ └── buildtimeblame │ │ ├── action │ │ └── BlameAction │ │ │ ├── action.jelly │ │ │ ├── body.jelly │ │ │ ├── edit.jelly │ │ │ └── index.jelly │ │ └── analysis │ │ └── BlameReport │ │ ├── body.jelly │ │ └── reportTable.jelly └── webapp │ └── build-time-blame.css ├── spotbugs └── excludesFilter.xml └── test └── groovy └── org └── jenkins └── ci └── plugins └── buildtimeblame ├── action ├── BlameActionFactoryTest.groovy └── BlameActionTest.groovy ├── analysis ├── BlameReportTest.groovy ├── ConsoleLogMatchTest.groovy ├── LogParserTest.groovy ├── MapFixStackedAreaRendererTest.groovy ├── RelevantStepTest.groovy └── TimedLogTest.groovy └── io ├── BlameFilePathsTest.groovy ├── ConfigIOTest.groovy ├── ReportIOTest.groovy └── StaplerUtilsTest.groovy /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | _extends: .github 2 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: cd 2 | on: 3 | workflow_dispatch: 4 | check_run: 5 | types: 6 | - completed 7 | 8 | permissions: 9 | checks: read 10 | contents: write 11 | 12 | jobs: 13 | maven-cd: 14 | uses: jenkins-infra/github-reusable-workflows/.github/workflows/maven-cd.yml@v1 15 | secrets: 16 | MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} 17 | MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/jenkins-security-scan.yml: -------------------------------------------------------------------------------- 1 | name: Jenkins Security Scan 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 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: 11 # Optionally specify what version of Java to set up for the build, or remove to use a recent default. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.ipr 3 | *.iws 4 | .idea 5 | work 6 | target 7 | *.class 8 | 9 | # Mobile Tools for Java (J2ME) 10 | .mtj.tmp/ 11 | 12 | # Package Files # 13 | *.jar 14 | *.war 15 | *.ear 16 | 17 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 18 | hs_err_pid* 19 | settings.xml -------------------------------------------------------------------------------- /.mvn/extensions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | io.jenkins.tools.incrementals 4 | git-changelist-maven-extension 5 | 1.6 6 | 7 | 8 | -------------------------------------------------------------------------------- /.mvn/maven.config: -------------------------------------------------------------------------------- 1 | -Pconsume-incrementals 2 | -Pmight-produce-incrementals 3 | -Dchangelist.format=%d.v%s 4 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | // Builds the plugin using https://github.com/jenkins-infra/pipeline-library 2 | buildPlugin(forkCount: '1C', configurations: [ 3 | [ platform: 'linux', jdk: '11' ], 4 | [ platform: 'windows', jdk: '11' ], 5 | ]) 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Deere & Company 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build Time Blame Plugin 2 | A Jenkins plugin for analyzing the historical console output of a Job with the goal of determining which steps are taking the most time. 3 | 4 | 5 | ## Requirements: 6 | + Jenkins Version >= `2.361.4` 7 | + [Timestamper Plugin](https://wiki.jenkins-ci.org/display/JENKINS/Timestamper) 8 | 9 | ## What it does: 10 | This plugin scans console output for successful builds. 11 | If the line matches one of the configured regex keys, it will store the timestamp generated for that line by the Timestamper Plugin. 12 | Then, it generates a report showing how those steps contributed to the total build time. 13 | This is intended to help analyze which steps in the build process are good candidates for optimization. 14 | 15 | #### The plugin produces: 16 | 17 | + A Build Time Trend graph showing what portion of the total build time each step has taken over the scanned builds. 18 | ![Build Time Trend](documentation/images/build_time_trend.png) 19 | 20 | + A Last Successful Build Times table showing each line that was matched in the most recent build. 21 | This helps analyze if the steps being matched are correct. 22 | ![Last Successful Build Times](documentation/images/last_successful.png) 23 | 24 | + A Mean Successful Build Times table showing the mean amount of time each step has taken across all successful builds. 25 | 26 | ![Mean Successful Build Times](documentation/images/mean_build_times.png) 27 | 28 | ## How to use it: 29 | 1. Install Plugin 30 | 31 | 1. Restart Jenkins 32 | 33 | 1. Each job will now have a new action: 34 | 35 | ![Build Time Blame Report](documentation/images/action_link.png) 36 | 37 | 1. Enable Timestamps for any jobs you want to analyze (the report will only include results for builds where this plugin was enabled): 38 | 39 | ![Timestamper Plugin](documentation/images/timestamps.png) 40 | 1. The report will be pre-populated with regex to denote the start and end of the job. 41 | 42 | 1. Add/Update regex statements to match parts of the build process. 43 | + Regex Search Key: 44 | Lines in the console output that match this regex string will be added to the report. 45 | Any leading or trailing spaces will be excluded before checking if the rest of the line matches the regex. 46 | These keys should match when the particular step has begun. 47 | Make sure to add another match for when that step is complete or the next begins to get the correct elapsed time in the report. 48 | + Label: The matches will be shown with this label in the report. 49 | + Only Use First Match: If this box is checked, the search key will not be checked against after the first line it matches. 50 | 1. (Optional) Set a limit on the "Maximum Builds To Process" 51 | + By default, the logs for all successfully completed builds will be scanned. 52 | For some jobs with long logs or many builds, limiting the report may allow it to load more quickly. 53 | 1. Click `Reprocess Report`. 54 | This will regenerate the report by searching for the configured steps in the console output for each successful run of the job that had Timestamps enabled. 55 | Note: This process can take some time for jobs with a lot of console output and/or a lot of build history. 56 | 57 | 1. When the report executes, the results for each build are cached. 58 | When the report is reloaded, the plugin will check for other successful builds that have not been processed and update the report accordingly. 59 | -------------------------------------------------------------------------------- /documentation/images/action_link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/build-time-blame-plugin/b58ab0ea65b9a24754373dcfacf8eee830315850/documentation/images/action_link.png -------------------------------------------------------------------------------- /documentation/images/build_time_trend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/build-time-blame-plugin/b58ab0ea65b9a24754373dcfacf8eee830315850/documentation/images/build_time_trend.png -------------------------------------------------------------------------------- /documentation/images/last_successful.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/build-time-blame-plugin/b58ab0ea65b9a24754373dcfacf8eee830315850/documentation/images/last_successful.png -------------------------------------------------------------------------------- /documentation/images/mean_build_times.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/build-time-blame-plugin/b58ab0ea65b9a24754373dcfacf8eee830315850/documentation/images/mean_build_times.png -------------------------------------------------------------------------------- /documentation/images/timestamps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/build-time-blame-plugin/b58ab0ea65b9a24754373dcfacf8eee830315850/documentation/images/timestamps.png -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | 6 | https://github.com/jenkinsci/build-time-blame-plugin 7 | scm:git:git@github.com:jenkinsci/build-time-blame-plugin.git 8 | scm:git:git@github.com:jenkinsci/build-time-blame-plugin.git 9 | HEAD 10 | 11 | 12 | 13 | org.jenkins-ci.plugins 14 | plugin 15 | 4.61 16 | 17 | 18 | 19 | 20 | 2.361.4 21 | 1.7.26 22 | [2.12.7.1,) 23 | 2.1.0 24 | 999999-SNAPSHOT 25 | 3.43 26 | 27 | 28 | build-time-blame 29 | ${changelist}-${revision} 30 | hpi 31 | 32 | 33 | MIT License 34 | http://opensource.org/licenses/MIT 35 | 36 | 37 | 38 | Build Time Blame Plugin 39 | 40 | A Jenkins plugin for analyzing the historical console output of a Job 41 | with the goal of determining which steps are taking the most time. 42 | 43 | https://github.com/jenkinsci/build-time-blame-plugin/ 44 | 45 | 46 | 47 | repo.jenkins-ci.org 48 | https://repo.jenkins-ci.org/public/ 49 | 50 | 51 | 52 | 53 | 54 | repo.jenkins-ci.org 55 | https://repo.jenkins-ci.org/public/ 56 | 57 | 58 | 59 | 60 | 61 | 62 | io.jenkins.tools.bom 63 | bom-2.361.x 64 | 2000.v4677a_6e0ffea 65 | pom 66 | import 67 | 68 | 69 | 70 | 71 | 72 | 73 | org.jenkins-ci.plugins 74 | timestamper 75 | 1.24 76 | compile 77 | 78 | 79 | org.slf4j 80 | slf4j-api 81 | ${slf4j.version} 82 | compile 83 | 84 | 85 | com.fasterxml.jackson.core 86 | jackson-core 87 | ${jackson-core.version} 88 | 89 | 90 | com.fasterxml.jackson.core 91 | jackson-annotations 92 | ${jackson-core.version} 93 | 94 | 95 | com.fasterxml.jackson.core 96 | jackson-databind 97 | ${jackson-core.version} 98 | 99 | 100 | org.codehaus.groovy 101 | groovy-all 102 | 2.5.13 103 | pom 104 | test 105 | 106 | 107 | org.apache.ant 108 | ant-launcher 109 | 110 | 111 | jline 112 | jline 113 | 114 | 115 | org.testng 116 | testng 117 | 118 | 119 | 120 | 121 | cglib 122 | cglib-nodep 123 | 3.3.0 124 | test 125 | 126 | 127 | org.spockframework 128 | spock-core 129 | 1.3-groovy-2.5 130 | test 131 | 132 | 133 | org.objenesis 134 | objenesis 135 | 3.3 136 | test 137 | 138 | 139 | 140 | 141 | src/main/groovy 142 | src/test/groovy 143 | 144 | 145 | org.codehaus.gmavenplus 146 | gmavenplus-plugin 147 | 148 | 149 | 150 | addSources 151 | addTestSources 152 | generateStubs 153 | compile 154 | compileTests 155 | removeStubs 156 | removeTestStubs 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/action/BlameAction.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | 3 | package org.jenkins.ci.plugins.buildtimeblame.action 4 | 5 | import groovy.transform.EqualsAndHashCode 6 | import groovy.transform.ToString 7 | import hudson.model.Action 8 | import hudson.model.Job 9 | import hudson.model.Result 10 | import hudson.model.Run 11 | import net.sf.json.JSONObject 12 | import org.jenkins.ci.plugins.buildtimeblame.analysis.BlameReport 13 | import org.jenkins.ci.plugins.buildtimeblame.analysis.BuildResult 14 | import org.jenkins.ci.plugins.buildtimeblame.analysis.LogParser 15 | import org.jenkins.ci.plugins.buildtimeblame.analysis.RelevantStep 16 | import org.jenkins.ci.plugins.buildtimeblame.io.ConfigIO 17 | import org.jenkins.ci.plugins.buildtimeblame.io.ReportConfiguration 18 | import org.jenkins.ci.plugins.buildtimeblame.io.ReportIO 19 | import org.kohsuke.stapler.StaplerRequest 20 | import org.kohsuke.stapler.StaplerResponse 21 | 22 | import java.util.stream.Collectors 23 | 24 | import static org.jenkins.ci.plugins.buildtimeblame.io.StaplerUtils.redirectToParentURI 25 | 26 | @ToString(includeNames = true) 27 | @EqualsAndHashCode 28 | class BlameAction implements Action { 29 | static final List DEFAULT_PATTERNS = [ 30 | new RelevantStep(~/.*Started by.*/, 'Job Started on Executor', true), 31 | new RelevantStep(~/^Finished: (SUCCESS|UNSTABLE|FAILURE|NOT_BUILT|ABORTED)$.*/, 'Finished', true), 32 | ] 33 | 34 | Job job 35 | List buildsWithoutTimestamps = [] 36 | ReportConfiguration config 37 | private BlameReport _report 38 | private int lastProcessedBuild 39 | 40 | BlameAction(Job job) { 41 | this.job = job 42 | this.config = new ConfigIO(job).readOrDefault(DEFAULT_PATTERNS) 43 | } 44 | 45 | @Override 46 | String getIconFileName() { 47 | return 'null' 48 | } 49 | 50 | @Override 51 | String getDisplayName() { 52 | return 'Build Time Blame Report' 53 | } 54 | 55 | String getIconClassName() { 56 | return 'icon-monitor' 57 | } 58 | 59 | @Override 60 | String getUrlName() { 61 | return 'buildTimeBlameReport' 62 | } 63 | 64 | String getMissingTimestampsDescription() { 65 | if (buildsWithoutTimestamps.isEmpty()) { 66 | return '' 67 | } 68 | 69 | List buildNumbers = getFailedBuildNumbers() 70 | if (buildNumbers.isEmpty()) { 71 | return '' 72 | } 73 | 74 | return "Error finding timestamps for builds: ${buildNumbers.sort().join(', ')}" 75 | } 76 | 77 | BlameReport getReport() { 78 | if (_report == null || hasNewBuilds()) { 79 | buildsWithoutTimestamps = [] 80 | _report = new BlameReport(getBuildResults(new LogParser(this.config.relevantSteps))) 81 | } 82 | 83 | return _report 84 | } 85 | 86 | private boolean hasNewBuilds() { 87 | job.getNearestBuild(lastProcessedBuild) != null 88 | } 89 | 90 | public doReprocessBlameReport(StaplerRequest request, StaplerResponse response) { 91 | updateConfiguration(request.getSubmittedForm()) 92 | clearReports() 93 | redirectToParentURI(request, response) 94 | } 95 | 96 | private List getFailedBuildNumbers() { 97 | def firstSuccessful = report.buildResults.collect({ it.build.getNumber() }).min() 98 | 99 | return buildsWithoutTimestamps 100 | .collect({ it.getNumber() }) 101 | .findAll({ it > firstSuccessful }) 102 | } 103 | 104 | private void clearReports() { 105 | job.getBuilds().each { Run build -> new ReportIO(build).clear() } 106 | _report = null 107 | buildsWithoutTimestamps = [] 108 | } 109 | 110 | private void updateConfiguration(JSONObject jsonObject) { 111 | def configIO = new ConfigIO(job) 112 | config = configIO.parse(jsonObject.toString()) 113 | configIO.write(config) 114 | } 115 | 116 | private List getBuildResults(LogParser logParser) { 117 | return job.getBuilds().stream() 118 | .filter({ Run run -> 119 | return !run.isBuilding() && run.result.isBetterOrEqualTo(Result.UNSTABLE) 120 | }) 121 | .limit(config.maxBuilds != null && config.maxBuilds > 0 ? config.maxBuilds : Integer.MAX_VALUE) 122 | .map({ Run run -> 123 | try { 124 | return logParser.getBuildResult(run) 125 | } catch (RuntimeException ignored) { 126 | buildsWithoutTimestamps.add(run) 127 | return null 128 | } finally { 129 | lastProcessedBuild = Math.max(lastProcessedBuild, run.getNumber()) 130 | } 131 | }) 132 | .filter({ 133 | it != null 134 | }) 135 | .collect(Collectors.toList()) as List 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/action/BlameActionFactory.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.action; 3 | 4 | import hudson.Extension; 5 | import hudson.model.Action; 6 | import hudson.model.Job; 7 | import jenkins.model.TransientActionFactory; 8 | 9 | import javax.annotation.Nonnull; 10 | import java.util.Collection; 11 | import java.util.Collections; 12 | 13 | @Extension 14 | public class BlameActionFactory extends TransientActionFactory { 15 | @Override 16 | public Class type() { 17 | return Job.class; 18 | } 19 | 20 | @Nonnull 21 | @Override 22 | public Collection createFor(@Nonnull Job job) { 23 | return Collections.singletonList(new BlameAction(job)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/BlameReport.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.analysis 3 | 4 | import com.google.common.collect.LinkedListMultimap 5 | import com.google.common.collect.Multimap 6 | import groovy.transform.EqualsAndHashCode 7 | import groovy.transform.ToString 8 | import hudson.util.Graph 9 | import org.apache.commons.lang.time.DurationFormatUtils 10 | import org.jfree.chart.ChartFactory 11 | import org.jfree.chart.JFreeChart 12 | import org.jfree.chart.axis.CategoryLabelPositions 13 | import org.jfree.chart.labels.CategoryToolTipGenerator 14 | import org.jfree.chart.plot.PlotOrientation 15 | import org.jfree.chart.urls.CategoryURLGenerator 16 | import org.jfree.data.category.CategoryDataset 17 | import org.jfree.data.category.DefaultCategoryDataset 18 | 19 | @EqualsAndHashCode 20 | @ToString(includeNames = true) 21 | class BlameReport { 22 | List buildResults 23 | List _meanBuildResult 24 | 25 | BlameReport(List buildResults) { 26 | this.buildResults = buildResults 27 | } 28 | 29 | List getLatestBuildResult() { 30 | if (this.buildResults.isEmpty()) { 31 | return [] 32 | } 33 | 34 | return this.buildResults[0].consoleLogMatches 35 | } 36 | 37 | List getMeanBuildResult() { 38 | if (_meanBuildResult == null) { 39 | _meanBuildResult = calculateMeanBuildResult() 40 | } 41 | 42 | return _meanBuildResult 43 | } 44 | 45 | Graph getGraph() { 46 | return new GraphImpl() 47 | } 48 | 49 | public class GraphImpl extends Graph { 50 | protected GraphImpl() { 51 | super(System.currentTimeMillis(), 900, 500) 52 | } 53 | 54 | @Override 55 | protected JFreeChart createGraph() { 56 | return ChartFactory.createStackedAreaChart( 57 | '', 'Build #', 'Time Taken (s)', getDataSet(), 58 | PlotOrientation.VERTICAL, true, false, false 59 | ).each { 60 | def plot = it.getCategoryPlot() 61 | plot.setRenderer(new BlameStackedAreaRenderer()) 62 | 63 | def xAxis = plot.getDomainAxis() 64 | xAxis.setCategoryMargin(0) 65 | xAxis.setLowerMargin(0) 66 | xAxis.setUpperMargin(0) 67 | xAxis.setCategoryLabelPositions(CategoryLabelPositions.UP_45); 68 | } 69 | } 70 | } 71 | 72 | private static class BlameStackedAreaRenderer extends MapFixStackedAreaRenderer 73 | implements CategoryToolTipGenerator, CategoryURLGenerator { 74 | BlameStackedAreaRenderer() { 75 | setItemURLGenerator(this) 76 | setToolTipGenerator(this) 77 | } 78 | 79 | @Override 80 | public String generateToolTip(CategoryDataset dataset, int row, int column) { 81 | String rowKey = dataset.getRowKey(row) 82 | int columnKey = dataset.getColumnKey(column) 83 | def value = dataset.getValue(rowKey, columnKey) ?: 0.0 84 | double elapsedSeconds = value 85 | long elapsedMillis = 1000.0 * elapsedSeconds 86 | String duration = DurationFormatUtils.formatDuration(elapsedMillis, 'mm:ss.S') 87 | 88 | return "#${columnKey} ${rowKey}\n${duration} (${(long)elapsedSeconds}s)" 89 | } 90 | 91 | @Override 92 | public String generateURL(CategoryDataset dataset, int row, int column) { 93 | int columnKey = dataset.getColumnKey(column) 94 | 95 | return "../${columnKey}" 96 | } 97 | } 98 | 99 | private CategoryDataset getDataSet() { 100 | def dataSet = new DefaultCategoryDataset() 101 | for (BuildResult buildResult : buildResults.reverse()) { 102 | def buildNumber = buildResult.build.getNumber() 103 | for (ConsoleLogMatch match : buildResult.consoleLogMatches) { 104 | dataSet.addValue(getTimeTakenInSeconds(match), match.label, buildNumber) 105 | } 106 | } 107 | return dataSet 108 | } 109 | 110 | private static double getTimeTakenInSeconds(ConsoleLogMatch match) { 111 | match.unFormattedTimeTaken / 1000 112 | } 113 | 114 | private List calculateMeanBuildResult() { 115 | List meanBuildResult = [] 116 | Multimap allBuildResults = getAllBuildResults() 117 | 118 | for (String label : allBuildResults.keySet()) { 119 | def meanElapsedMillis = mean(allBuildResults.get(label).elapsedMillis as List) 120 | def meanElapsedMillisOfNextMatch = mean(allBuildResults.get(label).elapsedMillisOfNextMatch as List) 121 | 122 | meanBuildResult.add(new ConsoleLogMatch( 123 | label: label, 124 | elapsedMillis: meanElapsedMillis, 125 | matchedLine: 'N/A', 126 | elapsedMillisOfNextMatch: meanElapsedMillisOfNextMatch, 127 | )) 128 | } 129 | return meanBuildResult 130 | } 131 | 132 | private static long mean(List values) { 133 | return values.sum() / values.size() 134 | } 135 | 136 | private Multimap getAllBuildResults() { 137 | Multimap allBuildResults = LinkedListMultimap.create() 138 | for (BuildResult buildResult : buildResults) { 139 | for (ConsoleLogMatch logResult : buildResult.consoleLogMatches) { 140 | allBuildResults.get(logResult.label).add(logResult) 141 | } 142 | } 143 | return allBuildResults 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/BuildResult.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.analysis 3 | 4 | import hudson.model.Run 5 | 6 | class BuildResult { 7 | List consoleLogMatches 8 | Run build 9 | } 10 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/ConsoleLogMatch.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.analysis 3 | 4 | import groovy.transform.AutoClone 5 | import groovy.transform.EqualsAndHashCode 6 | import groovy.transform.ToString 7 | import org.apache.commons.lang.time.DurationFormatUtils 8 | 9 | import java.beans.Transient 10 | 11 | @AutoClone 12 | @EqualsAndHashCode 13 | @ToString(includeNames = true) 14 | class ConsoleLogMatch { 15 | private static final int TARGETED_LINE_LENGTH = 30 16 | 17 | String label 18 | String matchedLine 19 | long elapsedMillis 20 | long elapsedMillisOfNextMatch 21 | 22 | @Transient 23 | String getElapsedTime() { 24 | format(elapsedMillis) 25 | } 26 | 27 | @Transient 28 | String getTimeTaken() { 29 | format(getUnFormattedTimeTaken()) 30 | } 31 | 32 | @Transient 33 | long getUnFormattedTimeTaken() { 34 | elapsedMillisOfNextMatch - elapsedMillis 35 | } 36 | 37 | String getMatchedLine() { 38 | if (matchedLine.length() > TARGETED_LINE_LENGTH + 3) { 39 | return matchedLine.substring(0, TARGETED_LINE_LENGTH) + '...' 40 | } 41 | 42 | return matchedLine 43 | } 44 | 45 | private static String format(long elapsedMillis) { 46 | return DurationFormatUtils.formatDuration(elapsedMillis, 'mm:ss.S'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/LogParser.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.analysis 3 | 4 | import hudson.model.Run 5 | import hudson.plugins.timestamper.api.TimestamperAPI 6 | import org.jenkins.ci.plugins.buildtimeblame.io.ReportIO 7 | 8 | class LogParser { 9 | List relevantSteps = [] 10 | 11 | LogParser(List relevantSteps) { 12 | this.relevantSteps = relevantSteps.collect() 13 | } 14 | 15 | BuildResult getBuildResult(Run run) { 16 | def report = ReportIO.getInstance(run).readFile() 17 | 18 | if (report.isPresent()) { 19 | return new BuildResult(consoleLogMatches: report.get(), build: run) 20 | } 21 | 22 | return new BuildResult(consoleLogMatches: computeRelevantLogLines(run), build: run) 23 | } 24 | 25 | private List computeRelevantLogLines(Run run) { 26 | List result = [] 27 | def addSingleMatchIfFound = { String label, String line, Long elapsedMillis -> 28 | result.add(new ConsoleLogMatch( 29 | label: label, 30 | matchedLine: line, 31 | elapsedMillis: elapsedMillis 32 | )) 33 | } 34 | 35 | def lastTimestampOfBuild = processMatches(run, addSingleMatchIfFound) 36 | def nextMatchTimestamp = lastTimestampOfBuild 37 | 38 | result.reverse().forEach({ ConsoleLogMatch match -> 39 | match.elapsedMillisOfNextMatch = nextMatchTimestamp 40 | nextMatchTimestamp = match.elapsedMillis 41 | }) 42 | 43 | ReportIO.getInstance(run).write(result) 44 | return result 45 | } 46 | 47 | private int processMatches(Run run, Closure onMatch) { 48 | long mostRecentTimestamp = 0 49 | def steps = relevantSteps.collect() 50 | def reader = TimestamperAPI.get().read(run, 'appendLog&elapsed=S') 51 | def hasTimestamps = false 52 | 53 | try { 54 | reader.lines() 55 | .map { line -> TimedLog.fromText(line) } 56 | .forEach { TimedLog line -> 57 | def timestamp = line.elapsedMillis.orElse(mostRecentTimestamp) 58 | mostRecentTimestamp = timestamp 59 | hasTimestamps = mostRecentTimestamp != 0 60 | 61 | getMatchingRegex(line.log, steps).ifPresent({ step -> 62 | onMatch(step.label, line.log, timestamp) 63 | }) 64 | } 65 | } finally { 66 | reader.close() 67 | } 68 | 69 | if (!hasTimestamps) { 70 | throw new TimestampMissingException() 71 | } 72 | 73 | return mostRecentTimestamp 74 | } 75 | 76 | static Optional getMatchingRegex(String value, List steps) { 77 | for (RelevantStep step : steps) { 78 | if (step.pattern.matcher(value).matches()) { 79 | if (step.onlyFirstMatch) { 80 | steps.remove(step) 81 | } 82 | return Optional.of(step) 83 | } 84 | } 85 | return Optional.empty() 86 | } 87 | 88 | public static class TimestampMissingException extends RuntimeException { 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/MapFixStackedAreaRenderer.groovy: -------------------------------------------------------------------------------- 1 | package org.jenkins.ci.plugins.buildtimeblame.analysis 2 | 3 | import org.jfree.chart.entity.EntityCollection 4 | import org.jfree.chart.renderer.category.StackedAreaRenderer 5 | import org.jfree.data.category.CategoryDataset 6 | 7 | import java.awt.Shape 8 | import java.awt.geom.GeneralPath 9 | import java.awt.geom.PathIterator 10 | 11 | class MapFixStackedAreaRenderer extends StackedAreaRenderer { 12 | /** 13 | * Breaks GeneralPath hotspots from StackedAreaRenderer into separate paths for 14 | * each polygon. This workaround lets JFreeChart build the correct image map. 15 | */ 16 | @Override 17 | protected void addItemEntity(EntityCollection entities, 18 | CategoryDataset dataset, int row, int column, Shape hotspot) { 19 | PathIterator pi = hotspot.getPathIterator(null, 1.0) 20 | float[] coords = new float[6] 21 | GeneralPath subPath = new GeneralPath(pi.getWindingRule()) 22 | 23 | while (!pi.isDone()) { 24 | int type = pi.currentSegment(coords) 25 | 26 | switch (type) { 27 | case PathIterator.SEG_MOVETO: 28 | subPath.moveTo(coords[0], coords[1]) 29 | break 30 | case PathIterator.SEG_LINETO: 31 | subPath.lineTo(coords[0], coords[1]) 32 | break 33 | case PathIterator.SEG_CLOSE: 34 | super.addItemEntity(entities, dataset, row, column, subPath) 35 | subPath = new GeneralPath() 36 | break 37 | } 38 | 39 | pi.next() 40 | } 41 | 42 | if (subPath.getCurrentPoint() != null) { 43 | super.addItemEntity(entities, dataset, row, column, subPath) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/RelevantStep.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | 3 | package org.jenkins.ci.plugins.buildtimeblame.analysis 4 | 5 | import com.fasterxml.jackson.annotation.JsonFormat 6 | import com.fasterxml.jackson.annotation.JsonProperty 7 | import groovy.transform.EqualsAndHashCode 8 | import groovy.transform.ToString 9 | 10 | import java.util.regex.Pattern 11 | 12 | @EqualsAndHashCode 13 | @ToString 14 | class RelevantStep { 15 | @JsonFormat(shape = JsonFormat.Shape.OBJECT) 16 | @JsonProperty("key") 17 | Pattern pattern 18 | String label 19 | boolean onlyFirstMatch 20 | 21 | RelevantStep() { 22 | } 23 | 24 | RelevantStep(Pattern pattern, String label, Boolean onlyFirstMatch) { 25 | this.pattern = pattern 26 | this.label = label 27 | this.onlyFirstMatch = onlyFirstMatch 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/TimedLog.groovy: -------------------------------------------------------------------------------- 1 | package org.jenkins.ci.plugins.buildtimeblame.analysis 2 | 3 | import groovy.transform.EqualsAndHashCode 4 | import org.apache.commons.lang3.StringUtils 5 | 6 | import java.util.stream.Collectors 7 | 8 | @EqualsAndHashCode 9 | class TimedLog { 10 | String log 11 | Optional elapsedMillis = Optional.empty() 12 | 13 | public static TimedLog fromText(String timestamperLog) { 14 | if (timestamperLog.startsWith(' ')) { 15 | return new TimedLog( 16 | log: timestamperLog 17 | ) 18 | } else { 19 | def split = timestamperLog.split(' ') 20 | if (StringUtils.isNumeric(split[0])) { 21 | return new TimedLog( 22 | elapsedMillis: Optional.of(Long.valueOf(split[0])), 23 | log: Arrays.stream(split).skip(1).collect(Collectors.joining(' ')), 24 | ) 25 | } else { 26 | return new TimedLog( 27 | log: timestamperLog 28 | ) 29 | } 30 | } 31 | } 32 | 33 | String setLog(String log) { 34 | this.log = log.trim() 35 | } 36 | 37 | String toText() { 38 | def elapsed = elapsedMillis.map({ time -> String.valueOf(time) }).orElse(' ') 39 | 40 | return "$elapsed $log" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/io/BlameFilePaths.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.io 3 | 4 | 5 | import hudson.model.Job 6 | import hudson.model.PersistenceRoot 7 | import hudson.model.Run 8 | 9 | class BlameFilePaths { 10 | static File getConfigFile(Job job) { 11 | getFile(job, 'build-time-blame-config.json') 12 | } 13 | 14 | static File getLegacyConfigFile(Job job) { 15 | getFile(job, 'buildtimeblameconfig') 16 | } 17 | 18 | static File getReportFile(Run run) { 19 | getFile(run, 'build-time-blame-matches.json') 20 | } 21 | 22 | private static File getFile(PersistenceRoot persistenceRoot, String fileName) { 23 | new File(persistenceRoot.rootDir, fileName) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/io/ConfigIO.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.io 3 | 4 | import com.fasterxml.jackson.databind.DeserializationFeature 5 | import com.fasterxml.jackson.databind.ObjectMapper 6 | import hudson.model.Job 7 | import org.jenkins.ci.plugins.buildtimeblame.analysis.RelevantStep 8 | 9 | import static org.jenkins.ci.plugins.buildtimeblame.io.BlameFilePaths.getConfigFile 10 | import static org.jenkins.ci.plugins.buildtimeblame.io.BlameFilePaths.getLegacyConfigFile 11 | 12 | 13 | class ConfigIO { 14 | Job job 15 | private static ObjectMapper objectMapper = new ObjectMapper() 16 | .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) 17 | 18 | ConfigIO(Job job) { 19 | this.job = job 20 | } 21 | 22 | public ReportConfiguration parse(String configString) { 23 | objectMapper.readValue(configString, ReportConfiguration) 24 | } 25 | 26 | public write(ReportConfiguration reportConfig) { 27 | getConfigFile(job).write(objectMapper.writeValueAsString(reportConfig)) 28 | } 29 | 30 | public ReportConfiguration readOrDefault(List defaultSteps = []) { 31 | try { 32 | ReportConfiguration configuration = getConfiguration() 33 | return configuration 34 | } catch (Exception ignored) { 35 | return new ReportConfiguration(relevantSteps: defaultSteps) 36 | } 37 | } 38 | 39 | private ReportConfiguration getConfiguration() { 40 | def legacyFile = getLegacyConfigFile(job) 41 | 42 | if (legacyFile.exists()) { 43 | def relevantSteps = objectMapper.readValue(legacyFile.text, RelevantStep[].class) 44 | def configuration = new ReportConfiguration(relevantSteps: relevantSteps) 45 | moveConfigToNewFile(configuration, legacyFile) 46 | return configuration 47 | } 48 | 49 | return parse(getConfigFile(job).text) 50 | } 51 | 52 | private void moveConfigToNewFile(ReportConfiguration configuration, File legacyFile) { 53 | getConfigFile(job).write(objectMapper.writeValueAsString(configuration)) 54 | legacyFile.delete() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/io/ReportConfiguration.groovy: -------------------------------------------------------------------------------- 1 | package org.jenkins.ci.plugins.buildtimeblame.io 2 | 3 | import groovy.transform.EqualsAndHashCode 4 | import groovy.transform.ToString 5 | import org.jenkins.ci.plugins.buildtimeblame.analysis.RelevantStep 6 | 7 | @ToString 8 | @EqualsAndHashCode 9 | class ReportConfiguration { 10 | Integer maxBuilds 11 | List relevantSteps 12 | } 13 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/io/ReportIO.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.io 3 | 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import hudson.model.Run 6 | import org.jenkins.ci.plugins.buildtimeblame.analysis.ConsoleLogMatch 7 | 8 | import static org.jenkins.ci.plugins.buildtimeblame.io.BlameFilePaths.getReportFile 9 | 10 | class ReportIO { 11 | Run build 12 | private static ObjectMapper objectMapper = new ObjectMapper() 13 | 14 | public static ReportIO getInstance(Run build) { 15 | return new ReportIO(build) 16 | } 17 | 18 | ReportIO(Run build) { 19 | this.build = build 20 | } 21 | 22 | public void clear() { 23 | getReportFile(build).delete() 24 | } 25 | 26 | public void write(List report) { 27 | getReportFile(build).write(objectMapper.writeValueAsString(report)) 28 | } 29 | 30 | public Optional> readFile() { 31 | try { 32 | def reportContent = getReportFile(build).text 33 | def report = objectMapper.readValue(reportContent, ConsoleLogMatch[]) 34 | 35 | return Optional.of(Arrays.asList(report)) 36 | } catch (Exception ignored) { 37 | return Optional.empty() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/groovy/org/jenkins/ci/plugins/buildtimeblame/io/StaplerUtils.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.io 3 | 4 | import net.sf.json.JSONArray 5 | import net.sf.json.JSONObject 6 | import org.kohsuke.stapler.StaplerRequest 7 | import org.kohsuke.stapler.StaplerResponse 8 | 9 | class StaplerUtils { 10 | public static void redirectToParentURI(StaplerRequest request, StaplerResponse response) { 11 | response.sendRedirect(getParentURI(request)) 12 | } 13 | 14 | private static String getParentURI(StaplerRequest request) { 15 | def originalRequestURI = request.getOriginalRequestURI() 16 | if (originalRequestURI.endsWith('/')) { 17 | originalRequestURI = originalRequestURI.substring(0, originalRequestURI.size() - 1) 18 | } 19 | 20 | return originalRequestURI.substring(0, originalRequestURI.lastIndexOf('/')) 21 | } 22 | 23 | static List getAsList(JSONObject jsonObject, String key) { 24 | Object value = jsonObject.get(key) 25 | if (value instanceof JSONArray) { 26 | return value.collect() as List 27 | } 28 | 29 | return [value] as List 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/resources/index.jelly: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 |
8 | A Jenkins plugin for analyzing the historical console output of a Job with the goal of determining which steps 9 | are 10 | taking the most time. 11 |
12 |
13 | -------------------------------------------------------------------------------- /src/main/resources/org/jenkins/ci/plugins/buildtimeblame/action/BlameAction/action.jelly: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /src/main/resources/org/jenkins/ci/plugins/buildtimeblame/action/BlameAction/body.jelly: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /src/main/resources/org/jenkins/ci/plugins/buildtimeblame/action/BlameAction/edit.jelly: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 | 23 | 24 | 25 | 26 |
27 | 28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /src/main/resources/org/jenkins/ci/plugins/buildtimeblame/action/BlameAction/index.jelly: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 |
8 |

Build Time Blame Report for ${it.job.name}

9 | 10 |

${it.missingTimestampsDescription}

11 | 12 |
13 | 14 |

Report Configuration

15 | 16 |
17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /src/main/resources/org/jenkins/ci/plugins/buildtimeblame/analysis/BlameReport/body.jelly: -------------------------------------------------------------------------------- 1 | 2 | 4 |
5 |

Build Time Trend

6 | [Duration graph] 7 |
8 | 9 |

Last Successful Build Times

10 | 11 | 12 |

Mean Successful Build Times

13 | 14 |
15 | -------------------------------------------------------------------------------- /src/main/resources/org/jenkins/ci/plugins/buildtimeblame/analysis/BlameReport/reportTable.jelly: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
LabelElapsed Time (mm:ss.S)Time Taken (mm:ss.S)Matched Line
${item.label}${item.elapsedTime}${item.timeTaken}${item.matchedLine}
26 |
27 | -------------------------------------------------------------------------------- /src/main/webapp/build-time-blame.css: -------------------------------------------------------------------------------- 1 | .build-time-blame table .pane, .build-time-blame table .pane-header { 2 | text-align: right; 3 | padding: 10px; 4 | } 5 | 6 | .build-time-blame table.pane { 7 | width: auto; 8 | } 9 | 10 | .build-time-blame .setting-main { 11 | width: auto; 12 | } 13 | 14 | .build-time-blame .error-text { 15 | color: red; 16 | } 17 | -------------------------------------------------------------------------------- /src/spotbugs/excludesFilter.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/action/BlameActionFactoryTest.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.action 3 | 4 | import hudson.model.Job 5 | import jenkins.model.TransientActionFactory 6 | import spock.lang.Specification 7 | 8 | class BlameActionFactoryTest extends Specification { 9 | def 'should add action to all jobs'() { 10 | given: 11 | def job = Mock(Job) { 12 | it.rootDir >> new File('') 13 | } 14 | TransientActionFactory factory = new BlameActionFactory() 15 | 16 | when: 17 | def addedActions = factory.createFor(job) 18 | 19 | then: 20 | addedActions == [new BlameAction(job)] 21 | } 22 | 23 | def 'should apply to all jobs'() { 24 | given: 25 | TransientActionFactory factory = new BlameActionFactory() 26 | 27 | when: 28 | def type = factory.type() 29 | 30 | then: 31 | type == Job 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/action/BlameActionTest.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.action 3 | 4 | import groovy.transform.ToString 5 | import hudson.model.Action 6 | import hudson.model.Job 7 | import hudson.model.Result 8 | import hudson.model.Run 9 | import hudson.util.RunList 10 | import org.jenkins.ci.plugins.buildtimeblame.analysis.* 11 | import org.jenkins.ci.plugins.buildtimeblame.io.ConfigIO 12 | import org.jenkins.ci.plugins.buildtimeblame.io.ReportConfiguration 13 | import org.jenkins.ci.plugins.buildtimeblame.io.ReportIO 14 | import org.jenkins.ci.plugins.buildtimeblame.io.StaplerUtils 15 | import org.kohsuke.stapler.StaplerRequest 16 | import org.kohsuke.stapler.StaplerResponse 17 | import spock.lang.Specification 18 | 19 | import static net.sf.json.JSONObject.fromObject 20 | 21 | class BlameActionTest extends Specification { 22 | ConfigIO mockConfigIO 23 | 24 | void setup() { 25 | GroovyMock(StaplerUtils, global: true) 26 | mockConfigIO = GroovyMock(ConfigIO, global: true) 27 | //noinspection GroovyAssignabilityCheck 28 | _ * new ConfigIO(_) >> mockConfigIO 29 | _ * mockConfigIO.readOrDefault(_ as List) >> new ReportConfiguration(relevantSteps: [new RelevantStep(~'.*', 'label', true)]) 30 | } 31 | 32 | def 'should implement basic Action methods'() { 33 | when: 34 | Action blameAction = new BlameAction(null); 35 | 36 | then: 37 | blameAction.getIconClassName() == 'icon-monitor' 38 | blameAction.getDisplayName() == 'Build Time Blame Report' 39 | blameAction.getUrlName() == 'buildTimeBlameReport' 40 | } 41 | 42 | def 'should store job'() { 43 | given: 44 | Job job = Mock(Job) 45 | 46 | when: 47 | def blameAction = new BlameAction(job) 48 | 49 | then: 50 | blameAction.job == job 51 | } 52 | 53 | def 'should read configuration from file'() { 54 | given: 55 | def job = Mock(Job) 56 | def expected = new ReportConfiguration(relevantSteps: [new RelevantStep((~'.*anything.*'), 'label', true)]) 57 | 58 | when: 59 | def blameAction = new BlameAction(job) 60 | 61 | then: 62 | 1 * new ConfigIO(job) >> mockConfigIO 63 | 1 * mockConfigIO.readOrDefault(BlameAction.DEFAULT_PATTERNS) >> expected 64 | blameAction.config == expected 65 | } 66 | 67 | def 'should have correct default patterns'() { 68 | expect: 69 | BlameAction.DEFAULT_PATTERNS.toListString() == [ 70 | new RelevantStep(~/.*Started by.*/, 'Job Started on Executor', true), 71 | new RelevantStep(~/^Finished: (SUCCESS|UNSTABLE|FAILURE|NOT_BUILT|ABORTED)$.*/, 'Finished', true), 72 | ].toListString() 73 | } 74 | 75 | def 'should parse log for each build'() { 76 | given: 77 | def lastBuildNumber = 37 78 | def logParser = GroovyMock(LogParser, global: true) 79 | def job = Mock(Job) 80 | def build1 = getRunWith(Result.SUCCESS) 81 | _ * build1.number >> lastBuildNumber 82 | def build2 = getRunWith(Result.SUCCESS) 83 | _ * build1.number >> 36 84 | def build1Results = new BuildResult(consoleLogMatches: [new ConsoleLogMatch(label: 'one')]) 85 | def build2Results = new BuildResult(consoleLogMatches: [new ConsoleLogMatch(label: 'two')]) 86 | def blameAction = new BlameAction(job) 87 | 88 | when: 89 | def report = blameAction.getReport() 90 | 91 | then: 92 | 1 * job.getBuilds() >> RunList.fromRuns([build1, build2]) 93 | 1 * new LogParser(blameAction.config.relevantSteps) >> logParser 94 | 1 * logParser.getBuildResult(build1) >> build1Results 95 | 1 * logParser.getBuildResult(build2) >> build2Results 96 | report == new BlameReport([build1Results, build2Results]) 97 | blameAction.buildsWithoutTimestamps == [] 98 | blameAction.lastProcessedBuild == lastBuildNumber 99 | } 100 | 101 | 102 | def 'should not skip builds if maxBuilds is 0'() { 103 | given: 104 | def lastBuildNumber = 37 105 | def logParser = GroovyMock(LogParser, global: true) 106 | def job = Mock(Job) 107 | def build1 = getRunWith(Result.SUCCESS) 108 | _ * build1.number >> lastBuildNumber 109 | def build2 = getRunWith(Result.SUCCESS) 110 | _ * build1.number >> 36 111 | def build1Results = new BuildResult(consoleLogMatches: [new ConsoleLogMatch(label: 'one')]) 112 | def build2Results = new BuildResult(consoleLogMatches: [new ConsoleLogMatch(label: 'two')]) 113 | def blameAction = new BlameAction(job) 114 | blameAction.config.maxBuilds = 0 115 | 116 | when: 117 | def report = blameAction.getReport() 118 | 119 | then: 120 | 1 * job.getBuilds() >> RunList.fromRuns([build1, build2]) 121 | 1 * new LogParser(blameAction.config.relevantSteps) >> logParser 122 | 1 * logParser.getBuildResult(build1) >> build1Results 123 | 1 * logParser.getBuildResult(build2) >> build2Results 124 | report == new BlameReport([build1Results, build2Results]) 125 | blameAction.buildsWithoutTimestamps == [] 126 | blameAction.lastProcessedBuild == lastBuildNumber 127 | } 128 | 129 | def 'should skip logs after the max builds'() { 130 | given: 131 | def lastBuildNumber = 37 132 | def logParser = GroovyMock(LogParser, global: true) 133 | def job = Mock(Job) 134 | def build1 = getRunWith(Result.SUCCESS) 135 | _ * build1.number >> lastBuildNumber 136 | def build2 = getRunWith(Result.SUCCESS) 137 | _ * build1.number >> 36 138 | def build1Results = new BuildResult(consoleLogMatches: [new ConsoleLogMatch(label: 'one')]) 139 | def blameAction = new BlameAction(job) 140 | blameAction.config.maxBuilds = 1 141 | 142 | when: 143 | def report = blameAction.getReport() 144 | 145 | then: 146 | 1 * job.getBuilds() >> RunList.fromRuns([build1, build2]) 147 | 1 * new LogParser(blameAction.config.relevantSteps) >> logParser 148 | 1 * logParser.getBuildResult(build1) >> build1Results 149 | 0 * logParser.getBuildResult(_) 150 | report == new BlameReport([build1Results]) 151 | blameAction.buildsWithoutTimestamps == [] 152 | blameAction.lastProcessedBuild == lastBuildNumber 153 | } 154 | 155 | def 'should only include successful or unstable builds'() { 156 | given: 157 | def logParser = GroovyMock(LogParser, global: true) 158 | def job = Mock(Job) 159 | def successful = getRunWith(Result.SUCCESS) 160 | def unstable = getRunWith(Result.UNSTABLE) 161 | def failed = getRunWith(Result.FAILURE) 162 | def aborted = getRunWith(Result.ABORTED) 163 | def notBuilt = getRunWith(Result.NOT_BUILT) 164 | def blameAction = new BlameAction(job) 165 | 166 | when: 167 | blameAction.getReport() 168 | 169 | then: 170 | 1 * job.getBuilds() >> RunList.fromRuns([successful, unstable, failed, aborted, notBuilt]) 171 | 1 * new LogParser(blameAction.config.relevantSteps) >> logParser 172 | 1 * logParser.getBuildResult(successful) >> [] 173 | 1 * logParser.getBuildResult(unstable) >> [] 174 | 0 * logParser.getBuildResult(_ as Run) 175 | } 176 | 177 | def 'should only include non-running builds'() { 178 | given: 179 | def logParser = GroovyMock(LogParser, global: true) 180 | def job = Mock(Job) 181 | def running = getRunWith(Result.SUCCESS, true) 182 | def notRunning = getRunWith(Result.UNSTABLE) 183 | def blameAction = new BlameAction(job) 184 | 185 | when: 186 | blameAction.getReport() 187 | 188 | then: 189 | 1 * job.getBuilds() >> RunList.fromRuns([running, notRunning]) 190 | 1 * new LogParser(blameAction.config.relevantSteps) >> logParser 191 | 1 * logParser.getBuildResult(notRunning) >> [] 192 | 0 * logParser.getBuildResult(_ as Run) 193 | } 194 | 195 | def 'should handle missing timestamps'() { 196 | given: 197 | def lastBuildNumber = 6 198 | def logParser = GroovyMock(LogParser, global: true) 199 | def job = Mock(Job) 200 | Run build1 = getRunWith(Result.SUCCESS) 201 | _ * build1.number >> 5 202 | Run build2 = getRunWith(Result.SUCCESS) 203 | _ * build2.number >> lastBuildNumber 204 | def build1Results = new BuildResult(consoleLogMatches: [new ConsoleLogMatch(label: 'one')]) 205 | def blameAction = new BlameAction(job) 206 | 207 | when: 208 | def report = blameAction.getReport() 209 | 210 | then: 211 | 1 * job.getBuilds() >> RunList.fromRuns([build1, build2]) 212 | 1 * new LogParser(blameAction.config.relevantSteps) >> logParser 213 | 1 * logParser.getBuildResult(build1) >> build1Results 214 | 1 * logParser.getBuildResult(build2) >> { throw new RuntimeException() } 215 | report == new BlameReport([build1Results]) 216 | blameAction.buildsWithoutTimestamps == [build2] 217 | blameAction.lastProcessedBuild == lastBuildNumber 218 | } 219 | 220 | def 'should override equal appropriately'() { 221 | given: 222 | def job = Mock(Job) 223 | 224 | expect: 225 | new BlameAction(job) == new BlameAction(job) 226 | new BlameAction(job) != new BlameAction(Mock(Job)) 227 | } 228 | 229 | def 'should update configuration'() { 230 | given: 231 | def job = Mock(Job) 232 | def response = Mock(StaplerResponse) 233 | def blameAction = new BlameAction(job) 234 | def expectedConfig = new ReportConfiguration(maxBuilds: 99191, relevantSteps: [new RelevantStep(~/.*|.*/, 'anything happened', false)]) 235 | def submittedConfig = fromObject(expectedConfig) 236 | def request = Mock(StaplerRequest) { _ * it.getSubmittedForm() >> submittedConfig } 237 | 238 | when: 239 | blameAction.doReprocessBlameReport(request, response) 240 | 241 | then: 242 | 1 * new ConfigIO(job) >> mockConfigIO 243 | 1 * mockConfigIO.parse(submittedConfig.toString()) >> expectedConfig 244 | 1 * mockConfigIO.write(expectedConfig) 245 | blameAction.config == expectedConfig 246 | } 247 | 248 | def 'should redirect to parent when updating configuration'() { 249 | given: 250 | def response = Mock(StaplerResponse) 251 | def request = Mock(StaplerRequest) 252 | def blameAction = new BlameAction(Mock(Job)) 253 | 254 | when: 255 | blameAction.doReprocessBlameReport(request, response) 256 | 257 | then: 258 | 1 * StaplerUtils.redirectToParentURI(request, response) 259 | } 260 | 261 | def 'should clear all build reports when updating configuration'() { 262 | given: 263 | def mockReportIO = GroovyMock(ReportIO, global: true) 264 | def firstRun = getRunWith(Result.FAILURE) 265 | def secondRun = getRunWith(Result.SUCCESS) 266 | 267 | def job = Mock(Job) { Job job -> 268 | job.getBuilds() >> RunList.fromRuns([firstRun, secondRun]) 269 | } 270 | 271 | def response = Mock(StaplerResponse) 272 | def request = Mock(StaplerRequest) 273 | def blameAction = new BlameAction(job) 274 | blameAction._report = new BlameReport(null) 275 | blameAction.buildsWithoutTimestamps = [Mock(Run)] 276 | 277 | when: 278 | blameAction.doReprocessBlameReport(request, response) 279 | 280 | then: 281 | 1 * new ReportIO(firstRun) >> mockReportIO 282 | 1 * new ReportIO(secondRun) >> mockReportIO 283 | 2 * mockReportIO.clear() 284 | blameAction._report == null 285 | blameAction.buildsWithoutTimestamps == [] 286 | } 287 | 288 | def 'should not recalculate build results if report exists'() { 289 | given: 290 | def job = Mock(Job) 291 | def blameAction = new BlameAction(job) 292 | def expected = new BlameReport([]) 293 | def runWithoutTimestamps = Mock(Run) 294 | def lastBuildNumber = 3 295 | blameAction._report = expected 296 | blameAction.buildsWithoutTimestamps = [runWithoutTimestamps] 297 | blameAction.lastProcessedBuild = lastBuildNumber 298 | 299 | when: 300 | def report = blameAction.getReport() 301 | 302 | then: 303 | report == expected 304 | blameAction.buildsWithoutTimestamps == [runWithoutTimestamps] 305 | 1 * job.getNearestBuild(3) >> null 306 | 0 * _ 307 | } 308 | 309 | def 'should recalculate build results if new builds have been run'() { 310 | given: 311 | def job = Mock(Job) 312 | def blameAction = new BlameAction(job) 313 | def expected = new BlameReport([]) 314 | def runWithoutTimestamps = Mock(Run) 315 | def lastBuildNumber = 15 316 | blameAction._report = expected 317 | blameAction.buildsWithoutTimestamps = [runWithoutTimestamps] 318 | blameAction.lastProcessedBuild = lastBuildNumber 319 | def logParser = GroovyMock(LogParser, global: true) 320 | def build1 = getRunWith(Result.SUCCESS) 321 | def build2 = getRunWith(Result.SUCCESS) 322 | def build1Results = new BuildResult(consoleLogMatches: [new ConsoleLogMatch(label: 'one')]) 323 | def build2Results = new BuildResult(consoleLogMatches: [new ConsoleLogMatch(label: 'two')]) 324 | 325 | when: 326 | def report = blameAction.getReport() 327 | 328 | then: 329 | 1 * job.getBuilds() >> RunList.fromRuns([build1, build2]) 330 | 1 * job.getNearestBuild(lastBuildNumber) >> Mock(hudson.model.AbstractBuild) 331 | 1 * new LogParser(blameAction.config.relevantSteps) >> logParser 332 | 1 * logParser.getBuildResult(build1) >> build1Results 333 | 1 * logParser.getBuildResult(build2) >> build2Results 334 | report == new BlameReport([build1Results, build2Results]) 335 | } 336 | 337 | def 'should include helpful annotation'() { 338 | expect: 339 | BlameAction.getAnnotation(ToString) != null 340 | } 341 | 342 | def 'should not have missing timestamp description if no builds'() { 343 | given: 344 | def blameAction = new BlameAction(Mock(Job)) 345 | 346 | expect: 347 | blameAction.missingTimestampsDescription == '' 348 | } 349 | 350 | def 'should not have missing timestamp description if failed builds since the first successful build'() { 351 | given: 352 | def blameAction = new BlameAction(Mock(Job)) 353 | blameAction._report = new BlameReport([new BuildResult(build: Mock(Run) { _ * it.getNumber() >> 60 })]) 354 | blameAction.buildsWithoutTimestamps = [ 355 | Mock(Run) { _ * it.getNumber() >> 58 }, 356 | Mock(Run) { _ * it.getNumber() >> 59 }, 357 | Mock(Run) { _ * it.getNumber() >> 57 }, 358 | ] 359 | 360 | expect: 361 | blameAction.missingTimestampsDescription == '' 362 | } 363 | 364 | def 'should have correct missing timestamp description if some builds match'() { 365 | given: 366 | def blameAction = new BlameAction(Mock(Job)) 367 | blameAction._report = new BlameReport([new BuildResult(build: Mock(Run) { _ * it.getNumber() >> 60 })]) 368 | def firstBuildNumber = 105 369 | def secondBuildNumber = 99 370 | def thirdBuildNumber = 61 371 | def expectedBuildNumbers = "$thirdBuildNumber, $secondBuildNumber, $firstBuildNumber" 372 | 373 | blameAction.buildsWithoutTimestamps = [ 374 | Mock(Run) { _ * it.getNumber() >> 59 }, 375 | Mock(Run) { _ * it.getNumber() >> firstBuildNumber }, 376 | Mock(Run) { _ * it.getNumber() >> secondBuildNumber }, 377 | Mock(Run) { _ * it.getNumber() >> 35 }, 378 | Mock(Run) { _ * it.getNumber() >> thirdBuildNumber }, 379 | ] 380 | 381 | expect: 382 | blameAction.missingTimestampsDescription == "Error finding timestamps for builds: $expectedBuildNumbers" 383 | } 384 | 385 | private Run getRunWith(Result result, boolean isBuilding = false) { 386 | return Mock(Run) { 387 | _ * it.result >> result 388 | _ * it.isBuilding() >> isBuilding 389 | } 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/BlameReportTest.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.analysis 3 | 4 | import groovy.transform.EqualsAndHashCode 5 | import groovy.transform.ToString 6 | import hudson.model.Run 7 | import hudson.util.Graph 8 | import hudson.util.ReflectionUtils 9 | import org.jfree.data.category.CategoryDataset 10 | import org.jfree.data.category.DefaultCategoryDataset 11 | import spock.lang.Specification 12 | 13 | class BlameReportTest extends Specification { 14 | def 'should include helpful annotations'() { 15 | expect: 16 | BlameReport.getAnnotation(EqualsAndHashCode) != null 17 | BlameReport.getAnnotation(ToString) != null 18 | } 19 | 20 | def 'should get latest build result'() { 21 | given: 22 | def logResult = new ConsoleLogMatch(label: 'test') 23 | def buildResults = [new BuildResult(consoleLogMatches: [logResult]), null, null] 24 | 25 | when: 26 | def firstBuildResult = new BlameReport(buildResults).getLatestBuildResult() 27 | 28 | then: 29 | firstBuildResult == [logResult] 30 | } 31 | 32 | def 'should handle no builds on latest build result'() { 33 | given: 34 | def buildResults = [] 35 | 36 | when: 37 | def firstBuildResult = new BlameReport(buildResults).getLatestBuildResult() 38 | 39 | then: 40 | firstBuildResult == [] 41 | } 42 | 43 | def 'should get mean build result'() { 44 | given: 45 | def buildResults = [ 46 | new BuildResult(consoleLogMatches: [ 47 | new ConsoleLogMatch(label: 'Start', elapsedMillis: 0, elapsedMillisOfNextMatch: 1000), 48 | new ConsoleLogMatch(label: 'Middle', elapsedMillis: 1000, elapsedMillisOfNextMatch: 25000), 49 | new ConsoleLogMatch(label: 'Finish', elapsedMillis: 25000, elapsedMillisOfNextMatch: 26000), 50 | ]), 51 | new BuildResult(consoleLogMatches: [ 52 | new ConsoleLogMatch(label: 'Start', elapsedMillis: 500, elapsedMillisOfNextMatch: 2000), 53 | new ConsoleLogMatch(label: 'Middle', elapsedMillis: 2000, elapsedMillisOfNextMatch: 3000), 54 | new ConsoleLogMatch(label: 'Finish', elapsedMillis: 3000, elapsedMillisOfNextMatch: 5000) 55 | ]), 56 | new BuildResult(consoleLogMatches: [ 57 | new ConsoleLogMatch(label: 'Start', elapsedMillis: 100, elapsedMillisOfNextMatch: 3000), 58 | new ConsoleLogMatch(label: 'Middle', elapsedMillis: 3000, elapsedMillisOfNextMatch: 4000), 59 | new ConsoleLogMatch(label: 'Finish', elapsedMillis: 4000, elapsedMillisOfNextMatch: 10000), 60 | ]), 61 | new BuildResult(consoleLogMatches: [ 62 | new ConsoleLogMatch(label: 'Start', elapsedMillis: 0, elapsedMillisOfNextMatch: 3000), 63 | new ConsoleLogMatch(label: 'Middle', elapsedMillis: 3000, elapsedMillisOfNextMatch: 5000), 64 | new ConsoleLogMatch(label: 'Finish', elapsedMillis: 5000, elapsedMillisOfNextMatch: 7000), 65 | ]), 66 | ] 67 | 68 | when: 69 | def medianBuildResult = new BlameReport(buildResults).getMeanBuildResult() 70 | 71 | then: 72 | medianBuildResult.size() == 3 73 | medianBuildResult[0] == buildExpectedMedianResult('Start', 150, 2250) 74 | medianBuildResult[1] == buildExpectedMedianResult('Middle', 2250, 9250) 75 | medianBuildResult[2] == buildExpectedMedianResult('Finish', 9250, 12000) 76 | } 77 | 78 | def 'should cache mean build result'() { 79 | given: 80 | def blameReport = new BlameReport([]) 81 | def expected = [buildExpectedMedianResult('Starting', 150, 0)] 82 | blameReport._meanBuildResult = expected 83 | 84 | when: 85 | def meanBuildResult = blameReport.getMeanBuildResult() 86 | 87 | then: 88 | meanBuildResult == expected 89 | } 90 | 91 | def 'should build graph of all build results over time'() { 92 | given: 93 | def latestBuildNumber = 98 94 | def previousBuildNumber = 53 95 | def latestBuild = Mock(Run) { Run it -> 96 | _ * it.getNumber() >> latestBuildNumber 97 | } 98 | def previousBuild = Mock(Run) { Run it -> 99 | _ * it.getNumber() >> previousBuildNumber 100 | } 101 | 102 | def buildResults = [ 103 | new BuildResult(consoleLogMatches: [ 104 | new ConsoleLogMatch(label: 'Start', elapsedMillis: 0, elapsedMillisOfNextMatch: 1000), 105 | new ConsoleLogMatch(label: 'Middle', elapsedMillis: 1000, elapsedMillisOfNextMatch: 25000), 106 | new ConsoleLogMatch(label: 'Finish', elapsedMillis: 25000, elapsedMillisOfNextMatch: 26000), 107 | ], build: latestBuild), 108 | new BuildResult(consoleLogMatches: [ 109 | new ConsoleLogMatch(label: 'Start', elapsedMillis: 500, elapsedMillisOfNextMatch: 2000), 110 | new ConsoleLogMatch(label: 'Middle', elapsedMillis: 2000, elapsedMillisOfNextMatch: 3000), 111 | new ConsoleLogMatch(label: 'Finish', elapsedMillis: 3000, elapsedMillisOfNextMatch: 5000) 112 | ], build: previousBuild), 113 | ] 114 | 115 | when: 116 | Graph graph = new BlameReport(buildResults).getGraph() 117 | 118 | then: 119 | graph.createGraph().getCategoryPlot().getDataset() == getExpectedDataSet() 120 | } 121 | 122 | def 'should build graph with tooltip and URL generators'() { 123 | given: 124 | def report = new BlameReport([]) 125 | def graph = report.getGraph() 126 | 127 | when: 128 | def chart = graph.createGraph() 129 | 130 | then: 131 | verifyAll(chart.getCategoryPlot().getRenderer()) { 132 | getToolTipGenerator(0, 0) != null 133 | getItemURLGenerator(0, 0) != null 134 | } 135 | } 136 | 137 | def 'should generate tooltips for graph areas'() { 138 | given: 139 | def buildNumber = 98 140 | def buildResults = [ 141 | new BuildResult(consoleLogMatches: [ 142 | new ConsoleLogMatch(label: 'Start', elapsedMillis: 0, elapsedMillisOfNextMatch: 1000), 143 | new ConsoleLogMatch(label: 'Middle', elapsedMillis: 1000, elapsedMillisOfNextMatch: 124000), 144 | new ConsoleLogMatch(label: 'Finish', elapsedMillis: 124000, elapsedMillisOfNextMatch: 260000), 145 | ], build: Mock(Run) { _ * it.getNumber() >> buildNumber }), 146 | ] 147 | def report = new BlameReport(buildResults) 148 | def dataSet = report.getDataSet() 149 | def chart = report.getGraph().createGraph() 150 | def toolTipGenerator = chart.getCategoryPlot().getRenderer().getToolTipGenerator(1, 0) 151 | 152 | when: 153 | def toolTip = toolTipGenerator.generateToolTip(dataSet, 1, 0) 154 | 155 | then: 156 | toolTip != null 157 | toolTip.contains("${buildNumber}") 158 | toolTip.contains('Middle') 159 | toolTip.contains('123s') 160 | } 161 | 162 | def 'should generate URLs for graph areas'() { 163 | given: 164 | def buildNumber = 98 165 | def buildResults = [ 166 | new BuildResult(consoleLogMatches: [ 167 | new ConsoleLogMatch(label: 'Start', elapsedMillis: 0, elapsedMillisOfNextMatch: 1000), 168 | new ConsoleLogMatch(label: 'Finish', elapsedMillis: 1000, elapsedMillisOfNextMatch: 1100), 169 | ], build: Mock(Run) { _ * it.getNumber() >> buildNumber }), 170 | ] 171 | def report = new BlameReport(buildResults) 172 | def dataSet = report.getDataSet() 173 | def chart = report.getGraph().createGraph() 174 | def urlGenerator = chart.getCategoryPlot().getRenderer().getItemURLGenerator(1, 0) 175 | 176 | when: 177 | def url = urlGenerator.generateURL(dataSet, 1, 0) 178 | 179 | then: 180 | url != null 181 | url.contains("${buildNumber}") 182 | } 183 | 184 | def 'should handle missing steps in build result'() { 185 | given: 186 | def buildNumber = 66 187 | def dataSet = new DefaultCategoryDataset() 188 | dataSet.addValue((double) 1.0, 'Compile', buildNumber) 189 | dataSet.addValue((double) 2.0, 'Compile', 31) 190 | dataSet.addValue((double) 3.0, 'Test', 31) 191 | 192 | def report = new BlameReport([]) 193 | def chart = report.getGraph().createGraph() 194 | def toolTipGenerator = chart.getCategoryPlot().getRenderer().getToolTipGenerator(1, 0) 195 | def urlGenerator = chart.getCategoryPlot().getRenderer().getItemURLGenerator(1, 0) 196 | 197 | when: 198 | def toolTip = toolTipGenerator.generateToolTip(dataSet, 1, 0) 199 | def url = urlGenerator.generateURL(dataSet, 1, 0) 200 | 201 | then: 202 | toolTip != null 203 | toolTip.contains('0s') 204 | url != null 205 | } 206 | 207 | CategoryDataset getExpectedDataSet() { 208 | def dataSet = new DefaultCategoryDataset() 209 | 210 | dataSet.addValue((double) 1.5, 'Start', 53) 211 | dataSet.addValue((double) 1.0, 'Middle', 53) 212 | dataSet.addValue((double) 2.0, 'Finish', 53) 213 | dataSet.addValue((double) 1.0, 'Start', 98) 214 | dataSet.addValue((double) 24.0, 'Middle', 98) 215 | dataSet.addValue((double) 1.0, 'Finish', 98) 216 | 217 | return dataSet 218 | } 219 | 220 | def Object getFieldValue(Class clazz, String fieldName, T instance) { 221 | def field = ReflectionUtils.findField(clazz, fieldName) 222 | field.setAccessible(true) 223 | return ReflectionUtils.getField(field, instance) 224 | } 225 | 226 | long getTimestamp(Graph graph) { 227 | return (graph as BlameReport.GraphImpl).getTimestamp() 228 | } 229 | 230 | ConsoleLogMatch buildExpectedMedianResult(String label, int elapsedTime, int nextTime) { 231 | new ConsoleLogMatch(label: label, elapsedMillis: elapsedTime, matchedLine: 'N/A', elapsedMillisOfNextMatch: nextTime) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/ConsoleLogMatchTest.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.analysis 3 | 4 | import groovy.transform.AutoClone 5 | import groovy.transform.EqualsAndHashCode 6 | import groovy.transform.ToString 7 | import spock.lang.Specification 8 | 9 | class ConsoleLogMatchTest extends Specification { 10 | def 'should include helpful annotations'() { 11 | expect: 12 | ConsoleLogMatch.getAnnotation(EqualsAndHashCode) != null 13 | ConsoleLogMatch.getAnnotation(ToString) != null 14 | ConsoleLogMatch.getAnnotation(AutoClone) != null 15 | } 16 | 17 | def 'should return formatted elapsed time'() { 18 | given: 19 | def logResult = new ConsoleLogMatch(elapsedMillis: 500010) 20 | 21 | expect: 22 | logResult.getElapsedTime() == '08:20.010' 23 | } 24 | 25 | def 'should return formatted time taken'() { 26 | given: 27 | def logResult = new ConsoleLogMatch(elapsedMillis: 50, elapsedMillisOfNextMatch: 500015) 28 | 29 | expect: 30 | logResult.getTimeTaken() == '08:19.965' 31 | } 32 | 33 | def 'should return un-formatted time taken'() { 34 | given: 35 | def logResult = new ConsoleLogMatch(elapsedMillis: 15, elapsedMillisOfNextMatch: 465) 36 | 37 | expect: 38 | logResult.getUnFormattedTimeTaken() == 450 39 | } 40 | 41 | def 'should truncate matched line'() { 42 | given: 43 | def message = 'Something the maximum length!!' 44 | 45 | when: 46 | def logResult = new ConsoleLogMatch(matchedLine: message + ' this should be thrown away') 47 | 48 | then: 49 | logResult.matchedLine == message + '...' 50 | } 51 | 52 | def 'should not add ... if matched line is short enough'() { 53 | given: 54 | def message = 'Something the maximum length!!!!!' 55 | 56 | when: 57 | def logResult = new ConsoleLogMatch(matchedLine: message) 58 | 59 | then: 60 | logResult.matchedLine == message 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/LogParserTest.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.analysis 3 | 4 | import hudson.model.Run 5 | import hudson.plugins.timestamper.api.TimestamperAPI 6 | import org.jenkins.ci.plugins.buildtimeblame.io.ReportIO 7 | import spock.lang.Specification 8 | 9 | import java.util.stream.Collectors 10 | import java.util.stream.IntStream 11 | 12 | class LogParserTest extends Specification { 13 | ReportIO reportIO 14 | TimestamperAPI timestamperAPI 15 | 16 | void setup() { 17 | timestamperAPI = GroovyMock(TimestamperAPI, global: true) 18 | _ * TimestamperAPI.get() >> timestamperAPI 19 | 20 | reportIO = GroovyMock(ReportIO, global: true) 21 | _ * ReportIO.getInstance(_ as Run) >> reportIO 22 | _ * reportIO.readFile() >> Optional.empty() 23 | } 24 | 25 | def 'should collate console output with timestamps on matched lines'() { 26 | given: 27 | def build = Mock(Run) 28 | def logs = ['theFirstLine', 'theSecondLine', 'theThirdLine', 'theFourthLine'] 29 | def timestamps = getRandomTimestamps(logs.size()) 30 | setupMockTimestamperLog(build, withTimestamps(logs, timestamps)) 31 | def expected = [ 32 | buildLogResult('Part One', 'theFirstLine', timestamps, 0, 2), 33 | buildLogResult('Part Two', 'theThirdLine', timestamps, 2, -1), 34 | ] 35 | 36 | def logParser = new LogParser([ 37 | new RelevantStep(~/.*First.*/, 'Part One', false), 38 | new RelevantStep(~/.*Third.*/, 'Part Two', false), 39 | ]) 40 | 41 | when: 42 | BuildResult buildResult = logParser.getBuildResult(build) 43 | 44 | then: 45 | buildResult.build == build 46 | buildResult.consoleLogMatches == expected 47 | 1 * reportIO.write(expected) 48 | } 49 | 50 | def 'should keep single match steps from one build to the next'() { 51 | given: 52 | def logs = ['theFirstLine', 'theSecondLine', 'theThirdLine'] 53 | def build1 = Mock(Run) 54 | setupMockTimestamperLog(build1, withTimestamps(logs, getRandomTimestamps(logs.size()))) 55 | def build2 = Mock(Run) 56 | def timestamps = getRandomTimestamps(logs.size()) 57 | setupMockTimestamperLog(build2, withTimestamps(logs, timestamps)) 58 | 59 | def logParser = new LogParser([ 60 | new RelevantStep(~/.*First.*/, 'Part One', true), 61 | new RelevantStep(~/.*Third.*/, 'Part Two', true), 62 | ]) 63 | 64 | when: 65 | logParser.getBuildResult(build1) 66 | BuildResult buildResult = logParser.getBuildResult(build2) 67 | 68 | then: 69 | buildResult.build == build2 70 | buildResult.consoleLogMatches.size() == 2 71 | } 72 | 73 | def 'should include the label if it is found multiple times'() { 74 | given: 75 | def build = Mock(Run) 76 | def logs = ['aoneline', 'atwoline', 'afiveline', 'oneoneone', 'one', 'five', 'one'] 77 | def timestamps = getRandomTimestamps(logs.size()) 78 | setupMockTimestamperLog(build, withTimestamps(logs, timestamps)) 79 | def logParser = new LogParser([ 80 | new RelevantStep(~/.*one.*/, 'Loaded', false), 81 | new RelevantStep(~/.*five.*/, 'Tested', false), 82 | ]) 83 | 84 | when: 85 | BuildResult buildResult = logParser.getBuildResult(build) 86 | 87 | then: 88 | buildResult.build == build 89 | buildResult.consoleLogMatches == [ 90 | buildLogResult('Loaded', 'aoneline', timestamps, 0, 2), 91 | buildLogResult('Tested', 'afiveline', timestamps, 2, 3), 92 | buildLogResult('Loaded', 'oneoneone', timestamps, 3, 4), 93 | buildLogResult('Loaded', 'one', timestamps, 4, 5), 94 | buildLogResult('Tested', 'five', timestamps, 5, 6), 95 | buildLogResult('Loaded', 'one', timestamps, 6, -1), 96 | ] 97 | } 98 | 99 | def 'should not include the label if it is found multiple times when onlyFirstMatch enabled'() { 100 | given: 101 | def firstStep = new RelevantStep(~/.*a.*/, 'Any', false) 102 | def secondStep = new RelevantStep(~/.*b.*/, 'Other', true) 103 | def originalSteps = [firstStep, secondStep,] 104 | def logParser = new LogParser(originalSteps) 105 | 106 | when: 107 | logParser.relevantSteps.remove(1) 108 | 109 | then: 110 | logParser.relevantSteps == [firstStep] 111 | originalSteps == [firstStep, secondStep] 112 | } 113 | 114 | def 'should use copy of relevant steps rather than same reference'() { 115 | given: 116 | def build = Mock(Run) 117 | def logs = ['aoneline', 'atwoline', 'afiveline', 'oneoneone', 'one', 'five', 'one'] 118 | def timestamps = getRandomTimestamps(logs.size()) 119 | setupMockTimestamperLog(build, withTimestamps(logs, timestamps)) 120 | 121 | def logParser = new LogParser([ 122 | new RelevantStep(~/.*one.*/, 'Include Every Time', false), 123 | new RelevantStep(~/.*five.*/, 'Include First Time', true), 124 | ]) 125 | 126 | when: 127 | BuildResult buildResult = logParser.getBuildResult(build) 128 | 129 | then: 130 | buildResult.build == build 131 | buildResult.consoleLogMatches == [ 132 | buildLogResult('Include Every Time', 'aoneline', timestamps, 0, 2), 133 | buildLogResult('Include First Time', 'afiveline', timestamps, 2, 3), 134 | buildLogResult('Include Every Time', 'oneoneone', timestamps, 3, 4), 135 | buildLogResult('Include Every Time', 'one', timestamps, 4, 6), 136 | buildLogResult('Include Every Time', 'one', timestamps, 6, -1), 137 | ] 138 | } 139 | 140 | def 'should throw error if there are no timestamps'() { 141 | given: 142 | def build = Mock(Run) 143 | setupMockTimestamperLog(build, [ 144 | new TimedLog(log: 'line1'), 145 | new TimedLog(log: 'line2'), 146 | new TimedLog(log: 'line3') 147 | ]) 148 | 149 | def logParser = new LogParser([new RelevantStep(~/line1/, '', false)]) 150 | 151 | when: 152 | logParser.getBuildResult(build) 153 | 154 | then: 155 | thrown(LogParser.TimestampMissingException) 156 | } 157 | 158 | def 'should use the previous timestamp for missing timestamps'() { 159 | given: 160 | def build = Mock(Run) 161 | def timestamps = getRandomTimestamps(2) 162 | setupMockTimestamperLog(build, [ 163 | new TimedLog(log: 'line1', elapsedMillis: Optional.of(timestamps[0])), 164 | new TimedLog(log: 'line2'), 165 | new TimedLog(log: 'line3', elapsedMillis: Optional.of(timestamps[1])) 166 | ]) 167 | 168 | def logParser = new LogParser([ 169 | new RelevantStep(~/line2/, 'First', false), 170 | new RelevantStep(~/line3/, 'Second', false), 171 | ]) 172 | 173 | when: 174 | BuildResult buildResult = logParser.getBuildResult(build) 175 | 176 | then: 177 | buildResult.build == build 178 | buildResult.consoleLogMatches == [ 179 | buildLogResult('First', 'line2', timestamps, 0, 1), 180 | buildLogResult('Second', 'line3', timestamps, 1, -1), 181 | ] 182 | } 183 | 184 | def 'should return existing results'() { 185 | given: 186 | def build = Mock(Run) 187 | def expected = [new ConsoleLogMatch(label: 'test')] 188 | 189 | when: 190 | BuildResult results = new LogParser([]).getBuildResult(build) 191 | 192 | then: 193 | 1 * ReportIO.getInstance(build) >> reportIO 194 | 1 * reportIO.readFile() >> Optional.of(expected) 195 | 0 * _ 196 | results.consoleLogMatches == expected 197 | results.build == build 198 | } 199 | 200 | private void setupMockTimestamperLog(Run build, List lines) { 201 | def mockReader = Mock(BufferedReader) 202 | 1 * timestamperAPI.read(build, 'appendLog&elapsed=S') >> mockReader 203 | 1 * mockReader.lines() >> lines.stream().map({ line -> line.toText() }) 204 | 1 * mockReader.close() 205 | } 206 | 207 | private static List getRandomTimestamps(int number) { 208 | def result = [] 209 | def random = new Random() 210 | def previousValue = nextPositiveInt(random) 211 | 212 | for (int i = 0; i < number; i++) { 213 | previousValue = nextPositiveInt(random) + previousValue 214 | result.add(previousValue) 215 | } 216 | return result 217 | } 218 | 219 | private static int nextPositiveInt(Random random) { 220 | random.nextInt(500000) + 1 221 | } 222 | 223 | private static List withTimestamps(List lines, List timestamps) { 224 | return IntStream.range(0, lines.size()) 225 | .mapToObj({ Integer index -> 226 | return new TimedLog(log: lines[index], elapsedMillis: Optional.of(timestamps[index])) 227 | }) 228 | .collect(Collectors.toList()) 229 | } 230 | 231 | private static ConsoleLogMatch buildLogResult(String label, String line, List elapsedMillis, int index, int nextIndex) { 232 | return new ConsoleLogMatch( 233 | label: label, 234 | matchedLine: line, 235 | elapsedMillis: elapsedMillis[index], 236 | elapsedMillisOfNextMatch: elapsedMillis[nextIndex] 237 | ) 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/MapFixStackedAreaRendererTest.groovy: -------------------------------------------------------------------------------- 1 | package org.jenkins.ci.plugins.buildtimeblame.analysis 2 | 3 | import org.jfree.chart.entity.EntityCollection 4 | import org.jfree.data.category.CategoryDataset 5 | import spock.lang.Specification 6 | 7 | import java.awt.Shape 8 | import java.awt.geom.GeneralPath 9 | import java.awt.geom.PathIterator 10 | 11 | class MapFixStackedAreaRendererTest extends Specification { 12 | def 'should split compound paths'() { 13 | given: 14 | def firstPath = new GeneralPath() 15 | firstPath.moveTo(110.0, 110.0) 16 | firstPath.lineTo(190.0, 110.0) 17 | firstPath.lineTo(190.0, 190.0) 18 | 19 | def secondPath = new GeneralPath() 20 | secondPath.moveTo(210.0, 210.0) 21 | secondPath.lineTo(290.0, 210.0) 22 | secondPath.lineTo(290.0, 290.0) 23 | 24 | def compoundPath = new GeneralPath() 25 | compoundPath.append(firstPath, false) 26 | compoundPath.closePath() 27 | compoundPath.append(secondPath, false) 28 | 29 | def renderer = new MapFixStackedAreaRenderer() 30 | def entityCollection = Mock(EntityCollection) 31 | def dataset = Mock(CategoryDataset) { _ * /get(Row|Column)Key/(_) >> '0' } 32 | 33 | when: 34 | renderer.addItemEntity(entityCollection, dataset, 0, 0, compoundPath) 35 | 36 | then: 37 | 1 * entityCollection.add({ pathsMatch(it.getArea(), firstPath) }) 38 | 39 | then: 40 | 1 * entityCollection.add({ pathsMatch(it.getArea(), secondPath) }) 41 | } 42 | 43 | private void pathsMatch(Shape a, Shape b) { 44 | PathIterator iteratorA = a.getPathIterator(null, 1.0) 45 | PathIterator iteratorB = b.getPathIterator(null, 1.0) 46 | 47 | while (!iteratorA.isDone() && !iteratorB.isDone()) { 48 | def segCoordsA = new float[6] 49 | int segTypeA = iteratorA.currentSegment(segCoordsA) 50 | 51 | def segCoordsB = new float[6] 52 | int segTypeB = iteratorB.currentSegment(segCoordsB) 53 | 54 | assert segTypeA == segTypeB 55 | assert Arrays.equals(segCoordsA, segCoordsB) 56 | 57 | iteratorA.next() 58 | iteratorB.next() 59 | } 60 | 61 | assert iteratorA.isDone() 62 | assert iteratorB.isDone() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/RelevantStepTest.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | 3 | package org.jenkins.ci.plugins.buildtimeblame.analysis 4 | 5 | import groovy.transform.EqualsAndHashCode 6 | import groovy.transform.ToString 7 | import spock.lang.Specification 8 | 9 | class RelevantStepTest extends Specification { 10 | def 'should have helpful annotations'() { 11 | expect: 12 | RelevantStep.isAnnotationPresent(EqualsAndHashCode) 13 | RelevantStep.isAnnotationPresent(ToString) 14 | } 15 | 16 | def 'should provide constructor for all properties'() { 17 | given: 18 | def pattern = ~/.*/ 19 | def label = 'Anything happened' 20 | def onlyFirstMatch = true 21 | 22 | when: 23 | def step = new RelevantStep(pattern, label, onlyFirstMatch) 24 | 25 | then: 26 | step.pattern == pattern 27 | step.label == label 28 | step.onlyFirstMatch == onlyFirstMatch 29 | } 30 | 31 | def 'should handle null onlyFirstMatch in constructor with false as default'() { 32 | given: 33 | def pattern = ~/.*/ 34 | def label = 'Anything happened' 35 | 36 | when: 37 | def step = new RelevantStep(pattern, label, null) 38 | 39 | then: 40 | step.pattern == pattern 41 | step.label == label 42 | !step.onlyFirstMatch 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/analysis/TimedLogTest.groovy: -------------------------------------------------------------------------------- 1 | package org.jenkins.ci.plugins.buildtimeblame.analysis 2 | 3 | import spock.lang.Specification 4 | 5 | class TimedLogTest extends Specification { 6 | def 'should extract the trimmed text and timestamp when available'() { 7 | given: 8 | def log = 'Step 1 is done' 9 | def elapsedMillis = 83993 10 | def text = "$elapsedMillis $log " 11 | 12 | when: 13 | def result = TimedLog.fromText(text) 14 | 15 | then: 16 | result.log == log 17 | result.elapsedMillis.get() == elapsedMillis 18 | } 19 | 20 | def 'should extract the trimmed text when a timestamp is not available'() { 21 | given: 22 | def log = 'Step 2 is done' 23 | def text = " $log " 24 | 25 | when: 26 | def result = TimedLog.fromText(text) 27 | 28 | then: 29 | result.log == log 30 | !result.elapsedMillis.isPresent() 31 | } 32 | 33 | def 'should use the full log statement if there are no extra spaces'() { 34 | given: 35 | def log = 'Step 3 is done' 36 | def text = "$log" 37 | 38 | when: 39 | def result = TimedLog.fromText(text) 40 | 41 | then: 42 | result.log == log 43 | !result.elapsedMillis.isPresent() 44 | } 45 | 46 | def 'should convert to a log line and back again when there is a timestamp'() { 47 | given: 48 | def log = 'some text for what happened' 49 | def elapsedMillis = 83993 50 | 51 | when: 52 | def text = new TimedLog(log: log, elapsedMillis: Optional.of(elapsedMillis)).toText() 53 | def result = TimedLog.fromText(text) 54 | 55 | then: 56 | result.log == log 57 | result.elapsedMillis.get() == elapsedMillis 58 | } 59 | 60 | def 'should convert to a log line and back again when there is no timestamp'() { 61 | given: 62 | def log = 'some text for what else happened' 63 | 64 | when: 65 | def text = new TimedLog(log: log, elapsedMillis: Optional.empty() as Optional).toText() 66 | def result = TimedLog.fromText(text) 67 | 68 | then: 69 | result.log == log 70 | !result.elapsedMillis.isPresent() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/io/BlameFilePathsTest.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.io 3 | 4 | 5 | import hudson.model.Job 6 | import hudson.model.Run 7 | import spock.lang.Specification 8 | 9 | class BlameFilePathsTest extends Specification { 10 | def 'should find correct job configuration file'() { 11 | given: 12 | def rootDir = 'Z:\\JenkinsRoot\\job\\JobRoot' 13 | def job = Mock(Job) { 14 | _ * it.getRootDir() >> new File(rootDir) 15 | } 16 | 17 | when: 18 | File file = BlameFilePaths.getConfigFile(job) 19 | 20 | then: 21 | file.path == new File(rootDir, 'build-time-blame-config.json').path 22 | } 23 | 24 | def 'should find correct legacy job configuration file'() { 25 | given: 26 | def rootDir = 'Z:\\JenkinsRoot\\job\\JobRoot' 27 | def job = Mock(Job) { 28 | _ * it.getRootDir() >> new File(rootDir) 29 | } 30 | 31 | when: 32 | File file = BlameFilePaths.getLegacyConfigFile(job) 33 | 34 | then: 35 | file.path == new File(rootDir, 'buildtimeblameconfig').path 36 | } 37 | 38 | def 'should find correct build data file'() { 39 | given: 40 | def rootDir = 'X:\\MyJenkinsRoot\\job\\MyJobRoot\\99' 41 | def build = Mock(Run) { 42 | _ * it.getRootDir() >> new File(rootDir) 43 | } 44 | 45 | when: 46 | File file = BlameFilePaths.getReportFile(build) 47 | 48 | then: 49 | file.path == new File(rootDir, 'build-time-blame-matches.json').path 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/io/ConfigIOTest.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.io 3 | 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import hudson.model.Job 6 | import org.jenkins.ci.plugins.buildtimeblame.analysis.RelevantStep 7 | import spock.lang.Specification 8 | 9 | class ConfigIOTest extends Specification { 10 | Job job 11 | 12 | void setup() { 13 | GroovyMock(BlameFilePaths, global: true) 14 | job = Mock(Job) 15 | _ * BlameFilePaths.getConfigFile(job) >> getTestFile() 16 | _ * BlameFilePaths.getLegacyConfigFile(job) >> getLegacyTestFile() 17 | } 18 | 19 | void cleanup() { 20 | getTestFile().delete() 21 | getLegacyTestFile().delete() 22 | } 23 | 24 | def 'should read steps from file'() { 25 | given: 26 | getTestFile().write('{"relevantSteps":[{"label":"Job Started","key":".*Started.*","onlyFirstMatch":true}]}') 27 | 28 | when: 29 | List relevantSteps = new ConfigIO(job).readOrDefault().relevantSteps 30 | 31 | then: 32 | relevantSteps.toListString() == [new RelevantStep(~/.*Started.*/, 'Job Started', true)].toListString() 33 | } 34 | 35 | def 'should handle missing flag when reading steps from file'() { 36 | given: 37 | getTestFile().write('{"relevantSteps":[{"key":".*Started.*","label":"Job Started"}]}') 38 | 39 | when: 40 | List relevantSteps = new ConfigIO(job).readOrDefault().relevantSteps 41 | 42 | then: 43 | relevantSteps.toListString() == [new RelevantStep(~/.*Started.*/, 'Job Started', false)].toListString() 44 | } 45 | 46 | def 'should read steps from legacy file one time if present'() { 47 | given: 48 | def legacyFileContent = '[{"key":".*Started.*","label":"Job Started","onlyFirstMatch":true}]' 49 | def expectedRelevantSteps = [new RelevantStep(~/.*Started.*/, 'Job Started', true)].toListString() 50 | getLegacyTestFile().write(legacyFileContent) 51 | 52 | when: 53 | List legacyRelevantSteps = new ConfigIO(job).readOrDefault().relevantSteps 54 | 55 | then: 56 | legacyRelevantSteps.toListString() == expectedRelevantSteps 57 | !getLegacyTestFile().exists() 58 | getTestFile().exists() 59 | 60 | when: 61 | List copiedRelevantSteps = new ConfigIO(job).readOrDefault().relevantSteps 62 | 63 | then: 64 | copiedRelevantSteps.toListString() == expectedRelevantSteps 65 | } 66 | 67 | def 'should handle missing flag when reading steps from legacy file one time if present'() { 68 | given: 69 | def fileContent = '[{"key":".*Started.*","label":"Job Started"}]' 70 | def expectedRelevantSteps = [new RelevantStep(~/.*Started.*/, 'Job Started', false)].toListString() 71 | 72 | getLegacyTestFile().write(fileContent) 73 | 74 | when: 75 | List legacyRelevantSteps = new ConfigIO(job).readOrDefault().relevantSteps 76 | 77 | then: 78 | legacyRelevantSteps.toListString() == expectedRelevantSteps 79 | !getLegacyTestFile().exists() 80 | getTestFile().exists() 81 | 82 | when: 83 | List copiedRelevantSteps = new ConfigIO(job).readOrDefault().relevantSteps 84 | 85 | then: 86 | copiedRelevantSteps.toListString() == expectedRelevantSteps 87 | } 88 | 89 | def 'should return default value if no file content'() { 90 | given: 91 | def defaultSteps = [new RelevantStep(~/.*/, 'ignored', false)] 92 | 93 | when: 94 | def config = new ConfigIO(job).readOrDefault(defaultSteps.clone() as List) 95 | 96 | then: 97 | config.relevantSteps == defaultSteps 98 | config.maxBuilds == null 99 | } 100 | 101 | def 'should return default value if invalid file content'() { 102 | given: 103 | def defaultSteps = [new RelevantStep(~/.*/, 'ignored', false)] 104 | getTestFile().write('not json') 105 | 106 | when: 107 | def config = new ConfigIO(job).readOrDefault(defaultSteps.clone() as List) 108 | 109 | then: 110 | config.relevantSteps == defaultSteps 111 | config.maxBuilds == null 112 | } 113 | 114 | def 'should return empty value if no file content and no default'() { 115 | when: 116 | def config = new ConfigIO(job).readOrDefault() 117 | 118 | then: 119 | config.relevantSteps == [] 120 | config.maxBuilds == null 121 | } 122 | 123 | def 'should support parsing and reading the written config file'() { 124 | given: 125 | def config = new ReportConfiguration(maxBuilds: 8787, relevantSteps: [new RelevantStep(~/.*Finished NPM.*/, 'NPM Finished', false)]) 126 | 127 | when: 128 | new ConfigIO(job).write(config) 129 | 130 | then: 131 | !getTestFile().text.empty 132 | 133 | when: 134 | def configString = getTestFile().text 135 | def parsedConfig = new ConfigIO(job).parse(configString) 136 | 137 | then: 138 | parsedConfig.toString() == config.toString() 139 | 140 | when: 141 | def readConfig = new ConfigIO(job).readOrDefault() 142 | 143 | then: 144 | readConfig.toString() == config.toString() 145 | } 146 | 147 | private static File getTestFile() { 148 | new File('temp-config.json') 149 | } 150 | 151 | private static File getLegacyTestFile() { 152 | new File('temp-legacy-config.json') 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/io/ReportIOTest.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.io 3 | 4 | import hudson.model.Run 5 | import org.jenkins.ci.plugins.buildtimeblame.analysis.ConsoleLogMatch 6 | import spock.lang.Specification 7 | 8 | class ReportIOTest extends Specification { 9 | Run build 10 | 11 | void setup() { 12 | GroovyMock(BlameFilePaths, global: true) 13 | build = Mock(Run) 14 | _ * BlameFilePaths.getReportFile(build) >> getTestFile() 15 | } 16 | 17 | void cleanup() { 18 | getTestFile().delete() 19 | } 20 | 21 | def 'should serialize list to file without derived fields'() { 22 | given: 23 | def report = [ 24 | new ConsoleLogMatch(label: 'Finished', matchedLine: 'Did it', elapsedMillisOfNextMatch: 50, elapsedMillis: 1), 25 | ] 26 | 27 | when: 28 | new ReportIO(build).write(report) 29 | 30 | then: 31 | getTestFile().text == '[{"label":"Finished","matchedLine":"Did it","elapsedMillis":1,"elapsedMillisOfNextMatch":50}]' 32 | } 33 | 34 | def 'should load list from file'() { 35 | given: 36 | getTestFile().write('[{"label":"Finished","matchedLine":"Did it","elapsedMillisOfNextMatch":50,"elapsedMillis":1}]') 37 | 38 | when: 39 | def report = new ReportIO(build).readFile().get() 40 | 41 | then: 42 | report == [ 43 | new ConsoleLogMatch(label: 'Finished', matchedLine: 'Did it', elapsedMillisOfNextMatch: 50, elapsedMillis: 1), 44 | ] 45 | } 46 | 47 | def 'should use same format for read and write'() { 48 | given: 49 | def expected = [ 50 | new ConsoleLogMatch(label: 'Begin', matchedLine: 'Did it', elapsedMillisOfNextMatch: 50, elapsedMillis: 1), 51 | new ConsoleLogMatch(label: 'Started', matchedLine: 'Did nothing', elapsedMillisOfNextMatch: 30, elapsedMillis: 3), 52 | new ConsoleLogMatch(label: 'Finished', matchedLine: 'Did all', elapsedMillisOfNextMatch: 21, elapsedMillis: 5), 53 | ] 54 | 55 | when: 56 | new ReportIO(build).write(expected.collect { it.clone() } as List) 57 | def report = new ReportIO(build).readFile().get() 58 | 59 | then: 60 | report == expected 61 | } 62 | 63 | def 'should allow clearing a report'() { 64 | given: 65 | getTestFile().write('anything at all') 66 | 67 | when: 68 | new ReportIO(build).clear() 69 | 70 | then: 71 | !getTestFile().exists() 72 | } 73 | 74 | def 'should return empty optional if invalid content'() { 75 | given: 76 | getTestFile().write('any text') 77 | 78 | expect: 79 | new ReportIO(build).readFile() == Optional.empty() 80 | } 81 | 82 | def 'should return empty optional if no content'() { 83 | expect: 84 | new ReportIO(build).readFile() == Optional.empty() 85 | } 86 | 87 | private static File getTestFile() { 88 | new File('tempreport.txt') 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/test/groovy/org/jenkins/ci/plugins/buildtimeblame/io/StaplerUtilsTest.groovy: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 Deere & Company 2 | package org.jenkins.ci.plugins.buildtimeblame.io 3 | 4 | import net.sf.json.JSONObject 5 | import org.kohsuke.stapler.StaplerRequest 6 | import org.kohsuke.stapler.StaplerResponse 7 | import spock.lang.Specification 8 | 9 | class StaplerUtilsTest extends Specification { 10 | def 'should redirect to parent url'() { 11 | given: 12 | def fullUrl = 'http://anywhere.com/jenkins/job/someJob/someTask/currentAction' 13 | def request = Mock(StaplerRequest) 14 | def response = Mock(StaplerResponse) 15 | 16 | when: 17 | StaplerUtils.redirectToParentURI(request, response) 18 | 19 | then: 20 | 1 * request.getOriginalRequestURI() >> fullUrl 21 | 1 * response.sendRedirect('http://anywhere.com/jenkins/job/someJob/someTask') 22 | } 23 | 24 | def 'should redirect to parent url if there is a trailing /'() { 25 | given: 26 | def fullUrl = 'http://anywhere.com/jenkins/job/otherTask/otherAction/' 27 | def request = Mock(StaplerRequest) 28 | def response = Mock(StaplerResponse) 29 | 30 | when: 31 | StaplerUtils.redirectToParentURI(request, response) 32 | 33 | then: 34 | 1 * request.getOriginalRequestURI() >> fullUrl 35 | 1 * response.sendRedirect('http://anywhere.com/jenkins/job/otherTask') 36 | } 37 | 38 | def 'should return list value if it is already a list'() { 39 | given: 40 | def baseObject = JSONObject.fromObject([one: ['first', 'second', 'third']]) 41 | 42 | when: 43 | def result = StaplerUtils.getAsList(baseObject, 'one') as List 44 | 45 | then: 46 | result == ['first', 'second', 'third'] 47 | } 48 | 49 | def 'should return list value if it is a value'() { 50 | given: 51 | def baseObject = JSONObject.fromObject([fifty: 'first value']) 52 | 53 | when: 54 | def result = StaplerUtils.getAsList(baseObject, 'fifty') as List 55 | 56 | then: 57 | result == ['first value'] 58 | } 59 | 60 | def 'should return list value if it is a map'() { 61 | given: 62 | def baseObject = JSONObject.fromObject([two: [first: 'value']]) 63 | 64 | when: 65 | def result = StaplerUtils.getAsList(baseObject, 'two') 66 | 67 | then: 68 | result[0].toMapString() == [first: 'value'].toMapString() 69 | } 70 | } 71 | --------------------------------------------------------------------------------