diff --git a/.github/workflows/gradle-data-capturing-samples-verification.yml b/.github/workflows/gradle-data-capturing-samples-verification.yml index b95b8d3f3..c6f5139c8 100644 --- a/.github/workflows/gradle-data-capturing-samples-verification.yml +++ b/.github/workflows/gradle-data-capturing-samples-verification.yml @@ -51,7 +51,7 @@ jobs: working-directory: common-gradle-enterprise-gradle-configuration-kotlin run: | # apply sample file - echo "apply(from = \"../build-data-capturing-gradle-samples/${{matrix.sample-file}}\")" >> build.gradle.kts + echo "apply(from = \"../build-data-capturing-gradle-samples/${{matrix.sample-file}}.kts\")" >> build.gradle.kts - name: Run Gradle build using Kotlin DSL working-directory: common-gradle-enterprise-gradle-configuration-kotlin run: ./gradlew tasks -Dgradle.enterprise.url=https://ge.solutions-team.gradle.com diff --git a/build-data-capturing-gradle-samples/capture-ge-plugin-version/gradle-ge-plugin-version.gradle.kts b/build-data-capturing-gradle-samples/capture-ge-plugin-version/gradle-ge-plugin-version.gradle.kts new file mode 100644 index 000000000..23004b427 --- /dev/null +++ b/build-data-capturing-gradle-samples/capture-ge-plugin-version/gradle-ge-plugin-version.gradle.kts @@ -0,0 +1,14 @@ +import com.gradle.enterprise.gradleplugin.GradleEnterpriseExtension + +/** + * This Kotlin script captures the Gradle Enterprise Gradle plugin version as a custom value. + */ + +project.extensions.configure() { + buildScan { + val url = GradleEnterpriseExtension::class.java.classLoader.getResource("com.gradle.scan.plugin.internal.meta.buildAgentVersion.txt") + val buildAgentVersion = url.readText() + value("GE Gradle plugin version", buildAgentVersion) + } +} + diff --git a/build-data-capturing-gradle-samples/capture-git-diffs/gradle-git-diffs.gradle.kts b/build-data-capturing-gradle-samples/capture-git-diffs/gradle-git-diffs.gradle.kts new file mode 100644 index 000000000..f0642ac35 --- /dev/null +++ b/build-data-capturing-gradle-samples/capture-git-diffs/gradle-git-diffs.gradle.kts @@ -0,0 +1,117 @@ +import com.gradle.enterprise.gradleplugin.GradleEnterpriseExtension +import com.gradle.scan.plugin.BuildScanExtension +import groovy.json.JsonBuilder +import groovy.json.JsonSlurper + +import java.nio.charset.Charset +import java.util.Base64 +import java.util.concurrent.TimeUnit + +/** + * This Gradle script captures the Git diff in a GitHub gist, + * and references the gist via a custom link. + * + * In order for the gist to be created successfully, you must specify the Gradle property `gistToken` where + * the value is a Github access token that has gist permission on the given Github repo. + * + * See https://docs.github.com/en/rest/gists/gists#create-a-gist for reference. + */ + +project.extensions.configure() { + + val capture = Capture(gradle.startParameter.isOffline, + gradle.startParameter.isContinuous, + gradle.rootProject.logger, + getProviders().gradleProperty("gistToken"), + rootProject.name) + + buildScan { + background { + if(capture.isEnabled()) { + capture.captureGitDiffInGist(buildScan) + } + } + } +} + +class Capture(val offline: Boolean, + val continuous: Boolean, + val logger: Logger, + val gistTokenProvider: Provider, + val projectName: String) { + + fun isEnabled(): Boolean { + val isCapturingScan = !offline && !continuous + if (!isCapturingScan) { + logger.warn("Build is offline or continuous. Will not publish gist.") + return false + } + return true + } + + fun captureGitDiffInGist(api: BuildScanExtension): Unit { + val hasCredentials = gistTokenProvider.isPresent + if (!hasCredentials) { + logger.warn("User has not set 'gistToken'. Cannot publish gist.") + return + } + + val diff = execAndGetStdout("git", "diff") + if (!diff.isNullOrEmpty()) { + try { + val baseUrl = java.net.URL("https://api.github.com/gists") + val credentials = Base64.getEncoder().encodeToString(gistTokenProvider.get().toByteArray()) + val basicAuth = "Basic ${credentials}" + + val connection = baseUrl.openConnection() as java.net.HttpURLConnection + connection.apply { + // request + setRequestProperty("Authorization", basicAuth) + setRequestProperty("Accept", "application/vnd.github+json") + requestMethod = "POST" + doOutput = true + outputStream.bufferedWriter().use { writer -> + jsonRequest(writer, diff) + } + + // response + val url = JsonSlurper().parse(content as java.io.InputStream).withGroovyBuilder { getProperty("html_url") } + api.link("Git diff", url as String) + } + logger.info("Successfully published gist.") + } catch (ex: Exception) { + logger.warn("Unable to publish gist", ex) + } + } + } + + // this method must be static, otherwise Gradle will interpret `files` as Project.files() and this won't work + private fun jsonRequest(writer: java.io.Writer, diff: String): Unit { + val json = groovy.json.JsonOutput.toJson(mapOf( + "description" to "Git diff for ${projectName}", + "public" to false, + "files" to mapOf("${projectName}.diff" to mapOf("content" to diff)))) + writer.write(json) + } + + private fun execAndGetStdout(vararg args: String): String { + val process = ProcessBuilder(*args) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + try { + val finished = process.waitFor(10, TimeUnit.SECONDS) + val standardText = process.inputStream.bufferedReader().readText() + val ignore = process.errorStream.bufferedReader().readText() + + return if (finished && process.exitValue() == 0) trimAtEnd(standardText) else return "" + } finally { + process.destroyForcibly() + } + } + + private fun trimAtEnd(str: String): String { + return ("x" + str).trim().substring(1) + } + +} diff --git a/build-data-capturing-gradle-samples/capture-os-processes/gradle-os-processes.gradle.kts b/build-data-capturing-gradle-samples/capture-os-processes/gradle-os-processes.gradle.kts new file mode 100644 index 000000000..2bdedbe2d --- /dev/null +++ b/build-data-capturing-gradle-samples/capture-os-processes/gradle-os-processes.gradle.kts @@ -0,0 +1,47 @@ +import com.gradle.enterprise.gradleplugin.GradleEnterpriseExtension +import com.gradle.scan.plugin.BuildScanExtension +import java.nio.charset.Charset +import java.util.concurrent.TimeUnit + +/** + * This Gradle script captures the OS processes as reported by the OS 'ps' command, + * and adds these as a custom value. + */ + +project.extensions.configure() { + buildScan { + background { + Capture.captureOsProcesses(buildScan) + } + } +} + +class Capture { + companion object { + fun captureOsProcesses(api: BuildScanExtension): Unit { + val psOutput = execAndGetStdout("ps", "-o pid,ppid,time,command") + api.value("OS processes", psOutput) + } + + private fun execAndGetStdout(vararg args: String): String { + val process = ProcessBuilder(*args) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + try { + val finished = process.waitFor(10, TimeUnit.SECONDS) + val standardText = process.inputStream.bufferedReader().readText() + val ignore = process.errorStream.bufferedReader().readText() + + return if (finished && process.exitValue() == 0) trimAtEnd(standardText) else return "" + } finally { + process.destroyForcibly() + } + } + + private fun trimAtEnd(str: String): String { + return ("x" + str).trim().substring(1) + } + } +} + diff --git a/build-data-capturing-gradle-samples/capture-processor-arch/gradle-processor-arch.gradle.kts b/build-data-capturing-gradle-samples/capture-processor-arch/gradle-processor-arch.gradle.kts new file mode 100644 index 000000000..f417ef5a9 --- /dev/null +++ b/build-data-capturing-gradle-samples/capture-processor-arch/gradle-processor-arch.gradle.kts @@ -0,0 +1,72 @@ +import com.gradle.enterprise.gradleplugin.GradleEnterpriseExtension +import com.gradle.scan.plugin.BuildScanExtension +import java.nio.charset.Charset +import java.util.concurrent.TimeUnit + +/** + * This Gradle script captures the processor architecture + * and adds these as a custom value. + */ + +project.extensions.configure() { + buildScan { + background { + Capture.captureProcessorArch(buildScan) + } + } +} + +class Capture { + companion object { + fun captureProcessorArch(api: BuildScanExtension): Unit { + val osName = System.getProperty("os.name") + api.value("os.name", osName) + + val osArch = System.getProperty("os.arch") + api.value("os.arch", osArch) + + if (isDarwin(osName)) { + if (isTranslatedByRosetta()) { + api.tag("M1-translated") + } else if (isM1()) { + api.tag("M1") + } + } + } + + private fun isDarwin(osName: String): Boolean { + return osName.contains("OS X") || osName.startsWith("Darwin") + } + + private fun isM1(): Boolean { + return execAndGetStdout("uname", "-p") == "arm" + } + + // On Apple silicon, a universal binary may run either natively or as a translated binary + // https://developer.apple.com/documentation/apple-silicon/about-the-rosetta-translation-environment#Determine-Whether-Your-App-Is-Running-as-a-Translated-Binary + private fun isTranslatedByRosetta(): Boolean { + return execAndGetStdout("sysctl", "sysctl.proc_translated") == "sysctl.proc_translated: 1" + } + + private fun execAndGetStdout(vararg args: String): String { + val process = ProcessBuilder(*args) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + try { + val finished = process.waitFor(10, TimeUnit.SECONDS) + val standardText = process.inputStream.bufferedReader().readText() + val ignore = process.errorStream.bufferedReader().readText() + + return if (finished && process.exitValue() == 0) trimAtEnd(standardText) else return "" + } finally { + process.destroyForcibly() + } + } + + private fun trimAtEnd(str: String): String { + return ("x" + str).trim().substring(1) + } + } +} + diff --git a/build-data-capturing-gradle-samples/capture-quality-check-issues/gradle-quality-check-issues.gradle.kts b/build-data-capturing-gradle-samples/capture-quality-check-issues/gradle-quality-check-issues.gradle.kts new file mode 100644 index 000000000..e92c5729f --- /dev/null +++ b/build-data-capturing-gradle-samples/capture-quality-check-issues/gradle-quality-check-issues.gradle.kts @@ -0,0 +1,129 @@ +import com.gradle.enterprise.gradleplugin.GradleEnterpriseExtension +import com.gradle.scan.plugin.BuildScanExtension +import groovy.xml.XmlSlurper +import groovy.xml.slurpersupport.NodeChildren +import groovy.xml.slurpersupport.NodeChild +import groovy.xml.slurpersupport.GPathResult + +/** + * This Gradle script captures issues found by reporting tasks, + * and adds these as custom values. + */ + +project.extensions.configure() { + buildScan { + gradle.taskGraph.beforeTask { + if (ReportingTask.isSupported(this) && this is Reporting<*>) { + val reportTask = this as Reporting> + reportTask.reports.withGroovyBuilder { "xml" { setProperty("enabled", true) } } + } else if (ReportingTask.SPOTBUGS.isTask(this)) { + this.withGroovyBuilder { + val reports = getProperty("reports") + val xml = (reports as NamedDomainObjectContainer<*>).create("xml") { + enabled = true + } + } + } + } + + gradle.taskGraph.afterTask { + if (ReportingTask.isSupported(this)) { + val reportFile = reportFromTask(this) + if (reportFile.exists()) { + val report = XmlSlurper().parse(reportFile) + var valueName = "" + var errors = mutableListOf() + + if (ReportingTask.CHECKSTYLE.isTask(this)) { + valueName = "Verification Checkstyle" + val files = report.getProperty("file") as NodeChildren + files.forEach { f -> + val file = f as NodeChild + val filePath = project.rootProject.relativePath(file.attributes()["name"]) + val checkErrors = file.getProperty("error") as NodeChildren + checkErrors.forEach { e -> + val error = e as NodeChild + errors.add("${filePath}:${error.attributes()["line"]}:${error.attributes()["column"]} \u2192 ${error.attributes()["message"]}") + } + } + } else if (ReportingTask.CODENARC.isTask(this)) { + valueName = "Verification CodeNarc" + val packages = report.getProperty("Package") as NodeChildren + packages.forEach { p -> + val proj = report.getProperty("Project") as NodeChildren + val sourceDirectoryNode = proj.getProperty("SourceDirectory") as NodeChildren + val sourceDirectory = appendIfMissing(sourceDirectoryNode.text() as String, "/") + val files = (p as NodeChild).getProperty("File") as NodeChildren + files.forEach { f -> + val file = f as NodeChild + val filePath = + project.rootProject.relativePath(sourceDirectory + file.attributes()["name"]) + (file.getProperty("Violation") as NodeChildren).forEach { v -> + val violation = v as NodeChild + errors.add("${filePath}:${violation.attributes()["lineNumber"]} \u2192 ${violation.attributes()["ruleName"]}") + } + } + } + } else if (ReportingTask.SPOTBUGS.isTask(this)) { + valueName = "Verification SpotBugs" + val bugs = report.getProperty("BugInstance") as NodeChildren + bugs.forEach { b -> + val bug = b as NodeChild + val type = bug.attributes()["type"] + val error = bug.breadthFirst().asSequence().filter { l -> + val line = l as NodeChild + l.name() == "SourceLine" + }.sortedBy { l -> + (l as NodeChild).parent().name() + }.first() as NodeChild + val startLine = error.attributes()["start"] + val endLine = error.attributes()["end"] + val lineNumber = if (endLine == startLine) startLine else "${startLine}-${endLine}" + val className = error.attributes()["classname"] + errors.add("${className}:${lineNumber} \u2192 ${type}") + } + } + errors.forEach { e -> buildScan.value(valueName, e) } + } + } + } + } +} + +fun reportFromTask(task: Task): File { + if (task is Reporting<*>) { + val task = task as Reporting> + return (task.reports.withGroovyBuilder { "xml" { getProperty("destination") } } as Report).getOutputLocation().get().asFile as File + } else if (ReportingTask.SPOTBUGS.isTask(task)) { + val reports = task.withGroovyBuilder { getProperty("reports") as NamedDomainObjectContainer<*> } + val report = (reports.named("XML") as NamedDomainObjectProvider).get() as SingleFileReport + return report.getOutputLocation().get().asFile + } else { + throw IllegalStateException("Unsupported report task: " + task) + } +} + +fun appendIfMissing(str: String, suffix: String): String { + return if (str.endsWith(suffix)) str else str + suffix +} + +enum class SpotBugsParent { + BugInstance, Method, Class +} + +enum class ReportingTask(val className: String) { + + CHECKSTYLE("org.gradle.api.plugins.quality.Checkstyle"), + CODENARC("org.gradle.api.plugins.quality.CodeNarc"), + SPOTBUGS("com.github.spotbugs.snom.SpotBugsTask"); + + fun isTask(task: Task): Boolean { + return task::class.java.name.contains(className) + } + + companion object { + fun isSupported(task: Task): Boolean { + return values().any { it.isTask(task) } + } + } +} diff --git a/build-data-capturing-gradle-samples/capture-slow-workunit-executions/gradle-slow-task-executions.gradle.kts b/build-data-capturing-gradle-samples/capture-slow-workunit-executions/gradle-slow-task-executions.gradle.kts new file mode 100644 index 000000000..886d731ad --- /dev/null +++ b/build-data-capturing-gradle-samples/capture-slow-workunit-executions/gradle-slow-task-executions.gradle.kts @@ -0,0 +1,36 @@ +import com.gradle.enterprise.gradleplugin.GradleEnterpriseExtension +import com.gradle.scan.plugin.BuildScanExtension + +/** + * This Gradle script captures all tasks of a given type taking longer to execute than a certain threshold, + * and adds these as custom values. + */ + +project.extensions.configure() { + buildScan { + val THRESHOLD_MILLIS = 15 * 60 * 1000 // 15 min + val api = buildScan + allprojects { + tasks.withType { + var start = 0L + doFirst { + start = System.currentTimeMillis() + } + doLast { + val duration = System.currentTimeMillis() - start + if (duration > THRESHOLD_MILLIS) { + Capture.addbuildScanValue(api, "Slow task", identityPath.toString()) + } + } + } + } + } +} + +class Capture { + companion object { + fun addbuildScanValue(api: BuildScanExtension, key: String, value: String): Unit { + api.value(key, value) + } + } +} diff --git a/build-data-capturing-gradle-samples/capture-test-execution-system-properties/gradle-test-execution-system-properties.gradle.kts b/build-data-capturing-gradle-samples/capture-test-execution-system-properties/gradle-test-execution-system-properties.gradle.kts new file mode 100644 index 000000000..e0de26b0a --- /dev/null +++ b/build-data-capturing-gradle-samples/capture-test-execution-system-properties/gradle-test-execution-system-properties.gradle.kts @@ -0,0 +1,52 @@ +import com.gradle.enterprise.gradleplugin.GradleEnterpriseExtension +import com.gradle.scan.plugin.BuildScanExtension +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +/** + * This Gradle script captures the system properties available to each Test task, hashes the properties' values, + * and adds these as custom values. + */ + +project.extensions.configure() { + buildScan { + val api = buildScan + allprojects { + tasks.withType { + doFirst { + systemProperties.forEach { (k, v) -> Capture.addbuildScanValue(api, "${identityPath}#sysProps-${k}", v) } + } + } + } + } +} + +class Capture { + companion object { + val MESSAGE_DIGEST = MessageDigest.getInstance("SHA-256") + + fun addbuildScanValue(api: BuildScanExtension, key: String, value: Any?): Unit { + api.value(key, hash(value)) + } + + private fun hash(value: Any?): String? { + if (value == null) { + return null + } else { + val str = java.lang.String.valueOf(value) + val encodedHash = MESSAGE_DIGEST.digest(str.toByteArray()) + val hexString = StringBuilder() + for (i in 0 until (encodedHash.size / 4)) { + val hex = java.lang.Integer.toHexString(0xff and encodedHash[i].toInt()) + if (hex.length == 1) { + hexString.append("0") + } + hexString.append(hex) + } + hexString.append("...") + return hexString.toString() + } + } + } +} + diff --git a/build-data-capturing-gradle-samples/capture-test-pts-support/gradle-test-pts-support.gradle b/build-data-capturing-gradle-samples/capture-test-pts-support/gradle-test-pts-support.gradle index 383d99c9b..f5c5642c3 100644 --- a/build-data-capturing-gradle-samples/capture-test-pts-support/gradle-test-pts-support.gradle +++ b/build-data-capturing-gradle-samples/capture-test-pts-support/gradle-test-pts-support.gradle @@ -1,12 +1,13 @@ +import com.gradle.scan.plugin.BuildScanExtension import java.nio.charset.StandardCharsets +import java.util.Collections import java.util.Optional import java.util.jar.JarFile import java.util.stream.Stream import java.util.stream.Collectors -import groovy.transform.Field /** - * This Gradle script captures Predictive Test Selection and Test Distribution compatibility for each Test task, + * This Gradle script captures Predictive Test Selection and Test Distribution compatibility for each Test task, * adding a flag as custom value. */ @@ -14,63 +15,73 @@ def buildScanApi = project.extensions.findByName('buildScan') if (!buildScanApi) { return } - -@Field -final def supportedEngines = [ - 'org.junit.support.testng.engine.TestNGTestEngine' : 'testng', - 'org.junit.jupiter.engine.JupiterTestEngine' : 'junit-jupiter', - 'org.junit.vintage.engine.VintageTestEngine' : 'junit-vintage', - 'org.spockframework.runtime.SpockEngine' : 'spock', - 'net.jqwik.engine.JqwikTestEngine' : 'jqwik', - 'com.tngtech.archunit.junit.ArchUnitTestEngine' : 'archunit', - 'co.helmethair.scalatest.ScalatestEngine' : 'scalatest' - ] - +def capture = new Capture(gradle.rootProject.logger) allprojects { tasks.withType(Test).configureEach { t -> doFirst { - if (t.getTestFramework().getClass().getName() == 'org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestFramework') { - def engines = testEngines(t) - buildScanApi.value("${t.identityPath}#engines", "${engines}") - if (!engines.isEmpty() && engines.stream().allMatch { e -> supportedEngines.containsKey(e) }) { - buildScanApi.value("${t.identityPath}#pts", 'SUPPORTED') - } else { - buildScanApi.value("${t.identityPath}#pts", 'ENGINES_NOT_ALL_SUPPORTED') - } + capture.capturePts(t, buildScanApi) + } + } +} + +class Capture { + final def supportedEngines = [ + 'org.junit.support.testng.engine.TestNGTestEngine' : 'testng', + 'org.junit.jupiter.engine.JupiterTestEngine' : 'junit-jupiter', + 'org.junit.vintage.engine.VintageTestEngine' : 'junit-vintage', + 'org.spockframework.runtime.SpockEngine' : 'spock', + 'net.jqwik.engine.JqwikTestEngine' : 'jqwik', + 'com.tngtech.archunit.junit.ArchUnitTestEngine' : 'archunit', + 'co.helmethair.scalatest.ScalatestEngine' : 'scalatest' + ] + private Logger logger + + Capture(Logger logger) { + this.logger = logger + } + + void capturePts(Test t, BuildScanExtension buildScanApi) { + if (t.getTestFramework().getClass().getName() == 'org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestFramework') { + def engines = testEngines(t) + buildScanApi.value("${t.identityPath}#engines", "${engines}") + if (!engines.isEmpty() && engines.stream().allMatch { e -> supportedEngines.containsKey(e) }) { + buildScanApi.value("${t.identityPath}#pts", 'SUPPORTED') } else { - buildScanApi.value("${t.identityPath}#pts", 'NO_JUNIT_PLATFORM') + buildScanApi.value("${t.identityPath}#pts", 'ENGINES_NOT_ALL_SUPPORTED') } + } else { + buildScanApi.value("${t.identityPath}#pts", 'NO_JUNIT_PLATFORM') } } -} -Set testEngines(Test t) { - try { - Stream engines = t.classpath.files.stream() + private Set testEngines(Test t) { + try { + Stream engines = t.classpath.files.stream() .filter { f -> f.name.endsWith('.jar') } .filter { f -> supportedEngines.values().stream().anyMatch { e -> f.name.contains(e) } } .map { f -> findTestEngine(f) } .flatMap { o -> o.isPresent() ? Stream.of(o.get()) : Stream.empty() } - // We take into account included/excluded engines (but only known ones) - def included = t.options.includeEngines - if (included) { - engines = engines.filter { e -> supportedEngines.get(e) == null || included.contains(supportedEngines.get(e)) } + // We take into account included/excluded engines (but only known ones) + def included = t.options.includeEngines + if (included) { + engines = engines.filter { e -> supportedEngines.get(e) == null || included.contains(supportedEngines.get(e)) } + } + def excluded = t.options.excludeEngines + if (excluded) { + engines = engines.filter { e -> supportedEngines.get(e) == null || !excluded.contains(supportedEngines.get(e)) } + } + return engines.collect(Collectors.toSet()) + } catch (Exception e) { + logger.warn("Could not detect test engines", e) } - def excluded = t.options.excludeEngines - if (excluded) { - engines = engines.filter { e -> supportedEngines.get(e) == null || !excluded.contains(supportedEngines.get(e)) } - } - return engines.collect(Collectors.toSet()) - } catch (Exception e) { - gradle.rootProject.logger.warn("Could not detect test engines", e) + return Collections.emptySet() } - return false -} -Optional findTestEngine(File jar) { - try (def jarFile = new JarFile(jar)) { - return Optional.ofNullable(jarFile.getEntry('META-INF/services/org.junit.platform.engine.TestEngine')) + private Optional findTestEngine(File jar) { + try (def jarFile = new JarFile(jar)) { + return Optional.ofNullable(jarFile.getEntry('META-INF/services/org.junit.platform.engine.TestEngine')) .map { e -> jarFile.getInputStream(e).withCloseable { it.getText(StandardCharsets.UTF_8.name()).trim() } } + } } -} \ No newline at end of file +} diff --git a/build-data-capturing-gradle-samples/capture-test-pts-support/gradle-test-pts-support.gradle.kts b/build-data-capturing-gradle-samples/capture-test-pts-support/gradle-test-pts-support.gradle.kts new file mode 100644 index 000000000..f4ffdb09d --- /dev/null +++ b/build-data-capturing-gradle-samples/capture-test-pts-support/gradle-test-pts-support.gradle.kts @@ -0,0 +1,88 @@ +import com.gradle.enterprise.gradleplugin.GradleEnterpriseExtension +import com.gradle.scan.plugin.BuildScanExtension +import java.nio.charset.StandardCharsets +import java.util.Collections +import java.util.Optional +import java.util.jar.JarFile +import java.util.stream.Stream +import java.util.stream.Collectors +import groovy.transform.Field + +/** + * This Gradle script captures Predictive Test Selection and Test Distribution compatibility for each Test task, + * adding a flag as custom value. + */ + +project.extensions.configure() { + buildScan { + val api = buildScan + val capture = Capture(gradle.rootProject.logger) + allprojects { + tasks.withType().configureEach { + doFirst { + capture.capturePts(this as Test, api) + } + } + } + } +} + +class Capture(val logger: Logger) { + val supportedEngines: Map = mapOf( + "org.junit.support.testng.engine.TestNGTestEngine" to "testng", + "org.junit.jupiter.engine.JupiterTestEngine" to "junit-jupiter", + "org.junit.vintage.engine.VintageTestEngine" to "junit-vintage", + "org.spockframework.runtime.SpockEngine" to "spock", + "net.jqwik.engine.JqwikTestEngine" to "jqwik", + "com.tngtech.archunit.junit.ArchUnitTestEngine" to "archunit", + "co.helmethair.scalatest.ScalatestEngine" to "scalatest" + ) + + fun capturePts(t: Test, api: BuildScanExtension): Unit { + if (t.getTestFramework()::class.java.name == "org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestFramework") { + val engines = testEngines(t) + api.value("${t.identityPath}#engines", "${engines}") + if (!engines.isEmpty() && engines.stream().allMatch { e -> supportedEngines.containsKey(e) }) { + api.value("${t.identityPath}#pts", "SUPPORTED") + } else { + api.value("${t.identityPath}#pts", "ENGINES_NOT_ALL_SUPPORTED") + } + } else { + api.value("${t.identityPath}#pts", "NO_JUNIT_PLATFORM") + } + } + + private fun testEngines(t: Test): Set { + try { + var engines = t.classpath.files.stream().filter { f -> f.name.endsWith(".jar") } + .filter { f -> supportedEngines.values.stream().anyMatch { e -> f.name.contains(e) } } + .map { f -> findTestEngine(f) } + .flatMap { o -> if (o.isPresent()) Stream.of(o.get()) else Stream.empty() } + + // We take into account included/excluded engines (but only known ones) + val included = (t.options as JUnitPlatformOptions).includeEngines + if (!included.isEmpty()) { + engines = + engines.filter { e -> supportedEngines.get(e) == null || included.contains(supportedEngines.get(e)) } + } + val excluded = (t.options as JUnitPlatformOptions).excludeEngines + if (!excluded.isEmpty()) { + engines = + engines.filter { e -> supportedEngines.get(e) == null || !excluded.contains(supportedEngines.get(e)) } + } + return engines.collect(Collectors.toSet()) + } catch (e: Exception) { + logger.warn("Could not detect test engines", e) + } + return Collections.emptySet() + } + + private fun findTestEngine(jar: File): Optional { + JarFile(jar).use { j -> + return Optional.ofNullable(j.getEntry("META-INF/services/org.junit.platform.engine.TestEngine")) + .map { e -> + j.getInputStream(e).bufferedReader().use { b -> b.readText().trim() } + } + } + } +} diff --git a/build-data-capturing-gradle-samples/capture-thermal-throttling/gradle-thermal-throttling.gradle.kts b/build-data-capturing-gradle-samples/capture-thermal-throttling/gradle-thermal-throttling.gradle.kts new file mode 100644 index 000000000..eca253aab --- /dev/null +++ b/build-data-capturing-gradle-samples/capture-thermal-throttling/gradle-thermal-throttling.gradle.kts @@ -0,0 +1,142 @@ +import org.gradle.api.internal.project.ProjectInternal +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters +import com.gradle.enterprise.gradleplugin.GradleEnterpriseExtension +import org.gradle.internal.os.OperatingSystem +import com.gradle.scan.plugin.BuildScanExtension + +import java.nio.charset.Charset +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executors +import java.util.concurrent.ExecutorService +import java.util.concurrent.TimeUnit +import java.util.function.Function +import java.util.Queue + +import org.gradle.tooling.events.OperationCompletionListener +import org.gradle.tooling.events.FinishEvent + +/** + * This Gradle script captures the thermal throttling level and adds it as a tag. + * Some parameters can be tweaked according to your build: + * - SAMPLING_INTERVAL_IN_SECONDS, frequency at which the capture command is run + * - THROTLLING_LEVEL, list of throttling levels and value ranges (to be compared with captured throttling average value) + * + * WARNINGS: + * - This is supported on MacOS only. + */ + +project.extensions.configure() { + buildScan { + val api = buildScan + if (OperatingSystem.current().isMacOsX()) { + // Register Thermal throttling service + val throttlingServiceProvider = + gradle.sharedServices.registerIfAbsent("thermalThrottling", ThermalThrottlingService::class.java) {} + // start service + (project as ProjectInternal).services.get(BuildEventsListenerRegistry::class.java).onTaskCompletion(throttlingServiceProvider) + + buildScan.buildFinished { + throttlingServiceProvider.get().use { s -> + // process results on build completion + s.processResults(api) + } + } + } else { + println("INFO - Not running on MacOS - no thermal throttling data will be captured") + } + } +} + +// Thermal Throttling service implementation +// OperationCompletionListener is a marker interface to get the service instantiated when configuration cache kicks in +abstract class ThermalThrottlingService : BuildService, AutoCloseable, OperationCompletionListener { + + /** + * Sampling interval can be adjusted according to total build time. + */ + val SAMPLING_INTERVAL_IN_SECONDS = 5L + + /** + * Throttling levels by throttling average value. + */ + val THROTTLING_LEVEL = mapOf("THROTTLING_HIGH" to 0..40, "THROTTLING_MEIUM" to 40..80, "THROTTLING_LOW" to 80..100) + + val COMMAND_ARGS = listOf("pmset", "-g", "therm") + val COMMAND_OUTPUT_PARSING_PATTERN = Regex("""CPU_Speed_Limit\s+=\s+""") + + val scheduler: ExecutorService + val samples: Queue + + init { + scheduler = Executors.newScheduledThreadPool(1) + samples = ConcurrentLinkedQueue() + scheduler.scheduleAtFixedRate(ProcessRunner(COMMAND_ARGS, this::processCommandOutput), 0, SAMPLING_INTERVAL_IN_SECONDS, TimeUnit.SECONDS) + } + + override fun close() { + scheduler.shutdownNow() + } + + override fun onFinish(ignored: FinishEvent){ + // ignored + } + + private fun processCommandOutput(commandOutput: String): Unit { + val tokens = commandOutput.split(COMMAND_OUTPUT_PARSING_PATTERN) + if (tokens != null && tokens.size > 0) { + val sample = tokens[1].toIntOrNull() + if (sample != null) { + samples.offer(sample) + } + } + } + + fun processResults(api: BuildScanExtension): Unit { + if (!samples.isEmpty()) { + val average = samples.stream().mapToInt{ it as Int }.average().getAsDouble() + if (average < 100.0) { + api.value("CPU Thermal Throttling Average", String.format("%.2f", average) + "%") + THROTTLING_LEVEL.entries.stream().filter { e -> + e.value.start <= average && average < e.value.endInclusive + }.forEach { e -> api.tag(e.key) } + } + } + } +} + +// Process Runner implementation +class ProcessRunner : Runnable { + + val args: List + val outputProcessor: Function + + constructor(args: List, outputProcessor: Function) { + this.args = args + this.outputProcessor = outputProcessor + } + + override fun run() { + val stdout = execAndGetStdout(args) + if (stdout != null) { + outputProcessor.apply(stdout) + } + } + + private fun execAndGetStdout(args: List): String? { + val process = ProcessBuilder(args).start() + try { + val finished = process.waitFor(10, TimeUnit.SECONDS) + val standardText = process.inputStream.bufferedReader().readText() + val ignore = process.errorStream.bufferedReader().readText() + return if (finished && process.exitValue() == 0) trimAtEnd(standardText) else null + } finally { + process.destroyForcibly() + } + } + + private fun trimAtEnd(str: String): String { + return ("x" + str).trim().substring(1) + } + +}