├── src ├── test │ ├── resources │ │ └── mockito-extensions │ │ │ └── org.mockito.plugins.MockMaker │ └── kotlin │ │ └── com │ │ └── dg │ │ └── watcher │ │ ├── PluginDescriptorTest.kt │ │ ├── history │ │ ├── HistoryTest.kt │ │ └── graph │ │ │ ├── HistoryGraphTooltipGeneratorTest.kt │ │ │ ├── HistoryGraphUrlGeneratorTest.kt │ │ │ └── HistoryGraphDataSetGenerationTest.kt │ │ ├── base │ │ └── extension │ │ │ ├── FilePathExtensionTest.kt │ │ │ ├── BuildExtensionTest.kt │ │ │ └── ProjectExtensionTest.kt │ │ ├── watching │ │ ├── saving │ │ │ └── SizeSavingTest.kt │ │ ├── surveying │ │ │ └── SizeSurveyingTest.kt │ │ ├── loading │ │ │ └── ApkLoadingTest.kt │ │ └── ApkSizeWatchingTest.kt │ │ ├── validation │ │ └── InputValidationTest.kt │ │ └── PluginTest.kt └── main │ ├── java │ └── com │ │ └── dg │ │ └── watcher │ │ ├── watching │ │ ├── saving │ │ │ ├── SizeEntry.kt │ │ │ └── SizeSaving.kt │ │ ├── surveying │ │ │ ├── SizeSurveyingResult.kt │ │ │ └── SizeSurveying.kt │ │ ├── loading │ │ │ └── ApkLoading.kt │ │ └── ApkSizeWatching.kt │ │ ├── base │ │ ├── extension │ │ │ ├── FilePathExtension.kt │ │ │ ├── BuildExtension.kt │ │ │ └── ProjectExtension.kt │ │ ├── Alias.kt │ │ ├── Log.kt │ │ └── Const.kt │ │ ├── history │ │ ├── graph │ │ │ ├── HistoryGraphUrlGenerator.kt │ │ │ ├── HistoryGraphTooltipGenerator.kt │ │ │ ├── HistoryGraphDataSetGeneration.kt │ │ │ └── HistoryGraph.kt │ │ └── History.kt │ │ ├── validation │ │ └── InputValidation.kt │ │ ├── Plugin.kt │ │ └── PluginDescriptor.java │ └── resources │ ├── index.jelly │ └── com │ └── dg │ └── watcher │ ├── Plugin │ ├── help-thresholdInMb.html │ ├── help-customPathToApk.html │ └── config.jelly │ └── history │ └── History │ └── index.jelly ├── .gitignore ├── README.md ├── LICENSE └── pom.xml /src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .classpath 2 | .project 3 | .settings/ 4 | target/ 5 | work/ 6 | .idea 7 | *.iml 8 | release.properties 9 | pom.xml.releaseBackup -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/watching/saving/SizeEntry.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.watching.saving 2 | 3 | 4 | data class SizeEntry(val buildName: String, val sizeInByte: Long) -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/base/extension/FilePathExtension.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.base.extension 2 | 3 | import hudson.FilePath 4 | 5 | 6 | fun FilePath?.exists() = this?.exists() ?: false -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Watch over the changing size of your .apk file and fail your build once the change exceeds your specified threshold.](https://wiki.jenkins.io/display/JENKINS/Android+Apk+Size+Watcher+Plugin) -------------------------------------------------------------------------------- /src/main/resources/index.jelly: -------------------------------------------------------------------------------- 1 | 2 |
3 | Watch over the changing size of your .apk file and fail your build once the change exceeds your specified threshold. 4 |
-------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/base/extension/BuildExtension.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.base.extension 2 | 3 | import com.dg.watcher.base.Build 4 | 5 | 6 | fun Build.getFileInWorkspace(path: String) = getWorkspace()?.child(path) -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/watching/surveying/SizeSurveyingResult.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.watching.surveying 2 | 3 | 4 | enum class SizeSurveyingResult { 5 | SIZE_THRESHOLD_MET, 6 | SIZE_THRESHOLD_EXCEEDED 7 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/base/extension/ProjectExtension.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.base.extension 2 | 3 | import com.dg.watcher.base.Project 4 | 5 | 6 | fun Project.getFileInWorkspace(path: String) = getSomeWorkspace()?.child(path) -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/base/Alias.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.base 2 | 3 | import hudson.model.AbstractBuild 4 | import hudson.model.AbstractProject 5 | 6 | 7 | typealias Build = AbstractBuild<*, *> 8 | typealias Project = AbstractProject<*, *> -------------------------------------------------------------------------------- /src/main/resources/com/dg/watcher/Plugin/help-thresholdInMb.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | Specify the allowed size difference between the last and the latest .apk File. 4 | Exceeding it will fail the build. The unit of the threshold is megabytes. 5 |
-------------------------------------------------------------------------------- /src/main/resources/com/dg/watcher/Plugin/help-customPathToApk.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | Specify the path to the directory of your .apk file if you don't use the default one. 4 | Leaving this field empty will default the path to app/build/outputs/apk/. 5 |
-------------------------------------------------------------------------------- /src/main/resources/com/dg/watcher/Plugin/config.jelly: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/main/resources/com/dg/watcher/history/History/index.jelly: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/PluginDescriptorTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher 2 | 3 | import org.hamcrest.Matchers.`is` 4 | import org.hamcrest.Matchers.equalTo 5 | import org.junit.Assert.assertThat 6 | import org.junit.Test 7 | 8 | 9 | class PluginDescriptorTest { 10 | @Test 11 | fun `Should specify the name of the post build action`() = assertThat(PluginDescriptor().displayName, 12 | `is`(equalTo("Watch over the changing size of your .apk file"))) 13 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/base/Log.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.base 2 | 3 | import java.util.logging.Level 4 | import java.util.logging.Level.INFO 5 | import java.util.logging.Level.SEVERE 6 | import java.util.logging.Level.WARNING 7 | import java.util.logging.Logger.getLogger 8 | 9 | 10 | fun err(tag: String, msg: String) = log(SEVERE, tag, msg) 11 | 12 | fun warn(tag: String, msg: String) = log(WARNING, tag, msg) 13 | 14 | fun info(tag: String, msg: String) = log(INFO, tag, msg) 15 | 16 | private fun log(level: Level, tag: String, msg: String) = getLogger(tag).log(level, msg) -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/history/graph/HistoryGraphUrlGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.history.graph 2 | 3 | import com.dg.watcher.base.Project 4 | import org.jfree.chart.urls.CategoryURLGenerator 5 | import org.jfree.data.category.CategoryDataset 6 | 7 | 8 | class HistoryGraphUrlGenerator(private val project: Project) : CategoryURLGenerator { 9 | override fun generateURL(categoryDataSet: CategoryDataset, series: Int, itemIndex: Int) = 10 | project.getAbsoluteUrl() + getBuildNumber(categoryDataSet, itemIndex) 11 | 12 | private fun getBuildNumber(categoryDataSet: CategoryDataset, itemIndex: Int) = 13 | (categoryDataSet.getColumnKey(itemIndex) as String).replaceFirst("#", "") 14 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/history/HistoryTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.history 2 | 3 | import com.nhaarman.mockito_kotlin.mock 4 | import org.hamcrest.Matchers.`is` 5 | import org.hamcrest.Matchers.equalTo 6 | import org.junit.Assert.assertThat 7 | import org.junit.Test 8 | 9 | 10 | class HistoryTest { 11 | @Test 12 | fun `Should specify the shown icon`() = assertThat(history().iconFileName, `is`(equalTo("graph.gif"))) 13 | 14 | @Test 15 | fun `Should specify the shown label`() = assertThat(history().displayName, `is`(equalTo("Apk Size History"))) 16 | 17 | @Test 18 | fun `Should specify the used domain`() = assertThat(history().urlName, `is`(equalTo("apk_size_history"))) 19 | 20 | private fun history() = History(mock()) 21 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/history/graph/HistoryGraphTooltipGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.history.graph 2 | 3 | import org.jfree.chart.labels.CategoryToolTipGenerator 4 | import org.jfree.data.category.CategoryDataset 5 | import com.dg.watcher.base.GRAPH_TOOLTIP 6 | import java.lang.String.format 7 | import java.util.Locale.ENGLISH 8 | 9 | 10 | class HistoryGraphTooltipGenerator : CategoryToolTipGenerator { 11 | override fun generateToolTip(categoryDataset: CategoryDataset, series: Int, itemIndex: Int): String { 12 | val buildName = categoryDataset.getColumnKey(itemIndex).toString() 13 | val apkSizeInMb = categoryDataset.getValue(series, itemIndex).toFloat() 14 | 15 | return format(ENGLISH, GRAPH_TOOLTIP, buildName, apkSizeInMb) 16 | } 17 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/base/extension/FilePathExtensionTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.base.extension 2 | 3 | import com.nhaarman.mockito_kotlin.mock 4 | import com.nhaarman.mockito_kotlin.verify 5 | import hudson.FilePath 6 | import org.junit.Assert.assertFalse 7 | import org.junit.Test 8 | 9 | 10 | class FilePathExtensionTest { 11 | @Test 12 | fun `A null file path doesn't exist per default`() { 13 | // GIVEN 14 | val filePath: FilePath? = null 15 | 16 | // THEN 17 | assertFalse(filePath.exists()) 18 | } 19 | 20 | @Test 21 | fun `A non null file path checks for its existence`() { 22 | // GIVEN 23 | val filePath: FilePath? = mock() 24 | 25 | // WHEN 26 | filePath.exists() 27 | 28 | // THEN 29 | verify(filePath)?.exists() 30 | } 31 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/base/extension/BuildExtensionTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.base.extension 2 | 3 | import com.dg.watcher.base.Build 4 | import com.nhaarman.mockito_kotlin.doReturn 5 | import com.nhaarman.mockito_kotlin.mock 6 | import hudson.FilePath 7 | import org.hamcrest.Matchers.`is` 8 | import org.hamcrest.Matchers.equalTo 9 | import org.junit.Assert.assertThat 10 | import org.junit.Test 11 | 12 | 13 | class BuildExtensionTest { 14 | @Test 15 | fun `Should provide a shortcut to get a file inside the builds's workspace`() { 16 | // GIVEN 17 | val file = mock() 18 | val workspace = mock { on { child("test/debug.apk") } doReturn file } 19 | val build = mock { on { getWorkspace() } doReturn workspace } 20 | 21 | // THEN 22 | assertThat(build.getFileInWorkspace("test/debug.apk"), `is`(equalTo(file))) 23 | } 24 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/base/extension/ProjectExtensionTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.base.extension 2 | 3 | import com.dg.watcher.base.Project 4 | import com.nhaarman.mockito_kotlin.doReturn 5 | import com.nhaarman.mockito_kotlin.mock 6 | import hudson.FilePath 7 | import org.hamcrest.Matchers.`is` 8 | import org.hamcrest.Matchers.equalTo 9 | import org.junit.Assert.assertThat 10 | import org.junit.Test 11 | 12 | 13 | class ProjectExtensionTest { 14 | @Test 15 | fun `Should provide a shortcut to get a file inside the project's workspace`() { 16 | // GIVEN 17 | val file = mock() 18 | val workspace = mock { on { child("test/debug.apk") } doReturn file } 19 | val project = mock { on { getSomeWorkspace() } doReturn workspace } 20 | 21 | // THEN 22 | assertThat(project.getFileInWorkspace("test/debug.apk"), `is`(equalTo(file))) 23 | } 24 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/history/graph/HistoryGraphDataSetGeneration.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.history.graph 2 | 3 | import com.dg.watcher.base.CONVERSION_FACTOR_BYTE_TO_MEGABYTE 4 | import com.dg.watcher.base.GRAPH_LEGEND 5 | import com.dg.watcher.base.GRAPH_MAX_ENTRY_COUNT 6 | import com.dg.watcher.watching.saving.SizeEntry 7 | import org.jfree.data.category.DefaultCategoryDataset 8 | 9 | 10 | fun generateGraphDataSet(entries: List) = DefaultCategoryDataset().apply { 11 | for((buildName, sizeInByte) in cutToMaximumEntryCount(entries)) { 12 | val sizeInMegaByte = sizeInByte / CONVERSION_FACTOR_BYTE_TO_MEGABYTE 13 | 14 | addValue(sizeInMegaByte, GRAPH_LEGEND, buildName) 15 | } 16 | } 17 | 18 | private fun cutToMaximumEntryCount(entries: List): List { 19 | var indexOfFirstIncludedEntry = entries.size - GRAPH_MAX_ENTRY_COUNT 20 | 21 | if(indexOfFirstIncludedEntry < 0) indexOfFirstIncludedEntry = 0 22 | 23 | return entries.subList(indexOfFirstIncludedEntry, entries.size) 24 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/history/graph/HistoryGraphTooltipGeneratorTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.history.graph 2 | 3 | import com.dg.watcher.base.GRAPH_LEGEND 4 | import hudson.util.DataSetBuilder 5 | import org.hamcrest.Matchers.`is` 6 | import org.hamcrest.Matchers.equalTo 7 | import org.junit.Assert.assertThat 8 | import org.junit.Test 9 | 10 | 11 | class HistoryGraphTooltipGeneratorTest { 12 | @Test 13 | fun `Should generate a tooltip consisting of the builds name and the size of the apk`() { 14 | // GIVEN 15 | val entry = createDataSetWithSizeEntry("#99", 1.5f) 16 | 17 | // WHEN 18 | val tooltip = HistoryGraphTooltipGenerator().generateToolTip(entry, 0, 0) 19 | 20 | // THEN 21 | assertThat(tooltip, `is`(equalTo("Build #99: 1.5 Megabytes"))) 22 | } 23 | 24 | private fun createDataSetWithSizeEntry(buildName: String, apkSizeInMb: Float) = DataSetBuilder().run { 25 | add(apkSizeInMb, GRAPH_LEGEND, buildName) 26 | 27 | build() 28 | } 29 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/history/History.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.history 2 | 3 | import com.dg.watcher.base.Project 4 | import com.dg.watcher.history.graph.HistoryGraph 5 | import hudson.model.Action 6 | import org.kohsuke.stapler.StaplerRequest 7 | import org.kohsuke.stapler.StaplerResponse 8 | 9 | 10 | class History(val project: Project) : Action { 11 | private var graph: HistoryGraph? = null 12 | 13 | init { 14 | updateHistory() 15 | } 16 | 17 | override fun getIconFileName() = "graph.gif" 18 | 19 | override fun getDisplayName() = "Apk Size History" 20 | 21 | override fun getUrlName() = "apk_size_history" 22 | 23 | fun updateHistory() { 24 | graph = HistoryGraph(project) 25 | } 26 | 27 | // Used in index.jelly 28 | fun doHistory(request: StaplerRequest, response: StaplerResponse) { 29 | graph?.drawGraph(request, response) 30 | } 31 | 32 | // Used in index.jelly 33 | fun doHistoryTooltips(request: StaplerRequest, response: StaplerResponse) { 34 | graph?.drawGraphTooltips(request, response) 35 | } 36 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/watching/loading/ApkLoading.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.watching.loading 2 | 3 | import com.dg.watcher.base.APK_DEFAULT_DIR 4 | import com.dg.watcher.base.APK_FILE_FILTER 5 | import com.dg.watcher.base.Build 6 | import com.dg.watcher.base.extension.getFileInWorkspace 7 | import hudson.FilePath 8 | 9 | 10 | fun loadApk(build: Build, customPathToApk: String = ""): FilePath? { 11 | val apkDirectory = getApkDirectory(build, customPathToApk) 12 | 13 | return apkDirectory?.let { loadApkFiles(it).firstOrNull() } 14 | } 15 | 16 | private fun getApkDirectory(build: Build, customPathToApk: String) = 17 | build.getFileInWorkspace(decidePathToApk(customPathToApk)) 18 | 19 | private fun decidePathToApk(customPathToApk: String) = 20 | if(customPathToApk.isNotBlank()) { 21 | customPathToApk 22 | } 23 | else { 24 | APK_DEFAULT_DIR 25 | } 26 | 27 | private fun loadApkFiles(apkDirectory: FilePath) = 28 | if(apkDirectory.exists()) { 29 | apkDirectory.list(APK_FILE_FILTER).toList() 30 | } 31 | else { 32 | emptyList() 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Daniel Gronau 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 | -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/history/graph/HistoryGraphUrlGeneratorTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.history.graph 2 | 3 | import com.dg.watcher.base.GRAPH_LEGEND 4 | import com.nhaarman.mockito_kotlin.doReturn 5 | import com.nhaarman.mockito_kotlin.mock 6 | import hudson.util.DataSetBuilder 7 | import org.hamcrest.Matchers.`is` 8 | import org.hamcrest.Matchers.equalTo 9 | import org.junit.Assert.assertThat 10 | import org.junit.Test 11 | 12 | 13 | class HistoryGraphUrlGeneratorTest { 14 | @Test 15 | fun `Should generate a url linking to the build of the size entry`() { 16 | // GIVEN 17 | val entry = createDataSetWithSizeEntry("#99") 18 | 19 | // WHEN 20 | val url = generator().generateURL(entry, 0, 0) 21 | 22 | // THEN 23 | assertThat(url, `is`(equalTo(".../jenkins/job/test/99"))) 24 | } 25 | 26 | private fun generator() = HistoryGraphUrlGenerator( 27 | mock { on { getAbsoluteUrl() } doReturn ".../jenkins/job/test/" }) 28 | 29 | private fun createDataSetWithSizeEntry(buildName: String) = DataSetBuilder().run { 30 | add(0f, GRAPH_LEGEND, buildName) 31 | 32 | build() 33 | } 34 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/validation/InputValidation.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.validation 2 | 3 | import com.dg.watcher.base.Project 4 | import com.dg.watcher.base.extension.exists 5 | import com.dg.watcher.base.extension.getFileInWorkspace 6 | import hudson.util.FormValidation 7 | import hudson.util.FormValidation.error 8 | import hudson.util.FormValidation.ok 9 | 10 | 11 | fun validateThresholdInMb(input: String): FormValidation = 12 | try { 13 | val thresholdInMb = input.toFloat() 14 | 15 | if(thresholdInMb >= 0f) { 16 | ok() 17 | } 18 | else { 19 | error("The threshold cannot be negative.") 20 | } 21 | } 22 | catch(e: Exception) { 23 | error("The threshold must be a floating point number.") 24 | } 25 | 26 | fun validateCustomPathToApk(input: String, project: Project): FormValidation = 27 | if(noPathSpecified(input) || validPathSpecified(input, project)) { 28 | ok() 29 | } 30 | else { 31 | error("The specified path does not exist.") 32 | } 33 | 34 | private fun noPathSpecified(path: String) = path.isBlank() 35 | 36 | private fun validPathSpecified(path: String, project: Project) = 37 | project.getFileInWorkspace(path).exists() -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/Plugin.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher 2 | 3 | import com.dg.watcher.base.Build 4 | import com.dg.watcher.base.Project 5 | import com.dg.watcher.history.History 6 | import com.dg.watcher.watching.watchApkSize 7 | import hudson.Launcher 8 | import hudson.model.BuildListener 9 | import hudson.tasks.BuildStepMonitor.NONE 10 | import hudson.tasks.Notifier 11 | import org.kohsuke.stapler.DataBoundConstructor 12 | 13 | 14 | class Plugin @DataBoundConstructor constructor(val thresholdInMb: Float, val customPathToApk: String) : Notifier() { 15 | override fun getDescriptor() = super.getDescriptor() as PluginDescriptor 16 | 17 | override fun getRequiredMonitorService() = NONE 18 | 19 | override fun getProjectActions(project: Project) = listOf(History(project)) 20 | 21 | override fun perform(build: Build, launcher: Launcher, listener: BuildListener): Boolean { 22 | val buildPermitted = watchApkSize(build, listener.logger, thresholdInMb, customPathToApk) 23 | 24 | updateHistoryAction(build.getProject()) 25 | 26 | return buildPermitted 27 | } 28 | 29 | private fun updateHistoryAction(project: Project) = getHistoryAction(project).updateHistory() 30 | 31 | private fun getHistoryAction(project: Project) = project.getAction(History::class.java) 32 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/watching/surveying/SizeSurveying.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.watching.surveying 2 | 3 | import com.dg.watcher.base.CONVERSION_FACTOR_BYTE_TO_MEGABYTE 4 | import com.dg.watcher.watching.saving.SizeEntry 5 | import com.dg.watcher.watching.surveying.SizeSurveyingResult.SIZE_THRESHOLD_EXCEEDED 6 | import com.dg.watcher.watching.surveying.SizeSurveyingResult.SIZE_THRESHOLD_MET 7 | 8 | 9 | fun surveySizes(sizes: List, thresholdInMb: Float): SizeSurveyingResult { 10 | if(atLeastTwoSizesRecorded(sizes)) { 11 | val latestSizeDifferenceInMb = calculateLatestSizeDifferenceInMb(sizes) 12 | 13 | if(sizeThresholdExceeded(latestSizeDifferenceInMb, thresholdInMb)) { 14 | return SIZE_THRESHOLD_EXCEEDED 15 | } 16 | } 17 | 18 | return SIZE_THRESHOLD_MET 19 | } 20 | 21 | private fun atLeastTwoSizesRecorded(sizes: List) = sizes.size > 1 22 | 23 | private fun calculateLatestSizeDifferenceInMb(sizes: List): Float { 24 | val sizeOfPreviousApk = sizes[sizes.lastIndex - 1].sizeInByte 25 | val sizeOfLatestApk = sizes[sizes.lastIndex].sizeInByte 26 | 27 | return (sizeOfLatestApk - sizeOfPreviousApk) / CONVERSION_FACTOR_BYTE_TO_MEGABYTE 28 | } 29 | 30 | private fun sizeThresholdExceeded(differenceInMb: Float, thresholdInMb: Float) = differenceInMb > thresholdInMb -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/PluginDescriptor.java: -------------------------------------------------------------------------------- 1 | package com.dg.watcher; 2 | 3 | import hudson.Extension; 4 | import hudson.model.AbstractProject; 5 | import hudson.tasks.BuildStepDescriptor; 6 | import hudson.tasks.Publisher; 7 | import hudson.util.FormValidation; 8 | import org.kohsuke.stapler.AncestorInPath; 9 | import org.kohsuke.stapler.QueryParameter; 10 | import static com.dg.watcher.validation.InputValidationKt.validateCustomPathToApk; 11 | import static com.dg.watcher.validation.InputValidationKt.validateThresholdInMb; 12 | 13 | 14 | @Extension 15 | public class PluginDescriptor extends BuildStepDescriptor { 16 | public PluginDescriptor() { 17 | super(Plugin.class); 18 | } 19 | 20 | @Override 21 | public boolean isApplicable(Class type) { 22 | return true; 23 | } 24 | 25 | @Override 26 | public String getDisplayName() { 27 | return "Watch over the changing size of your .apk file"; 28 | } 29 | 30 | // Used for validation in config.jelly 31 | public FormValidation doCheckThresholdInMb(@QueryParameter String value) { 32 | return validateThresholdInMb(value); 33 | } 34 | 35 | // Used for validation in config.jelly 36 | public FormValidation doCheckCustomPathToApk(@QueryParameter String value, @AncestorInPath AbstractProject project) { 37 | return validateCustomPathToApk(value, project); 38 | } 39 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/base/Const.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.base 2 | 3 | import org.jfree.chart.plot.DefaultDrawingSupplier 4 | import org.jfree.chart.plot.DefaultDrawingSupplier.* 5 | import java.awt.BasicStroke 6 | import java.awt.geom.Ellipse2D 7 | import java.io.File.separator 8 | import java.lang.System.getProperty 9 | 10 | 11 | val DB_FILE = separator + "android_apk_size_watcher_plugin" + separator + "database.csv" 12 | val DB_ENCODING = "UTF-8" 13 | val DB_ROW_SEPARATOR: String = getProperty("line.separator") 14 | val DB_COLUMN_SEPARATOR = "," 15 | 16 | val APK_FILE_FILTER = "*.apk" 17 | val APK_DEFAULT_DIR = "app" + separator + "build" + separator + "outputs" + separator + "apk" 18 | 19 | val BUILD_ALLOWED = true 20 | val BUILD_FORBIDDEN = false 21 | 22 | val GRAPH_WIDTH = 800 23 | val GRAPH_HEIGHT = 600 24 | val GRAPH_MAX_ENTRY_COUNT = 20 25 | val GRAPH_TITLE = "Apk Size History" 26 | val GRAPH_LEGEND = "Debug Apk File" 27 | val GRAPH_X_AXIS = "Build" 28 | val GRAPH_Y_AXIS = "Megabytes" 29 | val GRAPH_TOOLTIP = "Build %s: %.1f Megabytes" 30 | val GRAPH_LINE = BasicStroke(1.5f) 31 | val GRAPH_LINE_DOT = DefaultDrawingSupplier( 32 | DEFAULT_PAINT_SEQUENCE, 33 | DEFAULT_OUTLINE_PAINT_SEQUENCE, 34 | DEFAULT_STROKE_SEQUENCE, 35 | DEFAULT_OUTLINE_STROKE_SEQUENCE, 36 | arrayOf(Ellipse2D.Float(-4f, -4f, 8f, 8f))) 37 | 38 | val CONVERSION_FACTOR_BYTE_TO_MEGABYTE = 1024f * 1024f -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/watching/saving/SizeSavingTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.watching.saving 2 | 3 | import com.dg.watcher.base.Build 4 | import com.dg.watcher.base.Project 5 | import com.nhaarman.mockito_kotlin.mock 6 | import com.nhaarman.mockito_kotlin.whenever 7 | import org.hamcrest.Matchers.* 8 | import org.junit.Assert.assertThat 9 | import org.junit.Rule 10 | import org.junit.Test 11 | import org.junit.rules.TemporaryFolder 12 | import hudson.FilePath 13 | 14 | 15 | class SizeSavingTest { 16 | @Rule @JvmField 17 | val tempDir = TemporaryFolder() 18 | 19 | 20 | @Test 21 | fun `Should load the saved sizes`() { 22 | // GIVEN 23 | saveApkSize(mockApk(sizeInByte = 10000000L), mockBuild(name = "#1")) 24 | saveApkSize(mockApk(sizeInByte = 11000000L), mockBuild(name = "#2")) 25 | 26 | // WHEN 27 | val sizes = loadApkSizes(mockProject()) 28 | 29 | // THEN 30 | assertThat(sizes, hasSize(2)) 31 | assertThat(sizes.first().buildName, `is`(equalTo("#1"))) 32 | assertThat(sizes.first().sizeInByte, `is`(equalTo(10000000L))) 33 | assertThat(sizes.last().buildName, `is`(equalTo("#2"))) 34 | assertThat(sizes.last().sizeInByte, `is`(equalTo(11000000L))) 35 | } 36 | 37 | @Test 38 | fun `Should not load any sizes when there are none saved`() = 39 | assertThat(loadApkSizes(mockProject()), hasSize(0)) 40 | 41 | private fun mockBuild(name: String) = mockBuild().apply { 42 | whenever(getDisplayName()).thenReturn(name) 43 | } 44 | 45 | private fun mockBuild() = mock().apply { 46 | val project = mockProject() 47 | 48 | whenever(getProject()).thenReturn(project) 49 | } 50 | 51 | private fun mockProject() = mock().apply { 52 | whenever(getRootDir()).thenReturn(tempDir.root) 53 | } 54 | 55 | private fun mockApk(sizeInByte: Long) = mock().apply { 56 | whenever(length()).thenReturn(sizeInByte) 57 | } 58 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/watching/saving/SizeSaving.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.watching.saving 2 | 3 | import com.dg.watcher.base.* 4 | import hudson.FilePath 5 | import org.apache.commons.io.FileUtils.readFileToString 6 | import org.apache.commons.io.FileUtils.writeStringToFile 7 | import java.io.File 8 | import java.io.IOException 9 | 10 | 11 | fun saveApkSize(apk: FilePath, build: Build) { 12 | try { 13 | insertApkSize(apk, build) 14 | } 15 | catch(e: IOException) { 16 | e.printStackTrace() 17 | } 18 | } 19 | 20 | fun loadApkSizes(project: Project) = 21 | try { 22 | val sizes = arrayListOf() 23 | 24 | loadRowsFromDatabase(loadDatabase(project)).forEach { 25 | sizes += createSizeEntryFromRow(it) 26 | } 27 | 28 | sizes.toList() 29 | } 30 | catch(e: IOException) { 31 | e.printStackTrace() 32 | 33 | emptyList() 34 | } 35 | 36 | private fun insertApkSize(apk: FilePath, build: Build) { 37 | val db = loadDatabase(build.getProject()) 38 | 39 | writeStringToFile(db, createDatabaseRow(apk, build, db), DB_ENCODING, true) 40 | } 41 | 42 | private fun loadDatabase(project: Project) = File(project.getRootDir().absolutePath + DB_FILE) 43 | 44 | private fun createDatabaseRow(apk: FilePath, build: Build, db: File) = createRowSeparator(db) + createRowData(apk, build) 45 | 46 | private fun createRowSeparator(database: File) = if(database.exists()) DB_ROW_SEPARATOR else "" 47 | 48 | private fun createRowData(apk: FilePath, build: Build) = build.getDisplayName() + DB_COLUMN_SEPARATOR + apk.length() 49 | 50 | private fun loadRowsFromDatabase(database: File) = 51 | if(database.exists()) { 52 | readFileToString(database, DB_ENCODING).split(DB_ROW_SEPARATOR) 53 | } 54 | else { 55 | emptyList() 56 | } 57 | 58 | private fun createSizeEntryFromRow(entryRow: String): SizeEntry { 59 | val (name, size) = entryRow.split(DB_COLUMN_SEPARATOR) 60 | 61 | return SizeEntry(name, size.toLong()) 62 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/validation/InputValidationTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.validation 2 | 3 | import com.dg.watcher.base.Project 4 | import com.nhaarman.mockito_kotlin.doReturn 5 | import com.nhaarman.mockito_kotlin.mock 6 | import hudson.FilePath 7 | import hudson.util.FormValidation.ok 8 | import org.hamcrest.Matchers.`is` 9 | import org.hamcrest.Matchers.equalTo 10 | import org.junit.Assert.assertThat 11 | import org.junit.Before 12 | import org.junit.Rule 13 | import org.junit.Test 14 | import org.junit.rules.TemporaryFolder 15 | 16 | 17 | class InputValidationTest { 18 | @Rule @JvmField 19 | val tempDir = TemporaryFolder() 20 | 21 | 22 | @Before 23 | fun setUp() { 24 | createTempApkFolder() 25 | } 26 | 27 | @Test 28 | fun `Should allow a positive threshold`() = 29 | assertThat(validateThresholdInMb("2.5"), `is`(equalTo(ok()))) 30 | 31 | @Test 32 | fun `Should indicate a negative threshold with an error message`() = 33 | assertThat(validateThresholdInMb("-2.5").message, `is`(equalTo("The threshold cannot be negative."))) 34 | 35 | @Test 36 | fun `Should indicate a non integral threshold with an error message`() = 37 | assertThat(validateThresholdInMb("abc").message, `is`(equalTo("The threshold must be a floating point number."))) 38 | 39 | @Test 40 | fun `Should allow an empty custom path to the apk`() = 41 | assertThat(validateCustomPathToApk("", mockProject()), `is`(equalTo(ok()))) 42 | 43 | @Test 44 | fun `Should allow an existing custom path to the apk`() = 45 | assertThat(validateCustomPathToApk("temp_apk_folder", mockProject()), `is`(equalTo(ok()))) 46 | 47 | @Test 48 | fun `Should indicate a invalid custom path to the apk with an error message`() = 49 | assertThat(validateCustomPathToApk("not/existing/path", mockProject()).message, 50 | `is`(equalTo("The specified path does not exist."))) 51 | 52 | private fun createTempApkFolder() = tempDir.newFolder("temp_apk_folder") 53 | 54 | private fun mockProject() = mock { 55 | on { getSomeWorkspace() } doReturn FilePath(tempDir.root) 56 | } 57 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/watching/ApkSizeWatching.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.watching 2 | 3 | import com.dg.watcher.base.BUILD_ALLOWED 4 | import com.dg.watcher.base.BUILD_FORBIDDEN 5 | import com.dg.watcher.base.Build 6 | import com.dg.watcher.watching.loading.loadApk 7 | import com.dg.watcher.watching.saving.loadApkSizes 8 | import com.dg.watcher.watching.saving.saveApkSize 9 | import com.dg.watcher.watching.surveying.SizeSurveyingResult.SIZE_THRESHOLD_EXCEEDED 10 | import com.dg.watcher.watching.surveying.surveySizes 11 | import java.io.PrintStream 12 | 13 | 14 | fun watchApkSize(build: Build, logger: PrintStream, thresholdInMb: Float, customPathToApk: String = ""): Boolean { 15 | val apk = loadApk(build, customPathToApk) 16 | 17 | return if(apk != null) { 18 | saveApkSize(apk, build) 19 | 20 | evaluateSize(build, logger, thresholdInMb) 21 | } 22 | else { 23 | permitBuildWithoutApk(logger) 24 | } 25 | } 26 | 27 | private fun evaluateSize(build: Build, logger: PrintStream, thresholdInMb: Float) = 28 | if(surveySizes(loadApkSizes(build.getProject()), thresholdInMb) == SIZE_THRESHOLD_EXCEEDED) { 29 | cancelBuildWithApk(logger, thresholdInMb) 30 | } 31 | else { 32 | permitBuildWithApk(logger, thresholdInMb) 33 | } 34 | 35 | private fun cancelBuildWithApk(logger: PrintStream, thresholdInMb: Float) = BUILD_FORBIDDEN.also { 36 | logger.println("Android Apk Size Watcher Plugin: Build Failed") 37 | logger.println("Android Apk Size Watcher Plugin: The size difference between your " + 38 | "last and latest .apk file exceeded the specified threshold of $thresholdInMb megabytes.") 39 | } 40 | 41 | private fun permitBuildWithApk(logger: PrintStream, thresholdInMb: Float) = BUILD_ALLOWED.also { 42 | logger.println("Android Apk Size Watcher Plugin: Build Succeeded") 43 | logger.println("Android Apk Size Watcher Plugin: The size difference between your " + 44 | "last and latest .apk file met the specified threshold of $thresholdInMb megabytes.") 45 | } 46 | 47 | private fun permitBuildWithoutApk(logger: PrintStream) = BUILD_ALLOWED.also { 48 | logger.println("Android Apk Size Watcher Plugin: Couldn't detect a generated .apk file.") 49 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/watching/surveying/SizeSurveyingTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.watching.surveying 2 | 3 | import com.dg.watcher.watching.saving.SizeEntry 4 | import com.dg.watcher.watching.surveying.SizeSurveyingResult.SIZE_THRESHOLD_EXCEEDED 5 | import com.dg.watcher.watching.surveying.SizeSurveyingResult.SIZE_THRESHOLD_MET 6 | import org.hamcrest.Matchers.`is` 7 | import org.hamcrest.Matchers.equalTo 8 | import org.junit.Assert.assertThat 9 | import org.junit.Test 10 | 11 | 12 | class SizeSurveyingTest { 13 | @Test 14 | fun `Should pass the size survey when there are no sizes recorded`() { 15 | // GIVEN 16 | val sizes = listOf() 17 | 18 | // THEN 19 | assertThat(surveySizes(sizes, thresholdInMb = 1f), `is`(equalTo(SIZE_THRESHOLD_MET))) 20 | } 21 | 22 | @Test 23 | fun `Should pass the size survey when only one size is recorded`() { 24 | // GIVEN 25 | val sizes = listOf(SizeEntry("#1", sizeInByte = 10000000L)) 26 | 27 | // THEN 28 | assertThat(surveySizes(sizes, thresholdInMb = 1f), `is`(equalTo(SIZE_THRESHOLD_MET))) 29 | } 30 | 31 | @Test 32 | fun `Should pass the size survey when the size difference is below the threshold`() { 33 | // GIVEN 34 | val sizes = listOf(SizeEntry("#1", sizeInByte = 10000000L), SizeEntry("#2", sizeInByte = 11000000L)) 35 | 36 | // THEN 37 | assertThat(surveySizes(sizes, thresholdInMb = 2f), `is`(equalTo(SIZE_THRESHOLD_MET))) 38 | } 39 | 40 | @Test 41 | fun `Should pass the size survey when the size difference is exactly the threshold`() { 42 | // GIVEN 43 | val sizes = listOf(SizeEntry("#1", sizeInByte = 10000000L), SizeEntry("#2", sizeInByte = 12000000L)) 44 | 45 | // THEN 46 | assertThat(surveySizes(sizes, thresholdInMb = 2f), `is`(equalTo(SIZE_THRESHOLD_MET))) 47 | } 48 | 49 | @Test 50 | fun `Should fail the size survey when the size difference exceeds the threshold`() { 51 | // GIVEN 52 | val sizes = listOf(SizeEntry("#1", sizeInByte = 10000000L), SizeEntry("#2", sizeInByte = 14000000L)) 53 | 54 | // THEN 55 | assertThat(surveySizes(sizes, thresholdInMb = 2f), `is`(equalTo(SIZE_THRESHOLD_EXCEEDED))) 56 | } 57 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/history/graph/HistoryGraphDataSetGenerationTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.history.graph 2 | 3 | import com.dg.watcher.base.CONVERSION_FACTOR_BYTE_TO_MEGABYTE 4 | import com.dg.watcher.watching.saving.SizeEntry 5 | import org.hamcrest.Matchers.`is` 6 | import org.hamcrest.Matchers.equalTo 7 | import org.jfree.data.category.DefaultCategoryDataset 8 | import org.junit.Assert.assertThat 9 | import org.junit.Test 10 | 11 | 12 | class HistoryGraphDataSetGenerationTest { 13 | @Test 14 | fun `Should include all 20 entries in the generated data set`() { 15 | // GIVEN 16 | val entries = arrayListOf() 17 | repeat(20) { entries.add(SizeEntry("#" + it, 0L)) } 18 | 19 | // WHEN 20 | val dataSet = generateGraphDataSet(entries) 21 | 22 | // THEN 23 | assertThat(retrieveEntries(dataSet), `is`(equalTo(entries))) 24 | } 25 | 26 | @Test 27 | fun `Should include the last 20 entries in the generated data set`() { 28 | // GIVEN 29 | val entries = arrayListOf() 30 | repeat(35) { entries.add(SizeEntry("#" + it, 0L)) } 31 | 32 | // WHEN 33 | val dataSet = generateGraphDataSet(entries) 34 | 35 | // THEN 36 | assertThat(retrieveEntries(dataSet), `is`(equalTo(entries.subList(15, 35)))) 37 | } 38 | 39 | @Test 40 | fun `Should convert a entries size to megabyte for its row value`() { 41 | // GIVEN 42 | val entries = arrayListOf(SizeEntry("#1", sizeInByte = 10000000L)) 43 | 44 | // WHEN 45 | val dataSet = generateGraphDataSet(entries) 46 | 47 | // THEN 48 | val rowValue = dataSet.getValue(0, 0).toFloat() 49 | 50 | assertThat(rowValue, `is`(10000000L / CONVERSION_FACTOR_BYTE_TO_MEGABYTE)) 51 | } 52 | 53 | @Test 54 | fun `Should use a entries build name as its column key`() { 55 | // GIVEN 56 | val entries = arrayListOf(SizeEntry("#1", 0L)) 57 | 58 | // WHEN 59 | val dataSet = generateGraphDataSet(entries) 60 | 61 | // THEN 62 | val columnKey = dataSet.getColumnKey(0).toString() 63 | 64 | assertThat(columnKey, `is`(equalTo("#1"))) 65 | } 66 | 67 | private fun retrieveEntries(dataSet: DefaultCategoryDataset) = arrayListOf().apply { 68 | for(i in 0 until dataSet.columnCount) { 69 | val buildName = dataSet.getColumnKey(i).toString() 70 | 71 | val apkSizeInMb = dataSet.getValue(0, i).toFloat() 72 | val apkSizeInByte = (apkSizeInMb * CONVERSION_FACTOR_BYTE_TO_MEGABYTE).toLong() 73 | 74 | add(SizeEntry(buildName, apkSizeInByte)) 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/PluginTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher 2 | 3 | import com.dg.watcher.base.Build 4 | import com.dg.watcher.base.Project 5 | import com.dg.watcher.history.History 6 | import com.nhaarman.mockito_kotlin.mock 7 | import com.nhaarman.mockito_kotlin.whenever 8 | import hudson.FilePath 9 | import hudson.Launcher 10 | import hudson.model.BuildListener 11 | import hudson.tasks.BuildStepMonitor.NONE 12 | import org.hamcrest.Matchers.* 13 | import org.junit.Assert.assertThat 14 | import org.junit.Before 15 | import org.junit.Rule 16 | import org.junit.Test 17 | import org.junit.rules.TemporaryFolder 18 | import org.mockito.Mockito.verify 19 | 20 | 21 | class PluginTest { 22 | @Rule @JvmField 23 | val tempDir = TemporaryFolder() 24 | 25 | 26 | @Before 27 | fun setUp() { 28 | createApkFolder() 29 | createApkFile() 30 | } 31 | 32 | @Test 33 | fun `Should not require a monitor service`() = 34 | assertThat(plugin().requiredMonitorService, `is`(equalTo(NONE))) 35 | 36 | @Test 37 | fun `Should provide the history as the projects only action`() { 38 | // WHEN 39 | val actions = plugin().getProjectActions(mockProject()) 40 | 41 | // THEN 42 | assertThat(actions, hasSize(1)) 43 | assertThat(actions.first(), `is`(instanceOf(History::class.java))) 44 | } 45 | 46 | @Test 47 | fun `Should update the history action when a build is performed`() { 48 | // GIVEN 49 | val action = mockAction() 50 | 51 | // WHEN 52 | plugin().perform(mockBuildIncludesAction(action), mockLauncher(), mockListener()) 53 | 54 | // THEN 55 | verify(action).updateHistory() 56 | } 57 | 58 | private fun createApkFolder() = tempDir.newFolder("temp_apk_folder") 59 | 60 | private fun createApkFile() = tempDir.newFile("temp_apk_folder/debug.txt") 61 | 62 | private fun plugin() = Plugin(0f, "temp_apk_folder") 63 | 64 | private fun mockProjectIncludesAction(action: History) = mockProject().apply { 65 | whenever(getAction(History::class.java)).thenReturn(action) 66 | } 67 | 68 | private fun mockProject() = mock().apply { 69 | whenever(getRootDir()).thenReturn(tempDir.root) 70 | } 71 | 72 | private fun mockBuildIncludesAction(action: History) = mockBuild().apply { 73 | val project = mockProjectIncludesAction(action) 74 | 75 | whenever(getProject()).thenReturn(project) 76 | } 77 | 78 | private fun mockBuild() = mock().apply { 79 | whenever(getWorkspace()).thenReturn(FilePath(tempDir.root)) 80 | } 81 | 82 | private fun mockAction() = mock() 83 | 84 | private fun mockLauncher() = mock() 85 | 86 | private fun mockListener() = mock().apply { 87 | whenever(logger).thenReturn(mock()) 88 | } 89 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/watching/loading/ApkLoadingTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.watching.loading 2 | 3 | import com.dg.watcher.base.Build 4 | import com.nhaarman.mockito_kotlin.mock 5 | import com.nhaarman.mockito_kotlin.whenever 6 | import hudson.FilePath 7 | import org.junit.Assert.* 8 | import org.junit.Rule 9 | import org.junit.Test 10 | import org.junit.rules.TemporaryFolder 11 | import java.io.File.separator 12 | 13 | 14 | class ApkLoadingTest { 15 | @Rule @JvmField 16 | val tempDir = TemporaryFolder() 17 | 18 | 19 | @Test 20 | fun `Should return null when the workspace is non existent`() = 21 | assertNull(loadApk(mockBuild(workspace = null))) 22 | 23 | @Test 24 | fun `Should return null when the default folder is non existent`() = 25 | assertNull(loadApk(mockBuild())) 26 | 27 | @Test 28 | fun `Should return null when the specified folder is non existent`() = 29 | assertNull(loadApk(mockBuild(), "temp_apk_folder")) 30 | 31 | @Test 32 | fun `Should return null when the apk in the default folder is non existent`() { 33 | // GIVEN 34 | createApkFolder("app", "build", "outputs", "apk") 35 | 36 | // THEN 37 | assertNull(loadApk(mockBuild())) 38 | } 39 | 40 | @Test 41 | fun `Should return null when the apk in the specified folder is non existent`() { 42 | // GIVEN 43 | createApkFolder("temp_apk_folder") 44 | 45 | // THEN 46 | assertNull(loadApk(mockBuild(), "temp_apk_folder")) 47 | } 48 | 49 | @Test 50 | fun `Should load the apk from the default folder`() { 51 | // GIVEN 52 | createApkFolder("app", "build", "outputs", "apk") 53 | createApkFile("app/build/outputs/apk", "debug.apk") 54 | 55 | // THEN 56 | assertNotNull(loadApk(mockBuild())) 57 | } 58 | 59 | @Test 60 | fun `Should load the apk from the specified folder`() { 61 | // GIVEN 62 | createApkFolder("temp_apk_folder") 63 | createApkFile("temp_apk_folder", "debug.apk") 64 | 65 | // THEN 66 | assertNotNull(loadApk(mockBuild(), "temp_apk_folder")) 67 | } 68 | 69 | @Test 70 | fun `Should never load the apk from a nested folder`() { 71 | // GIVEN 72 | createApkFolder("apk_folder", "nested_folder") 73 | createApkFile("apk_folder/nested_folder", "debug.apk") 74 | 75 | // THEN 76 | assertNull(loadApk(mockBuild(), "apk_folder")) 77 | } 78 | 79 | private fun createApkFolder(vararg folders: String) = tempDir.newFolder(*folders) 80 | 81 | private fun createApkFile(folder: String, fileName: String) = tempDir.newFile("$folder$separator$fileName") 82 | 83 | private fun mockBuild(workspace: FilePath? = FilePath(tempDir.root)) = mock().apply { 84 | whenever(getWorkspace()).thenReturn(workspace) 85 | } 86 | } -------------------------------------------------------------------------------- /src/main/java/com/dg/watcher/history/graph/HistoryGraph.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.history.graph 2 | 3 | import com.dg.watcher.base.* 4 | import com.dg.watcher.watching.saving.loadApkSizes 5 | import hudson.util.Graph 6 | import org.jfree.chart.ChartFactory.createLineChart 7 | import org.jfree.chart.JFreeChart 8 | import org.jfree.chart.axis.CategoryLabelPositions.UP_90 9 | import org.jfree.chart.axis.NumberAxis 10 | import org.jfree.chart.plot.CategoryPlot 11 | import org.jfree.chart.plot.PlotOrientation.VERTICAL 12 | import org.jfree.chart.renderer.category.LineAndShapeRenderer 13 | import org.kohsuke.stapler.StaplerRequest 14 | import org.kohsuke.stapler.StaplerResponse 15 | import java.awt.Color.RED 16 | import java.awt.Color.white 17 | import java.lang.System.currentTimeMillis 18 | import java.text.NumberFormat 19 | 20 | 21 | class HistoryGraph(private val project: Project) : Graph(currentTimeMillis(), GRAPH_WIDTH, GRAPH_HEIGHT) { 22 | fun drawGraph(request: StaplerRequest, response: StaplerResponse) = doPng(request, response) 23 | 24 | fun drawGraphTooltips(request: StaplerRequest, response: StaplerResponse) = doMap(request, response) 25 | 26 | override fun createGraph(): JFreeChart = createLineGraph().also { 27 | setupGraphBackground(it) 28 | setupGraphSizesAxis(it) 29 | setupGraphBuildAxis(it) 30 | setupGraphSizesLine(it) 31 | setupGraphTooltips(it) 32 | setupGraphLinks(it) 33 | } 34 | 35 | private fun createLineGraph() = createLineChart(GRAPH_TITLE, GRAPH_X_AXIS, GRAPH_Y_AXIS, 36 | createGraphData(), VERTICAL, true, false, false) 37 | 38 | private fun createGraphData() = generateGraphDataSet(loadApkSizes(project)) 39 | 40 | private fun setupGraphBackground(graph: JFreeChart) { 41 | graph.backgroundPaint = white 42 | } 43 | 44 | private fun setupGraphSizesAxis(graph: JFreeChart) { 45 | val sizesAxis = getPlot(graph).rangeAxis as NumberAxis 46 | 47 | val sizeInMegabyteFormat = NumberFormat.getInstance().apply { 48 | minimumFractionDigits = 1 49 | maximumFractionDigits = 1 50 | } 51 | 52 | sizesAxis.numberFormatOverride = sizeInMegabyteFormat 53 | } 54 | 55 | private fun setupGraphBuildAxis(graph: JFreeChart) { 56 | val buildAxis = getPlot(graph).domainAxis 57 | 58 | buildAxis.categoryLabelPositions = UP_90 59 | } 60 | 61 | private fun setupGraphSizesLine(graph: JFreeChart) { 62 | getRenderer(graph).apply { 63 | baseShapesVisible = true 64 | baseStroke = GRAPH_LINE 65 | setSeriesPaint(0, RED) 66 | } 67 | 68 | getPlot(graph).drawingSupplier = GRAPH_LINE_DOT 69 | } 70 | 71 | private fun setupGraphTooltips(graph: JFreeChart) { 72 | getRenderer(graph).baseToolTipGenerator = HistoryGraphTooltipGenerator() 73 | } 74 | 75 | private fun setupGraphLinks(graph: JFreeChart) { 76 | getRenderer(graph).baseItemURLGenerator = HistoryGraphUrlGenerator(project) 77 | } 78 | 79 | private fun getRenderer(graph: JFreeChart) = getPlot(graph).renderer as LineAndShapeRenderer 80 | 81 | private fun getPlot(graph: JFreeChart) = graph.plot as CategoryPlot 82 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/dg/watcher/watching/ApkSizeWatchingTest.kt: -------------------------------------------------------------------------------- 1 | package com.dg.watcher.watching 2 | 3 | import com.dg.watcher.base.BUILD_ALLOWED 4 | import com.dg.watcher.base.BUILD_FORBIDDEN 5 | import com.dg.watcher.base.Build 6 | import com.dg.watcher.base.Project 7 | import com.nhaarman.mockito_kotlin.doReturn 8 | import com.nhaarman.mockito_kotlin.mock 9 | import com.nhaarman.mockito_kotlin.whenever 10 | import hudson.FilePath 11 | import org.apache.commons.io.FileUtils.cleanDirectory 12 | import org.apache.commons.io.FileUtils.writeByteArrayToFile 13 | import org.hamcrest.Matchers.equalTo 14 | import org.hamcrest.core.Is.`is` 15 | import org.junit.Assert.assertThat 16 | import org.junit.Before 17 | import org.junit.Rule 18 | import org.junit.Test 19 | import org.junit.rules.TemporaryFolder 20 | import java.io.File 21 | 22 | 23 | class ApkSizeWatchingTest { 24 | @Rule @JvmField 25 | val tempDir = TemporaryFolder() 26 | 27 | 28 | @Before 29 | fun setUp() { 30 | createApkFolder() 31 | } 32 | 33 | @Test 34 | fun `Should allow the initial build with an generated apk`() { 35 | // GIVEN 36 | val build = mockBuildWithAnApkOfSizeInByte(10000000L) 37 | 38 | // WHEN 39 | val result = watchApkSize(build, thresholdInMb = 1f) 40 | 41 | // THEN 42 | assertThat(result, `is`(equalTo(BUILD_ALLOWED))) 43 | } 44 | 45 | @Test 46 | fun `Should allow a build without an generated apk`() { 47 | // GIVEN 48 | val build = mockBuildWithoutAnApk() 49 | 50 | // WHEN 51 | val result = watchApkSize(build) 52 | 53 | // THEN 54 | assertThat(result, `is`(equalTo(BUILD_ALLOWED))) 55 | } 56 | 57 | @Test 58 | fun `Should allow the build when the generated apk meets the size threshold`() { 59 | // GIVEN 60 | watchApkSize(mockBuildWithAnApkOfSizeInByte(10000000L)) 61 | val build = mockBuildWithAnApkOfSizeInByte(11000000L) 62 | 63 | // WHEN 64 | val result = watchApkSize(build, thresholdInMb = 1f) 65 | 66 | // THEN 67 | assertThat(result, `is`(equalTo(BUILD_ALLOWED))) 68 | } 69 | 70 | @Test 71 | fun `Should forbid the build when the generated apk exceeds the size threshold`() { 72 | // GIVEN 73 | watchApkSize(mockBuildWithAnApkOfSizeInByte(10000000L)) 74 | val build = mockBuildWithAnApkOfSizeInByte(12000000L) 75 | 76 | // WHEN 77 | val result = watchApkSize(build, thresholdInMb = 1f) 78 | 79 | // THEN 80 | assertThat(result, `is`(equalTo(BUILD_FORBIDDEN))) 81 | } 82 | 83 | private fun watchApkSize(build: Build, thresholdInMb: Float = 0f) = 84 | watchApkSize(build, mock(), thresholdInMb) 85 | 86 | private fun mockBuild() = mock().apply { 87 | val project: Project = mock { 88 | on { getRootDir() } doReturn tempDir.root 89 | } 90 | 91 | whenever(getProject()).thenReturn(project) 92 | whenever(getWorkspace()).thenReturn(FilePath(tempDir.root)) 93 | } 94 | 95 | private fun mockBuildWithoutAnApk() = mockBuild() 96 | 97 | private fun mockBuildWithAnApkOfSizeInByte(apkSizeInByte: Long) = mockBuild().also { 98 | createApkForBuild(apkSizeInByte) 99 | } 100 | 101 | private fun createApkFolder() = tempDir.newFolder("app", "build", "outputs", "apk") 102 | 103 | private fun createApkForBuild(apkSizeInByte: Long) { 104 | deleteOldApk() 105 | 106 | createNewApk(apkSizeInByte) 107 | } 108 | 109 | private fun deleteOldApk() = cleanDirectory(File(tempDir.root.absolutePath + "/app/build/outputs/apk")) 110 | 111 | private fun createNewApk(apkSizeInByte: Long) { 112 | val apk = tempDir.newFile("app/build/outputs/apk/debug.apk") 113 | 114 | writeByteArrayToFile(apk, ByteArray(apkSizeInByte.toInt())) 115 | } 116 | } -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4.0.0 5 | 6 | android-apk-size-watcher 7 | hpi 8 | 1.1-SNAPSHOT 9 | 10 | Android Apk Size Watcher Plugin 11 | Watch over the changing size of your .apk file and fail your build once the change exceeds your specified threshold. 12 | https://wiki.jenkins.io/display/JENKINS/Android+Apk+Size+Watcher+Plugin 13 | 14 | 15 | 16 | xgleich1 17 | Daniel Gronau 18 | xgleich1@gmail.com 19 | 20 | 21 | 22 | 23 | 24 | The MIT License (MIT) 25 | http://opensource.org/licenses/MIT 26 | repo 27 | 28 | 29 | 30 | 31 | scm:git:ssh://github.com/jenkinsci/android-apk-size-watcher-plugin.git 32 | scm:git:ssh://git@github.com/jenkinsci/android-apk-size-watcher-plugin.git 33 | https://github.com/jenkinsci/android-apk-size-watcher-plugin 34 | HEAD 35 | 36 | 37 | 38 | 39 | 40 | org.jetbrains.kotlin 41 | kotlin-maven-plugin 42 | 1.1.4-3 43 | 44 | 45 | 46 | compile 47 | 48 | 49 | compile 50 | 51 | 52 | 53 | 54 | test-compile 55 | 56 | 57 | test-compile 58 | 59 | 60 | 61 | 62 | ${project.basedir}/src/test/kotlin 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | org.apache.maven.plugins 71 | maven-compiler-plugin 72 | 73 | 74 | 75 | default-compile 76 | 77 | none 78 | 79 | 80 | 81 | default-testCompile 82 | 83 | none 84 | 85 | 86 | 87 | java-compile 88 | 89 | compile 90 | 91 | 92 | compile 93 | 94 | 95 | 96 | 97 | java-test-compile 98 | 99 | test-compile 100 | 101 | 102 | testCompile 103 | 104 | 105 | 106 | 107 | 108 | 109 | org.apache.maven.plugins 110 | maven-release-plugin 111 | 2.5.3 112 | 113 | 114 | 115 | org.apache.maven.plugins 116 | maven-scm-plugin 117 | 1.9.5 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | org.jenkins-ci.plugins 126 | plugin 127 | 2.33 128 | 129 | 130 | 131 | 132 | repo.jenkins-ci.org 133 | http://repo.jenkins-ci.org/public/ 134 | 135 | 136 | 137 | 138 | 139 | repo.jenkins-ci.org 140 | http://repo.jenkins-ci.org/public/ 141 | 142 | 143 | 144 | 145 | 146 | org.jetbrains.kotlin 147 | kotlin-stdlib-jre7 148 | 1.1.4-3 149 | 150 | 151 | 152 | commons-io 153 | commons-io 154 | 2.5 155 | 156 | 157 | 158 | junit 159 | junit 160 | 4.12 161 | test 162 | 163 | 164 | 165 | org.hamcrest 166 | hamcrest-all 167 | 1.3 168 | test 169 | 170 | 171 | 172 | com.nhaarman 173 | mockito-kotlin 174 | 1.5.0 175 | 176 | 177 | 178 | --------------------------------------------------------------------------------