diff --git a/scripts/BUILD.bazel b/scripts/BUILD.bazel index 23587533fa0..a895764d5f2 100644 --- a/scripts/BUILD.bazel +++ b/scripts/BUILD.bazel @@ -246,6 +246,16 @@ kt_jvm_binary( ], ) +kt_jvm_binary( + name = "run_coverage", + testonly = True, + data = TEST_FILE_EXEMPTION_ASSETS, + main_class = "org.oppia.android.scripts.coverage.RunCoverageKt", + runtime_deps = [ + "//scripts/src/java/org/oppia/android/scripts/coverage:run_coverage_lib", + ], +) + # Note that this is intentionally not test-only since it's used by the app build pipeline. Also, # this apparently needs to be a java_binary to set up runfiles correctly when executed within a # Starlark rule as a tool. diff --git a/scripts/src/java/org/oppia/android/scripts/coverage/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/coverage/BUILD.bazel index 53f09dbb98c..7aba008aaa6 100644 --- a/scripts/src/java/org/oppia/android/scripts/coverage/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/coverage/BUILD.bazel @@ -4,6 +4,20 @@ Libraries corresponding to developer scripts that obtain coverage data for test load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_library") +kt_jvm_library( + name = "run_coverage_lib", + testonly = True, + srcs = [ + "RunCoverage.kt", + ], + visibility = ["//scripts:oppia_script_binary_visibility"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common:bazel_client", + "//scripts/src/java/org/oppia/android/scripts/proto:script_exemptions_java_proto", + "//scripts/src/java/org/oppia/android/scripts/coverage:run_coverage_for_test_target_lib", + ], +) + kt_jvm_library( name = "run_coverage_for_test_target_lib", testonly = True, @@ -13,7 +27,6 @@ kt_jvm_library( visibility = ["//scripts:oppia_script_binary_visibility"], deps = [ "//scripts/src/java/org/oppia/android/scripts/common:bazel_client", - "//scripts/src/java/org/oppia/android/scripts/common:git_client", "//scripts/src/java/org/oppia/android/scripts/coverage:coverage_runner", ], ) diff --git a/scripts/src/java/org/oppia/android/scripts/coverage/RunCoverage.kt b/scripts/src/java/org/oppia/android/scripts/coverage/RunCoverage.kt new file mode 100644 index 00000000000..fb46019d694 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/coverage/RunCoverage.kt @@ -0,0 +1,147 @@ +package org.oppia.android.scripts.coverage + +import org.oppia.android.scripts.common.BazelClient +import org.oppia.android.scripts.common.CommandExecutor +import org.oppia.android.scripts.common.CommandExecutorImpl +import org.oppia.android.scripts.common.ScriptBackgroundCoroutineDispatcher +import org.oppia.android.scripts.proto.TestFileExemptions +import java.util.concurrent.TimeUnit +import java.io.File +import java.io.FileInputStream + +/** + * Entry point function for running coverage analysis for a source file. + * + * Usage: + * bazel run //scripts:run_coverage_for_test_target -- + * + * Arguments: + * - path_to_root: directory path to the root of the Oppia Android repository. + * - relative_path_to_file: the relative path to the file to analyse coverage + * + * Example: + * bazel run //scripts:run_coverage -- $(pwd) + * utility/src/main/java/org/oppia/android/util/parser/math/MathModel.kt + * Example with custom process timeout: + * bazel run //scripts:run_coverage -- $(pwd) + * utility/src/main/java/org/oppia/android/util/parser/math/MathModel.kt processTimeout=10 + * + */ +fun main(vararg args: String) { + val repoRoot = args[0] + val filePath = args[1] + + ScriptBackgroundCoroutineDispatcher().use { scriptBgDispatcher -> + val processTimeout: Long = args.find { it.startsWith("processTimeout=") } + ?.substringAfter("=") + ?.toLongOrNull() ?: 5 + + val commandExecutor: CommandExecutor = CommandExecutorImpl( + scriptBgDispatcher, processTimeout = processTimeout, processTimeoutUnit = TimeUnit.MINUTES + ) + + RunCoverage(repoRoot, filePath, commandExecutor, scriptBgDispatcher).execute() + } +} + +/** + * Class responsible for executing coverage on a given file. + * + * @param repoRoot the root directory of the repository + * @param filePath the relative path to the file to analyse coverage + * @param commandExecutor Executes the specified command in the specified working directory + * @param scriptBgDispatcher the [ScriptBackgroundCoroutineDispatcher] to be used for running the coverage command + */ +class RunCoverage( + private val repoRoot: String, + private val filePath: String, + private val commandExecutor: CommandExecutor, + private val scriptBgDispatcher: ScriptBackgroundCoroutineDispatcher +) { + private val bazelClient by lazy { BazelClient(File(repoRoot), commandExecutor) } + + private val rootDirectory = File(repoRoot).absoluteFile + private val testFileExemptionTextProto = "scripts/assets/test_file_exemptions" + var coverageDataList = mutableListOf() +// var covdat: String = "" + + /** + * Executes coverage analysis for the specified file. + * + * Loads test file exemptions and checks if the specified file is exempted. If exempted, + * prints a message indicating no coverage analysis is performed. Otherwise, initializes + * a Bazel client, finds potential test file paths, retrieves Bazel targets, and initiates + * coverage analysis for each test target found. + */ + fun execute(): List { + val testFileExemptionList = loadTestFileExemptionsProto(testFileExemptionTextProto) + .getExemptedFilePathList() + + val isExempted = testFileExemptionList.contains(filePath) + if (isExempted) { + println("This file is exempted from having a test file. Hence No coverage!") + return emptyList() + } + + val testFilePaths = findTestFile(repoRoot, filePath) + val testTargets = bazelClient.retrieveBazelTargets(testFilePaths) + + for (testTarget in testTargets) { + val coverageData = RunCoverageForTestTarget( + rootDirectory, + testTarget.substringBeforeLast(".kt"), + commandExecutor, + scriptBgDispatcher + ).runCoverage()!! + coverageDataList.add(coverageData) + } + return coverageDataList + } + + fun findTestFile(repoRoot: String, filePath: String): List { + val file = File(filePath) + val parts = file.parent.split(File.separator) + val testFiles = mutableListOf() + + if (parts.isNotEmpty() && parts[0] == "scripts") { + val testFilePath = filePath.replace("/java/", "/javatests/").replace(".kt", "Test.kt") + if (File(repoRoot, testFilePath).exists()) { + testFiles.add(testFilePath) + } + } else if (parts.isNotEmpty() && parts[0] == "app") { + val sharedTestFilePath = filePath.replace("/main/", "/sharedTest/").replace(".kt", "Test.kt") + val testFilePath = filePath.replace("/main/", "/test/").replace(".kt", "Test.kt") + val localTestFilePath = filePath.replace("/main/", "/test/").replace(".kt", "LocalTest.kt") + + if (File(repoRoot, sharedTestFilePath).exists()) { + testFiles.add(sharedTestFilePath) + } + if (File(repoRoot, testFilePath).exists()) { + testFiles.add(testFilePath) + } + if (File(repoRoot, localTestFilePath).exists()) { + testFiles.add(localTestFilePath) + } + } else { + val defaultTestFilePath = filePath.replace("/main/", "/test/").replace(".kt", "Test.kt") + if (File(repoRoot, defaultTestFilePath).exists()) { + testFiles.add(defaultTestFilePath) + } + } + return testFiles + } + + private fun loadTestFileExemptionsProto(testFileExemptiontextProto: String): TestFileExemptions { + val protoBinaryFile = File("$testFileExemptiontextProto.pb") + val builder = TestFileExemptions.getDefaultInstance().newBuilderForType() + + // This cast is type-safe since proto guarantees type consistency from mergeFrom(), + // and this method is bounded by the generic type T. + @Suppress("UNCHECKED_CAST") + val protoObj: TestFileExemptions = + FileInputStream(protoBinaryFile).use { + builder.mergeFrom(it) + }.build() as TestFileExemptions + return protoObj + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt b/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt index 71ee2eb542a..83d802f3b89 100644 --- a/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt +++ b/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt @@ -172,7 +172,7 @@ class TestBazelWorkspace(private val temporaryRootFolder: TemporaryFolder) { testBuildFile.appendText( """ kt_jvm_test( - name = "test", + name = "$testName", srcs = ["$testName.kt"], deps = [ "//$sourceSubpackage:${filename.lowercase()}", diff --git a/scripts/src/javatests/org/oppia/android/scripts/common/BazelClientTest.kt b/scripts/src/javatests/org/oppia/android/scripts/common/BazelClientTest.kt index 4bbde885d47..bf99a7e3577 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/common/BazelClientTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/common/BazelClientTest.kt @@ -512,7 +512,7 @@ class BazelClientTest { subpackage = "coverage" ) - val result = bazelClient.runCoverageForTestTarget("//coverage/test/java/com/example:test") + val result = bazelClient.runCoverageForTestTarget("//coverage/test/java/com/example:TwoSumTest") // Check if ByteArray is returned from executing coverage command assertThat(result).isInstanceOf(ByteArray::class.java) diff --git a/scripts/src/javatests/org/oppia/android/scripts/coverage/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/coverage/BUILD.bazel index cb20129dd61..49226a9e4f7 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/coverage/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/coverage/BUILD.bazel @@ -27,3 +27,16 @@ kt_jvm_test( "//third_party:org_jetbrains_kotlin_kotlin-test-junit", ], ) + +kt_jvm_test( + name = "RunCoverageTest", + srcs = ["RunCoverageTest.kt"], + deps = [ + "//scripts:test_file_check_assets", + "//scripts/src/java/org/oppia/android/scripts/coverage:run_coverage_lib", + "//scripts/src/java/org/oppia/android/scripts/testing:test_bazel_workspace", + "//testing:assertion_helpers", + "//third_party:com_google_truth_truth", + "//third_party:org_jetbrains_kotlin_kotlin-test-junit", + ], +) diff --git a/scripts/src/javatests/org/oppia/android/scripts/coverage/CoverageRunnerTest.kt b/scripts/src/javatests/org/oppia/android/scripts/coverage/CoverageRunnerTest.kt index 6892535c598..7560e0ad158 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/coverage/CoverageRunnerTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/coverage/CoverageRunnerTest.kt @@ -36,7 +36,7 @@ class CoverageRunnerTest { } @Test - fun testRunCoverage_emptyDirectory_throwsException() { + fun testCoverageRunner_emptyDirectory_throwsException() { val exception = assertThrows() { coverageRunner.getCoverage(bazelTestTarget) } @@ -45,7 +45,7 @@ class CoverageRunnerTest { } @Test - fun testRunCoverage_invalidTestTarget_throwsException() { + fun testCoverageRunner_invalidTestTarget_throwsException() { testBazelWorkspace.initEmptyWorkspace() val exception = assertThrows() { @@ -57,7 +57,7 @@ class CoverageRunnerTest { } @Test - fun testRunCoverage_validSampleTestTarget_returnsCoverageData() { + fun testCoverageRunner_validSampleTestTarget_returnsCoverageData() { testBazelWorkspace.initEmptyWorkspace() val sourceContent = @@ -103,7 +103,7 @@ class CoverageRunnerTest { subpackage = "coverage" ) - val result = coverageRunner.getCoverage("//coverage/test/java/com/example:test") + val result = coverageRunner.getCoverage("//coverage/test/java/com/example:TwoSumTest") val expectedResult = "SF:coverage/main/java/com/example/TwoSum.kt\n" + "FN:7,com/example/TwoSum${'$'}Companion::sumNumbers (II)Ljava/lang/Object;\n" + diff --git a/scripts/src/javatests/org/oppia/android/scripts/coverage/RunCoverageForTestTargetTest.kt b/scripts/src/javatests/org/oppia/android/scripts/coverage/RunCoverageForTestTargetTest.kt index df2390a5efc..4210e54accf 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/coverage/RunCoverageForTestTargetTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/coverage/RunCoverageForTestTargetTest.kt @@ -114,7 +114,7 @@ class RunCoverageForTestTargetTest { val result = RunCoverageForTestTarget( tempFolder.root, - "//coverage/test/java/com/example:test", + "//coverage/test/java/com/example:TwoSumTest", longCommandExecutor, scriptBgDispatcher ).runCoverage() diff --git a/scripts/src/javatests/org/oppia/android/scripts/coverage/RunCoverageTest.kt b/scripts/src/javatests/org/oppia/android/scripts/coverage/RunCoverageTest.kt new file mode 100644 index 00000000000..506a9bcb1c1 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/coverage/RunCoverageTest.kt @@ -0,0 +1,256 @@ +package org.oppia.android.scripts.coverage + +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.oppia.android.scripts.common.CommandExecutorImpl +import org.oppia.android.scripts.common.ScriptBackgroundCoroutineDispatcher +import org.oppia.android.scripts.testing.TestBazelWorkspace +import org.oppia.android.testing.assertThrows +import java.util.concurrent.TimeUnit +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.PrintStream + +/** Tests for [RunCoverage]. */ +class RunCoverageTest { + @field:[Rule JvmField] val tempFolder = TemporaryFolder() + + private val outContent: ByteArrayOutputStream = ByteArrayOutputStream() + private val originalOut: PrintStream = System.out + + private val scriptBgDispatcher by lazy { ScriptBackgroundCoroutineDispatcher() } + private val commandExecutor by lazy { CommandExecutorImpl(scriptBgDispatcher) } + private val longCommandExecutor by lazy { initializeCommandExecutorWithLongProcessWaitTime() } + + private lateinit var testBazelWorkspace: TestBazelWorkspace + private lateinit var sampleFilePath: String + + @Before + fun setUp() { + sampleFilePath = "/path/to/Sample.kt" + testBazelWorkspace = TestBazelWorkspace(tempFolder) + System.setOut(PrintStream(outContent)) + } + + @After + fun tearDown() { + System.setOut(originalOut) + scriptBgDispatcher.close() + } + + @Test + fun testRunCoverage_testFileExempted_noCoverage() { + val exemptedFilePath = "app/src/main/java/org/oppia/android/app/activity/ActivityComponent.kt" + + RunCoverage( + "${tempFolder.root}", + exemptedFilePath, + commandExecutor, + scriptBgDispatcher).execute() + + assertThat(outContent.toString()).isEqualTo("This file is exempted from having a test file. Hence No coverage!\n") + } + + @Test + fun testRunCoverage_ScriptsPath_returnTestFilePath() { + val rootFolderPath = tempFolder.root.absolutePath + val expectedTestFilePath = "scripts/javatests/sample/ExampleTest.kt" + val file = File(rootFolderPath, expectedTestFilePath) + + file.parentFile?.mkdirs() + file.createNewFile() + + val expectedTestFilePaths = listOf(expectedTestFilePath) + + val result = RunCoverage( + rootFolderPath, + sampleFilePath, + commandExecutor, + scriptBgDispatcher + ).findTestFile(rootFolderPath, "scripts/java/sample/Example.kt") + + assertEquals(expectedTestFilePaths, result) + } + + @Test + fun testRunCoverage_AppPath_returnSharedTestFilePath() { + val rootFolderPath = tempFolder.root.absolutePath + val expectedSharedTestFilePath = "app/sharedTest/sample/ExampleTest.kt" + val file = File(rootFolderPath, expectedSharedTestFilePath) + + file.parentFile?.mkdirs() + file.createNewFile() + + val expectedSharedTestFilePaths = listOf(expectedSharedTestFilePath) + + val result = RunCoverage( + rootFolderPath, + sampleFilePath, + commandExecutor, + scriptBgDispatcher + ).findTestFile(rootFolderPath, "app/main/sample/Example.kt") + + assertEquals(expectedSharedTestFilePaths, result) + } + + @Test + fun testRunCoverage_AppPath_returnLocalTestFilePath() { + val rootFolderPath = tempFolder.root.absolutePath + val expectedLocalTestFilePath = "app/test/sample/ExampleTest.kt" + val file = File(rootFolderPath, expectedLocalTestFilePath) + + file.parentFile?.mkdirs() + file.createNewFile() + + val expectedLocalTestFilePaths = listOf(expectedLocalTestFilePath) + + val result = RunCoverage( + rootFolderPath, + sampleFilePath, + commandExecutor, + scriptBgDispatcher + ).findTestFile(rootFolderPath, "app/main/sample/Example.kt") + + assertEquals(expectedLocalTestFilePaths, result) + } + + @Test + fun testRunCoverage_AppPath_returnSharedAndLocalTestFilePath() { + val rootFolderPath = tempFolder.root.absolutePath + val expectedLocalTestFilePath = "app/test/sample/ExampleTest.kt" + val expectedSharedTestFilePath = "app/sharedTest/sample/ExampleTest.kt" + + val sharedFile = File(rootFolderPath, expectedSharedTestFilePath) + sharedFile.parentFile?.mkdirs() + sharedFile.createNewFile() + + val localFile = File(rootFolderPath, expectedLocalTestFilePath) + localFile.parentFile?.mkdirs() + localFile.createNewFile() + + val expectedLocalAndSharedTestFilePaths = listOf( + expectedSharedTestFilePath, + expectedLocalTestFilePath + ) + + val result = RunCoverage( + rootFolderPath, + sampleFilePath, + commandExecutor, + scriptBgDispatcher + ).findTestFile(rootFolderPath, "app/main/sample/Example.kt") + + assertEquals(expectedLocalAndSharedTestFilePaths, result) + } + + @Test + fun testRunCoverage_AppPath_returnDefaultTestFilePath() { + val rootFolderPath = tempFolder.root.absolutePath + val expectedLocalTestFilePath = "util/test/sample/ExampleTest.kt" + val file = File(rootFolderPath, expectedLocalTestFilePath) + + file.parentFile?.mkdirs() + file.createNewFile() + + val expectedLocalTestFilePaths = listOf(expectedLocalTestFilePath) + + val result = RunCoverage( + rootFolderPath, + sampleFilePath, + commandExecutor, + scriptBgDispatcher + ).findTestFile(rootFolderPath, "util/main/sample/Example.kt") + + assertEquals(expectedLocalTestFilePaths, result) + } + + @Test + fun testRunCoverage_validSampleTestFile_returnsCoverageData() { + testBazelWorkspace.initEmptyWorkspace() + + val sourceContent = + """ + package com.example + + class TwoSum { + + companion object { + fun sumNumbers(a: Int, b: Int): Any { + return if (a ==0 && b == 0) { + "Both numbers are zero" + } else { + a + b + } + } + } + } + """.trimIndent() + + val testContent = + """ + package com.example + + import org.junit.Assert.assertEquals + import org.junit.Test + + class TwoSumTest { + + @Test + fun testSumNumbers() { + assertEquals(TwoSum.sumNumbers(0, 1), 1) + assertEquals(TwoSum.sumNumbers(3, 4), 7) + assertEquals(TwoSum.sumNumbers(0, 0), "Both numbers are zero") + } + } + """.trimIndent() + + testBazelWorkspace.addSourceAndTestFileWithContent( + filename = "TwoSum", + sourceContent = sourceContent, + testContent = testContent, + subpackage = "coverage" + ) + + val result = RunCoverage( + "${tempFolder.root}", + "coverage/main/java/com/example/TwoSum.kt", + longCommandExecutor, + scriptBgDispatcher).execute() + + val expectedResultList = listOf( + "SF:coverage/main/java/com/example/TwoSum.kt\n"+ + "FN:7,com/example/TwoSum${'$'}Companion::sumNumbers (II)Ljava/lang/Object;\n"+ + "FN:3,com/example/TwoSum:: ()V\n"+ + "FNDA:1,com/example/TwoSum${'$'}Companion::sumNumbers (II)Ljava/lang/Object;\n"+ + "FNDA:0,com/example/TwoSum:: ()V\n"+ + "FNF:2\n"+ + "FNH:1\n"+ + "BRDA:7,0,0,1\n"+ + "BRDA:7,0,1,1\n"+ + "BRDA:7,0,2,1\n"+ + "BRDA:7,0,3,1\n"+ + "BRF:4\n"+ + "BRH:4\n"+ + "DA:3,0\n"+ + "DA:7,1\n"+ + "DA:8,1\n"+ + "DA:10,1\n"+ + "LH:3\n"+ + "LF:4\n"+ + "end_of_record\n" + ) + + assertThat(result).isEqualTo(expectedResultList) + } + + private fun initializeCommandExecutorWithLongProcessWaitTime(): CommandExecutorImpl { + return CommandExecutorImpl( + scriptBgDispatcher, processTimeout = 5, processTimeoutUnit = TimeUnit.MINUTES + ) + } +}