diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 6b650843df1..3a26c691371 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -33,8 +33,8 @@ jobs: # https://unix.stackexchange.com/a/338124 for reference on creating a JSON-friendly # comma-separated list of test targets for the matrix. run: | - bash ./scripts/compute_affected_tests.sh ./affected_targets.txt - TEST_TARGET_LIST=$(cat ./affected_targets.txt | sed 's/^\|$/"/g' | paste -sd, -) + bazel run //scripts:compute_affected_tests -- $(pwd) $(pwd)/affected_targets.log origin/develop + TEST_TARGET_LIST=$(cat ./affected_targets.log | sed 's/^\|$/"/g' | paste -sd, -) echo "Affected tests (note that this might be all tests if on the develop branch): $TEST_TARGET_LIST" echo "::set-output name=matrix::{\"test-target\":[$TEST_TARGET_LIST]}" if [[ ! -z "$TEST_TARGET_LIST" ]]; then diff --git a/scripts/BUILD.bazel b/scripts/BUILD.bazel new file mode 100644 index 00000000000..924617c1f69 --- /dev/null +++ b/scripts/BUILD.bazel @@ -0,0 +1,44 @@ +""" +Kotlin-based scripts to help developers or perform continuous integration tasks. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_binary") + +# Visibility for libraries that should be accessible to script tests. +package_group( + name = "oppia_script_test_visibility", + packages = [ + "//scripts/src/javatests/...", + ], +) + +# Visibility for libraries that have binaries defined at this package & tests. +package_group( + name = "oppia_script_binary_visibility", + includes = [ + ":oppia_script_test_visibility", + ], + packages = [ + "//scripts", + ], +) + +# Visibility for libraries that should be accessible to other script packages & tests. +package_group( + name = "oppia_script_library_visibility", + includes = [ + ":oppia_script_test_visibility", + ], + packages = [ + "//scripts/src/java/...", + ], +) + +kt_jvm_binary( + name = "compute_affected_tests", + testonly = True, + main_class = "org.oppia.android.scripts.ci.ComputeAffectedTestsKt", + runtime_deps = [ + "//scripts/src/java/org/oppia/android/scripts/ci:compute_affected_tests_lib", + ], +) diff --git a/scripts/buildifier_lint_check.sh b/scripts/buildifier_lint_check.sh index 1bfd2dfb7e0..47d0d91a248 100644 --- a/scripts/buildifier_lint_check.sh +++ b/scripts/buildifier_lint_check.sh @@ -14,7 +14,7 @@ else buildifier_file_path="$github_actions_path/oppia-android-tools/buildifier" fi -$buildifier_file_path --lint=warn --mode=check --warnings=-native-android,+out-of-order-load,+unsorted-dict-items -r app data domain model testing utility third_party tools BUILD.bazel WORKSPACE oppia_android_test.bzl +$buildifier_file_path --lint=warn --mode=check --warnings=-native-android,+out-of-order-load,+unsorted-dict-items -r app data domain model testing utility third_party tools scripts BUILD.bazel WORKSPACE oppia_android_test.bzl status=$? diff --git a/scripts/checkstyle_lint_check.sh b/scripts/checkstyle_lint_check.sh index cc623af333d..668be1ba670 100644 --- a/scripts/checkstyle_lint_check.sh +++ b/scripts/checkstyle_lint_check.sh @@ -14,7 +14,7 @@ else jar_file_path="$github_actions_path/oppia-android-tools/checkstyle-8.37-all.jar" fi -lint_results=$(java -jar $jar_file_path -c /google_checks.xml app/src/ data/src/ domain/src/ utility/src/ testing/src/ 2>&1) +lint_results=$(java -jar $jar_file_path -c /google_checks.xml app/src/ data/src/ domain/src/ utility/src/ testing/src/ scripts/src/ 2>&1) lint_command_result=$? diff --git a/scripts/compute_affected_tests.sh b/scripts/compute_affected_tests.sh deleted file mode 100755 index a45b0ed7fd4..00000000000 --- a/scripts/compute_affected_tests.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash - -# Compute the list of tests that are affected by changes on this branch (including in the working -# directory). This script is useful to verify that only the tests that could break due to a change -# are actually verified as passing. Note that this script does not actually run any of the tests. -# -# Usage: -# bash scripts/compute_affected_tests.sh -# -# This script is based on https://github.com/bazelbuild/bazel/blob/d96e1cd/scripts/ci/ci.sh and the -# documentation at https://docs.bazel.build/versions/master/query.html. Note that this script will -# automatically list all tests if it's run on the develop branch (the idea being that test -# considerations on the develop branch should always consider all targets). - -DEST_FILE=$1 - -# Reference: https://stackoverflow.com/a/6245587. -current_branch=$(git rev-parse --abbrev-ref HEAD) - -printf "Current branch: $current_branch\n" - -if [[ "$current_branch" != "develop" ]]; then - # Compute all files that have been changed on this branch. https://stackoverflow.com/a/9294015 for - # constructing the arrays. - commit_range=HEAD..$(git merge-base origin/develop HEAD) - printf "Commit range: $commit_range\n\n" - changed_committed_files=($(git diff --name-only $commit_range)) - # See https://stackoverflow.com/a/26304373 for reference on printing Bash arrays. - (IFS=",$IFS"; printf "Committed changed files to consider: %s\n\n" "${changed_committed_files[*]}"; IFS="${IFS:1}") - changed_staged_files=($(git diff --name-only --cached)) - (IFS=",$IFS"; printf "Staged changes files to consider: %s\n\n" "${changed_staged_files[*]}"; IFS="${IFS:1}") - changed_unstaged_files=($(git diff --name-only)) - (IFS=",$IFS"; printf "Changed unstaged files to consider: %s\n\n" "${changed_unstaged_files[*]}"; IFS="${IFS:1}") - # See https://stackoverflow.com/a/35484355 for how this works. - changed_untracked_files=($(git ls-files --others --exclude-standard)) - (IFS=",$IFS"; printf "Changed untracked files to consider: %s\n\n" "${changed_untracked_files[*]}"; IFS="${IFS:1}") - - changed_files_with_potential_duplicates=( - "${changed_committed_files[@]}" - "${changed_staged_files[@]}" - "${changed_unstaged_files[@]}" - "${changed_untracked_files[@]}" - ) - - # De-duplicate files: https://unix.stackexchange.com/q/377812. - changed_files=($(printf "%s\n" "${changed_files_with_potential_duplicates[@]}" | sort -u)) - (IFS=",$IFS"; printf "All files to consider: %s\n\n" "${changed_files[*]}"; IFS="${IFS:1}") - - # Filter all of the source files among those that are actually included in Bazel builds. - changed_bazel_files=() - for changed_file in "${changed_files[@]}"; do - changed_bazel_files+=($(bazel query --noshow_progress $changed_file 2> /dev/null)) - done - - (IFS=",$IFS"; printf "Changed Bazel files: %s\n\n" "${changed_bazel_files[*]}"; IFS="${IFS:1}") - - # Compute the list of affected tests based on source files. - source_affected_targets="$(bazel query --noshow_progress --universe_scope=//... --order_output=no "kind(test, allrdeps(set(${changed_bazel_files[@]})))" 2>/dev/null)" - (IFS=",$IFS"; printf "Affected targets: %s\n\n" "${source_affected_targets[*]}"; IFS="${IFS:1}") - - # Compute the list of files to consider for BUILD-level changes (this uses the base file list as a - # reference since Bazel's query won't find matching targets for utility bzl files that can still - # affect the build). https://stackoverflow.com/a/44107086 for reference on changing case matching. - shopt -s nocasematch - changed_bazel_support_files=() - for changed_file in "${changed_files[@]}"; do - if [[ "$changed_file" =~ ^.+?\.bazel$ ]] || [[ "$changed_file" =~ ^.+?\.bzl$ ]] || [[ "$changed_file" == "WORKSPACE" ]]; then - changed_bazel_support_files+=("$changed_file") - fi - done - shopt -u nocasematch - (IFS=",$IFS"; printf "Changed Bazel support files: %s\n\n" "${changed_bazel_support_files[*]}"; IFS="${IFS:1}") - - printf "Clear Bazel cache between target analyses\n\n" - bazel clean - bazel shutdown - - # Compute the list of affected tests based on BUILD/Bazel/WORKSPACE files. These are generally - # framed as: if a BUILD file changes, run all tests transitively connected to it. - # Reference for joining an array to string: https://stackoverflow.com/a/53839433. - printf -v changed_bazel_support_files_list '%s,' "${changed_bazel_support_files[@]}" - build_affected_targets=$(bazel query --noshow_progress --universe_scope=//... --order_output=no "filter('^[^@]', kind(test, allrdeps(siblings(rbuildfiles(${changed_bazel_support_files_list%,})))))" 2>/dev/null) - (IFS=",$IFS"; printf "Affected Bazel support targets: %s\n\n" "${build_affected_targets[*]}"; IFS="${IFS:1}") - - all_affected_targets_with_potential_duplicated=( - "${source_affected_targets[@]}" - "${build_affected_targets[@]}" - ) - - # Print all of the affected targets without duplicates. - printf "%s" "${all_affected_targets_with_potential_duplicated[@]}" | sort -u - printf "%s" "${all_affected_targets_with_potential_duplicated[@]}" | sort -u > $DEST_FILE -else - # Print all test targets. - bazel query --noshow_progress "kind(test, //...)" 2>/dev/null - bazel query --noshow_progress "kind(test, //...)" 1>$DEST_FILE -fi diff --git a/scripts/ktlint_lint_check.sh b/scripts/ktlint_lint_check.sh index 2fcae97e378..5045c1fd3f9 100755 --- a/scripts/ktlint_lint_check.sh +++ b/scripts/ktlint_lint_check.sh @@ -14,7 +14,7 @@ else jar_file_path="$github_actions_path/oppia-android-tools/ktlint" fi -java -jar $jar_file_path --android app/src/**/*.kt data/src/**/*.kt domain/src/**/*.kt testing/src/**/*.kt utility/src/**/*.kt +java -jar $jar_file_path --android app/src/**/*.kt data/src/**/*.kt domain/src/**/*.kt testing/src/**/*.kt utility/src/**/*.kt scripts/src/**/*.kt status=$? @@ -25,7 +25,7 @@ else echo "********************************" echo "Ktlint issue found." echo "Please fix the above issues. - You can also use the ktlint -F --android domain/src/**/*.kt utility/src/**/*.kt data/src/**/*.kt app/src/**/*.kt testing/src/**/*.kt + You can also use the java -jar $jar_file_path -F --android domain/src/**/*.kt utility/src/**/*.kt data/src/**/*.kt app/src/**/*.kt testing/src/**/*.kt scripts/src/**/*.kt command to fix the most common issues." echo "Please note, there might be a possibility where the above command will not fix the issue. In that case, you will have to fix it yourself." diff --git a/scripts/src/java/org/oppia/android/scripts/ci/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/ci/BUILD.bazel new file mode 100644 index 00000000000..5791cac87b4 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/ci/BUILD.bazel @@ -0,0 +1,18 @@ +""" +Libraries corresponding to developer scripts that help with continuous integration workflows. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_library") + +kt_jvm_library( + name = "compute_affected_tests_lib", + testonly = True, + srcs = [ + "ComputeAffectedTests.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/common:git_client", + ], +) diff --git a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt new file mode 100644 index 00000000000..31d0c552f2d --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt @@ -0,0 +1,112 @@ +package org.oppia.android.scripts.ci + +import org.oppia.android.scripts.common.BazelClient +import org.oppia.android.scripts.common.GitClient +import java.io.File +import java.util.Locale +import kotlin.system.exitProcess + +/** + * The main entrypoint for computing the list of affected test targets based on changes in the local + * Oppia Android Git repository. + * + * Usage: + * bazel run //scripts:compute_affected_tests -- \\ + * + * + * Arguments: + * - path_to_directory_root: directory path to the root of the Oppia Android repository. + * - path_to_output_file: path to the file in which the affected test targets will be printed. + * - base_develop_branch_reference: the reference to the local develop branch that should be use. + * Generally, this is 'origin/develop'. + * + * Example: + * bazel run //scripts:compute_affected_tests -- $(pwd) /tmp/affected_tests.log + */ +fun main(args: Array) { + if (args.size < 3) { + println( + "Usage: bazel run //scripts:compute_affected_tests --" + + " " + ) + exitProcess(1) + } + + val pathToRoot = args[0] + val pathToOutputFile = args[1] + val baseDevelopBranchReference = args[2] + val rootDirectory = File(pathToRoot).absoluteFile + val outputFile = File(pathToOutputFile).absoluteFile + + check(rootDirectory.isDirectory) { "Expected '$pathToRoot' to be a directory" } + check(rootDirectory.list().contains("WORKSPACE")) { + "Expected script to be run from the workspace's root directory" + } + + println("Running from directory root: $rootDirectory") + println("Saving results to file: $outputFile") + + val gitClient = GitClient(rootDirectory, baseDevelopBranchReference) + val bazelClient = BazelClient(rootDirectory) + println("Current branch: ${gitClient.currentBranch}") + println("Most recent common commit: ${gitClient.branchMergeBase}") + when (gitClient.currentBranch.toLowerCase(Locale.getDefault())) { + "develop" -> computeAffectedTargetsForDevelopBranch(bazelClient, outputFile) + else -> + computeAffectedTargetsForNonDevelopBranch(gitClient, bazelClient, rootDirectory, outputFile) + } +} + +private fun computeAffectedTargetsForDevelopBranch(bazelClient: BazelClient, outputFile: File) { + // Compute & print all test targets since this is the develop branch. + println("Computing all test targets for the develop branch") + + val allTestTargets = bazelClient.retrieveAllTestTargets() + println() + println( + "Affected test targets:" + + "\n${allTestTargets.joinToString(separator = "\n") { "- $it" }}" + ) + outputFile.printWriter().use { writer -> allTestTargets.forEach { writer.println(it) } } +} + +private fun computeAffectedTargetsForNonDevelopBranch( + gitClient: GitClient, + bazelClient: BazelClient, + rootDirectory: File, + outputFile: File +) { + // Compute the list of changed files, but exclude files which no longer exist (since bazel query + // can't handle these well). + val changedFiles = gitClient.changedFiles.filter { filepath -> + File(rootDirectory, filepath).exists() + } + println("Changed files (per Git): $changedFiles") + + val changedFileTargets = bazelClient.retrieveBazelTargets(changedFiles).toSet() + println("Changed Bazel file targets: $changedFileTargets") + + val affectedTestTargets = bazelClient.retrieveRelatedTestTargets(changedFileTargets).toSet() + println("Affected Bazel test targets: $affectedTestTargets") + + // Compute the list of Bazel files that were changed. + val changedBazelFiles = changedFiles.filter { file -> + file.endsWith(".bzl", ignoreCase = true) || + file.endsWith(".bazel", ignoreCase = true) || + file == "WORKSPACE" + } + println("Changed Bazel-specific support files: $changedBazelFiles") + + // Compute the list of affected tests based on BUILD/Bazel/WORKSPACE files. These are generally + // framed as: if a BUILD file changes, run all tests transitively connected to it. + val transitiveTestTargets = bazelClient.retrieveTransitiveTestTargets(changedBazelFiles) + println("Affected test targets due to transitive build deps: $transitiveTestTargets") + + val allAffectedTestTargets = (affectedTestTargets + transitiveTestTargets).toSet() + println() + println( + "Affected test targets:" + + "\n${allAffectedTestTargets.joinToString(separator = "\n") { "- $it" }}" + ) + outputFile.printWriter().use { writer -> allAffectedTestTargets.forEach { writer.println(it) } } +} diff --git a/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel new file mode 100644 index 00000000000..55a4e80543f --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel @@ -0,0 +1,41 @@ +""" +Package for common libraries that potentially support multiple scripts by performing common or +generic operations. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_library") + +kt_jvm_library( + name = "bazel_client", + testonly = True, + srcs = [ + "BazelClient.kt", + ], + visibility = ["//scripts:oppia_script_library_visibility"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common:command_executor", + ], +) + +kt_jvm_library( + name = "git_client", + testonly = True, + srcs = [ + "GitClient.kt", + ], + visibility = ["//scripts:oppia_script_library_visibility"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common:command_executor", + ], +) + +kt_jvm_library( + name = "command_executor", + testonly = True, + srcs = [ + "CommandExecutor.kt", + "CommandExecutorImpl.kt", + "CommandResult.kt", + ], + visibility = ["//scripts:oppia_script_library_visibility"], +) diff --git a/scripts/src/java/org/oppia/android/scripts/common/BazelClient.kt b/scripts/src/java/org/oppia/android/scripts/common/BazelClient.kt new file mode 100644 index 00000000000..6f980c16e55 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/common/BazelClient.kt @@ -0,0 +1,125 @@ +package org.oppia.android.scripts.common + +import java.io.File +import java.lang.IllegalArgumentException + +/** + * Utility class to query & interact with a Bazel workspace on the local filesystem (residing within + * the specified root directory). + */ +class BazelClient( + private val rootDirectory: File, + private val commandExecutor: CommandExecutor = CommandExecutorImpl() +) { + /** Returns all Bazel test targets in the workspace. */ + fun retrieveAllTestTargets(): List { + return correctPotentiallyBrokenTargetNames( + executeBazelCommand("query", "--noshow_progress", "kind(test, //...)") + ) + } + + /** Returns all Bazel file targets that correspond to each of the relative file paths provided. */ + fun retrieveBazelTargets(changedFileRelativePaths: Iterable): List { + return correctPotentiallyBrokenTargetNames( + executeBazelCommand( + "query", + "--noshow_progress", + "--keep_going", + "set(${changedFileRelativePaths.joinToString(" ")})", + allowPartialFailures = true + ) + ) + } + + /** Returns all test targets in the workspace that are affected by the list of file targets. */ + fun retrieveRelatedTestTargets(fileTargets: Iterable): List { + return correctPotentiallyBrokenTargetNames( + executeBazelCommand( + "query", + "--noshow_progress", + "--universe_scope=//...", + "--order_output=no", + "kind(test, allrdeps(set(${fileTargets.joinToString(" ")})))" + ) + ) + } + + /** + * Returns all test targets transitively tied to the specific Bazel BUILD/WORKSPACE files listed + * in the provided [buildFiles] list. This may return different files than + * [retrieveRelatedTestTargets] since that method relies on the dependency graph to compute + * affected targets whereas this assumes that any changes to BUILD files could affect any test + * directly or indirectly tied to that BUILD file, regardless of dependencies. + */ + fun retrieveTransitiveTestTargets(buildFiles: Iterable): List { + val buildFileList = buildFiles.joinToString(",") + // Note that this check is needed since rbuildfiles() doesn't like taking an empty list. + return if (buildFileList.isNotEmpty()) { + return correctPotentiallyBrokenTargetNames( + executeBazelCommand( + "query", + "--noshow_progress", + "--universe_scope=//...", + "--order_output=no", + "filter('^[^@]', kind(test, allrdeps(siblings(rbuildfiles($buildFileList)))))", + ) + ) + } else listOf() + } + + private fun correctPotentiallyBrokenTargetNames(lines: List): List { + val correctedTargets = mutableListOf() + for (line in lines) { + when { + line.isEmpty() -> correctedTargets += line + else -> { + val indexes = line.findOccurrencesOf("//") + if (indexes.isEmpty() || indexes.first() != 0) { + throw IllegalArgumentException("Invalid line: $line (expected to start with '//')") + } + + val targetBounds: List> = indexes.mapIndexed { arrayIndex, lineIndex -> + lineIndex to (indexes.getOrNull(arrayIndex + 1) ?: line.length) + } + correctedTargets += targetBounds.map { (startIndex, endIndex) -> + line.substring(startIndex, endIndex) + } + } + } + } + return correctedTargets + } + + @Suppress("SameParameterValue") // This check doesn't work correctly for varargs. + private fun executeBazelCommand( + vararg arguments: String, + allowPartialFailures: Boolean = false + ): List { + val result = + commandExecutor.executeCommand( + rootDirectory, command = "bazel", *arguments, includeErrorOutput = false + ) + // Per https://docs.bazel.build/versions/main/guide.html#what-exit-code-will-i-get error code of + // 3 is expected for queries since it indicates that some of the arguments don't correspond to + // valid targets. Note that this COULD result in legitimate issues being ignored, but it's + // unlikely. + val expectedExitCodes = if (allowPartialFailures) listOf(0, 3) else listOf(0) + check(result.exitCode in expectedExitCodes) { + "Expected non-zero exit code (not ${result.exitCode}) for command: ${result.command}." + + "\nStandard output:\n${result.output.joinToString("\n")}" + + "\nError output:\n${result.errorOutput.joinToString("\n")}" + } + return result.output + } +} + +/** Returns a list of indexes where the specified [needle] occurs in this string. */ +private fun String.findOccurrencesOf(needle: String): List { + val indexes = mutableListOf() + var needleIndex = indexOf(needle) + while (needleIndex >= 0) { + indexes += needleIndex + needleIndex = indexOf(needle, startIndex = needleIndex + needle.length) + } + return indexes +} diff --git a/scripts/src/java/org/oppia/android/scripts/common/CommandExecutor.kt b/scripts/src/java/org/oppia/android/scripts/common/CommandExecutor.kt new file mode 100644 index 00000000000..9fdf5093123 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/common/CommandExecutor.kt @@ -0,0 +1,24 @@ +package org.oppia.android.scripts.common + +import java.io.File + +/** Utility class for executing commands on the local filesystem. */ +interface CommandExecutor { + /** + * Executes the specified [command] in the specified working directory [workingDir] with the + * provided arguments being passed as arguments to the command. + * + * Any exceptions thrown when trying to execute the application will be thrown by this method. + * Any failures in the underlying process should not result in an exception. + * + * @param includeErrorOutput whether to include error output in the returned [CommandResult], + * otherwise it's discarded + * @return a [CommandResult] that includes the error code & application output + */ + fun executeCommand( + workingDir: File, + command: String, + vararg arguments: String, + includeErrorOutput: Boolean = true + ): CommandResult +} diff --git a/scripts/src/java/org/oppia/android/scripts/common/CommandExecutorImpl.kt b/scripts/src/java/org/oppia/android/scripts/common/CommandExecutorImpl.kt new file mode 100644 index 00000000000..44817344a8f --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/common/CommandExecutorImpl.kt @@ -0,0 +1,41 @@ +package org.oppia.android.scripts.common + +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * The default amount of time that should be waited before considering a process as 'hung', in + * milliseconds. + */ +const val WAIT_PROCESS_TIMEOUT_MS = 60_000L + +/** Default implementation of [CommandExecutor]. */ +class CommandExecutorImpl( + private val processTimeout: Long = WAIT_PROCESS_TIMEOUT_MS, + private val processTimeoutUnit: TimeUnit = TimeUnit.MILLISECONDS +) : CommandExecutor { + override fun executeCommand( + workingDir: File, + command: String, + vararg arguments: String, + includeErrorOutput: Boolean + ): CommandResult { + check(workingDir.isDirectory) { + "Expected working directory to be an actual directory: $workingDir" + } + val assembledCommand = listOf(command) + arguments.toList() + val process = + ProcessBuilder(assembledCommand) + .directory(workingDir) + .redirectErrorStream(includeErrorOutput) + .start() + val finished = process.waitFor(processTimeout, processTimeoutUnit) + check(finished) { "Process did not finish within the expected timeout" } + return CommandResult( + process.exitValue(), + process.inputStream.bufferedReader().readLines(), + if (!includeErrorOutput) process.errorStream.bufferedReader().readLines() else listOf(), + assembledCommand, + ) + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/common/CommandResult.kt b/scripts/src/java/org/oppia/android/scripts/common/CommandResult.kt new file mode 100644 index 00000000000..ff7b14956ca --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/common/CommandResult.kt @@ -0,0 +1,13 @@ +package org.oppia.android.scripts.common + +/** The result of executing a command using [CommandExecutorImpl.executeCommand]. */ +data class CommandResult( + /** The exit code of the application. */ + val exitCode: Int, + /** The lines of output from the command, including both error & standard output lines. */ + val output: List, + /** The lines of error output, or empty if error output is redirected to [output]. */ + val errorOutput: List, + /** The fully-formed command line executed by the application to achieve this result. */ + val command: List, +) diff --git a/scripts/src/java/org/oppia/android/scripts/common/GitClient.kt b/scripts/src/java/org/oppia/android/scripts/common/GitClient.kt new file mode 100644 index 00000000000..4da3ddb49cd --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/common/GitClient.kt @@ -0,0 +1,77 @@ +package org.oppia.android.scripts.common + +import java.io.File + +/** + * General utility for interfacing with a Git repository located at the specified working directory + * and using the specified relative branch reference that should be used when computing changes from + * the develop branch. + */ +class GitClient( + private val workingDirectory: File, + private val baseDevelopBranchReference: String +) { + private val commandExecutor by lazy { CommandExecutorImpl() } + + /** The name of the current branch of the local Git repository. */ + val currentBranch: String by lazy { retrieveCurrentBranch() } + + /** The hash of of the latest commit common between the current & HEAD branches. */ + val branchMergeBase: String by lazy { retrieveBranchMergeBase() } + + /** + * The set of files that have been changed in the local branch, including committed, staged, + * unstaged, and untracked files. + */ + val changedFiles: Set by lazy { retrieveChangedFilesWithPotentialDuplicates().toSet() } + + private fun retrieveCurrentBranch(): String { + return executeGitCommandWithOneLineOutput("rev-parse --abbrev-ref HEAD") + } + + private fun retrieveBranchMergeBase(): String { + return executeGitCommandWithOneLineOutput("merge-base $baseDevelopBranchReference HEAD") + } + + private fun retrieveChangedFilesWithPotentialDuplicates(): List = + retrieveChangedCommittedFiles() + + retrieveChangedStagedFiles() + + retrieveChangedUnstagedFiles() + + retrieveChangedUntrackedFiles() + + private fun retrieveChangedCommittedFiles(): List { + return executeGitCommand("diff --name-only ${computeCommitRange()}") + } + + private fun retrieveChangedStagedFiles(): List { + return executeGitCommand("diff --name-only --cached") + } + + private fun retrieveChangedUnstagedFiles(): List { + return executeGitCommand("diff --name-only") + } + + private fun retrieveChangedUntrackedFiles(): List { + return executeGitCommand("ls-files --others --exclude-standard") + } + + private fun computeCommitRange(): String = "HEAD..$branchMergeBase" + + private fun executeGitCommandWithOneLineOutput(argumentsLine: String): String { + val outputLines = executeGitCommand(argumentsLine) + check(outputLines.size == 1) { "Expected one line of output, not: $outputLines" } + return outputLines.first() + } + + private fun executeGitCommand(argumentsLine: String): List { + val result = + commandExecutor.executeCommand( + workingDirectory, command = "git", *argumentsLine.split(" ").toTypedArray() + ) + check(result.exitCode == 0) { + "Expected non-zero exit code (not ${result.exitCode}) for command: ${result.command}." + + " Output:\n${result.output.joinToString("\n")}" + } + return result.output + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/testing/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/testing/BUILD.bazel new file mode 100644 index 00000000000..78b4f12e169 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/testing/BUILD.bazel @@ -0,0 +1,31 @@ +""" +Package for utility libraries that aid in script-related test suites by performing common test +arrangement or operations. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_library") + +kt_jvm_library( + name = "test_bazel_workspace", + testonly = True, + srcs = [ + "TestBazelWorkspace.kt", + ], + visibility = ["//scripts:oppia_script_test_visibility"], + deps = [ + "//third_party:com_google_truth_truth", + ], +) + +kt_jvm_library( + name = "test_git_repository", + testonly = True, + srcs = [ + "TestGitRepository.kt", + ], + visibility = ["//scripts:oppia_script_test_visibility"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common:command_executor", + "//third_party:com_google_truth_truth", + ], +) diff --git a/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt b/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt new file mode 100644 index 00000000000..41b5dd24f4d --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt @@ -0,0 +1,226 @@ +package org.oppia.android.scripts.testing + +import com.google.common.truth.Truth.assertThat +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Test utility for generating various test & library targets in the specified [TemporaryFolder]. + * This is meant to be used to arrange the local test filesystem for use with a real Bazel + * application on the host system. + * + * Note that constructing this class is insufficient to start using Bazel locally. At minimum, + * [initEmptyWorkspace] must be called first. + */ +class TestBazelWorkspace(private val temporaryRootFolder: TemporaryFolder) { + private val workspaceFile by lazy { temporaryRootFolder.newFile("WORKSPACE") } + + /** + * The root BUILD.bazel file which will, by default, hold generated libraries & tests (for those + * not generated as part of subpackages). + */ + val rootBuildFile: File by lazy { temporaryRootFolder.newFile("BUILD.bazel") } + + private val testFileMap = mutableMapOf() + private val libraryFileMap = mutableMapOf() + private val testDependencyNameMap = mutableMapOf() + private var isConfiguredForKotlin = false + private val filesConfiguredForTests = mutableListOf() + private val filesConfiguredForLibraries = mutableListOf() + + /** Initializes the local Bazel workspace by introducing a new, empty WORKSPACE file. */ + fun initEmptyWorkspace() { + // Sanity check, but in reality this is just initializing workspaceFile to ensure that it + // exists. + assertThat(workspaceFile.exists()).isTrue() + } + + /** + * Generates and adds a new kt_jvm_test target with the target name [testName] and test file + * [testFile]. This can be used to add multiple tests to the same build file, and will + * automatically set up the local WORKSPACE file, if needed, to support kt_jvm_test. + * + * @param testName the name of the generated test target (must be unique) + * @param testFile the local test file (which does not necessarily need to exist yet) + * @param withGeneratedDependency whether to automatically generate a new library (see + * [createLibrary]) and add it to the new test target as a dependency + * @param withExtraDependency if present, will be added as an additional dependency to the + * generated test target + * @param subpackage if present, the subpackage under which the test target should live. This will + * create a new BUILD.bazel file if one isn't already present. Note that only one subpackage + * level can be used (i.e. "subpackage" is valid, but "sub.package" is not). + * @return an iterable of [File]s that were changed as part of generating this new test + */ + fun addTestToBuildFile( + testName: String, + testFile: File, + withGeneratedDependency: Boolean = false, + withExtraDependency: String? = null, + subpackage: String? = null + ): Iterable { + check(testName !in testFileMap) { "Test '$testName' already set up" } + val prereqFiles = ensureWorkspaceIsConfiguredForKotlin() + val (dependencyTargetName, libPrereqFiles) = if (withGeneratedDependency) { + createLibrary("${testName}Dependency") + } else null to listOf() + val buildFile = if (subpackage != null) { + if (!File(temporaryRootFolder.root, subpackage).exists()) { + temporaryRootFolder.newFolder(subpackage) + } + val newBuildFile = temporaryRootFolder.newFile("$subpackage/BUILD.bazel") + newBuildFile + } else rootBuildFile + prepareBuildFileForTests(buildFile) + + testFileMap[testName] = testFile + val generatedDependencyExpression = if (withGeneratedDependency) { + testDependencyNameMap[testName] = dependencyTargetName ?: error("Something went wrong.") + "\":$dependencyTargetName\"," + } else "" + val extraDependencyExpression = withExtraDependency?.let { "\"$it\"," } ?: "" + buildFile.appendText( + """ + kt_jvm_test( + name = "$testName", + srcs = ["${testFile.name}"], + deps = [$generatedDependencyExpression$extraDependencyExpression], + ) + """.trimIndent() + "\n" + ) + + return setOf(testFile, buildFile) + prereqFiles + libPrereqFiles + } + + /** + * Generates a new test target using the specified [testName], and generating a local test file + * using the test name as the filename (with a Kotlin extension). + * + * For details on the parameters & return value, see [addTestToBuildFile] for more context. + */ + fun createTest( + testName: String, + withGeneratedDependency: Boolean = false, + withExtraDependency: String? = null, + subpackage: String? = null + ): Iterable { + check(testName !in testFileMap) { "Test '$testName' already exists" } + val testFile = if (subpackage != null) { + if (!File(temporaryRootFolder.root, subpackage).exists()) { + temporaryRootFolder.newFolder(subpackage) + } + temporaryRootFolder.newFile("$subpackage/$testName.kt") + } else temporaryRootFolder.newFile("$testName.kt") + return addTestToBuildFile( + testName, + testFile, + withGeneratedDependency, + withExtraDependency, + subpackage + ) + } + + /** + * Generates a new library using the specified dependency name, modifying the local + * [rootBuildFile] and updating the WORKSPACE to support Kotlin libraries, if necessary. + * + * @param dependencyName the name of the library dependency, which will be used both for the + * target name and a local source file representing the 'library'. For example, if "SampleLib" + * is passed as the dependency name then a "SampleLib.kt" will be generated and the library's + * target name will be "SampleLib_lib". + * @return a pair where the first value is the library's target name and the second value is an + * iterable of files that were changed as part of generating this library + */ + fun createLibrary(dependencyName: String): Pair> { + val libTargetName = "${dependencyName}_lib" + check(libTargetName !in libraryFileMap) { "Library '$dependencyName' already exists" } + val prereqFiles = ensureWorkspaceIsConfiguredForKotlin() + prepareBuildFileForLibraries(rootBuildFile) + + val depFile = temporaryRootFolder.newFile("$dependencyName.kt") + libraryFileMap[libTargetName] = depFile + rootBuildFile.appendText( + """ + kt_jvm_library( + name = "$libTargetName", + srcs = ["${depFile.name}"], + ) + """.trimIndent() + "\n" + ) + + return libTargetName to (setOf(depFile, rootBuildFile) + prereqFiles) + } + + /** + * Return the source test file corresponding to the test with the specified [testName], if one + * exists (otherwise an exception is thrown). + */ + fun retrieveTestFile(testName: String): File = testFileMap.getValue(testName) + + /** + * Returns the source library file corresponding to the library with the specified + * [dependencyName], if one exists (otherwise an exception is thrown). + */ + fun retrieveLibraryFile(dependencyName: String): File = + libraryFileMap.getValue("${dependencyName}_lib") + + /** + * Returns the source library file corresponding to the library that was generated when creating + * the test corresponding to [testName], or an exception is thrown if either the test does not + * exist or it was not configured with a test dependency. + * + * This function is only valid to call for tests that were initialized by setting + * 'withGeneratedDependency' to 'true' when calling either [addTestToBuildFile] or [createTest]. + */ + fun retrieveTestDependencyFile(testName: String): File { + return libraryFileMap.getValue( + testDependencyNameMap[testName] + ?: error("No entry for '$testName'. Was the test created without dependencies?") + ) + } + + private fun ensureWorkspaceIsConfiguredForKotlin(): List { + if (!isConfiguredForKotlin) { + // Add support for Kotlin: https://github.com/bazelbuild/rules_kotlin. + val rulesKotlinReleaseUrl = + "https://github.com/bazelbuild/rules_kotlin/releases/download/v1.5.0-alpha-2" + + "/rules_kotlin_release.tgz" + val rulesKotlinArchiveName = "io_bazel_rules_kotlin" + val rulesKotlinBazelPrefix = "@$rulesKotlinArchiveName//kotlin" + + workspaceFile.appendText( + """ + load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + http_archive( + name = "$rulesKotlinArchiveName", + sha256 = "6194a864280e1989b6d8118a4aee03bb50edeeae4076e5bc30eef8a98dcd4f07", + urls = ["$rulesKotlinReleaseUrl"], + ) + load("$rulesKotlinBazelPrefix:dependencies.bzl", "kt_download_local_dev_dependencies") + load("$rulesKotlinBazelPrefix:kotlin.bzl", "kotlin_repositories", "kt_register_toolchains") + kt_download_local_dev_dependencies() + kotlin_repositories() + kt_register_toolchains() + """.trimIndent() + "\n" + ) + isConfiguredForKotlin = true + return listOf(workspaceFile) + } + return listOf() + } + + private fun prepareBuildFileForTests(buildFile: File) { + if (buildFile !in filesConfiguredForTests) { + buildFile.appendText("load(\"@io_bazel_rules_kotlin//kotlin:kotlin.bzl\", \"kt_jvm_test\")\n") + filesConfiguredForTests += buildFile + } + } + + private fun prepareBuildFileForLibraries(buildFile: File) { + if (buildFile !in filesConfiguredForLibraries) { + buildFile.appendText( + "load(\"@io_bazel_rules_kotlin//kotlin:kotlin.bzl\", \"kt_jvm_library\")\n" + ) + filesConfiguredForLibraries += buildFile + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/testing/TestGitRepository.kt b/scripts/src/java/org/oppia/android/scripts/testing/TestGitRepository.kt new file mode 100644 index 00000000000..61d5c055992 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/testing/TestGitRepository.kt @@ -0,0 +1,105 @@ +package org.oppia.android.scripts.testing + +import com.google.common.truth.Truth.assertWithMessage +import org.junit.rules.TemporaryFolder +import org.oppia.android.scripts.common.CommandExecutor +import org.oppia.android.scripts.common.CommandResult +import java.io.File + +/** + * Test utility for interacting with a local Git repository on the filesystem, located in the + * specified [TemporaryFolder]. This is meant to be used to arrange the local test filesystem for + * use with a real Git application on the host system. + * + * Note that constructing this class is insufficient to establish a local Git repository. At + * minimum, [init] must be called first. + */ +class TestGitRepository( + private val temporaryRootFolder: TemporaryFolder, + private val commandExecutor: CommandExecutor +) { + private val rootDirectory by lazy { temporaryRootFolder.root } + + /** Creates the repository using git init. */ + fun init() { + executeSuccessfulGitCommand("init") + } + + /** Sets the user's [email] and [name] using git config. */ + fun setUser(email: String, name: String) { + executeSuccessfulGitCommand("config", "user.email", email) + executeSuccessfulGitCommand("config", "user.name", name) + } + + /** Creates a new branch with the specified name, and switches to it. */ + fun checkoutNewBranch(branchName: String) { + executeSuccessfulGitCommand("checkout", "-b", branchName) + } + + /** + * Adds the specified file to be staged for committing (via git add). + * + * This does not perform a commit. See [commit] for actually committing the change. + */ + fun stageFileForCommit(file: File) { + executeSuccessfulGitCommand("add", file.toRelativeString(rootDirectory)) + } + + /** Stages the iterable of files for commit. See [stageFileForCommit]. */ + fun stageFilesForCommit(files: Iterable) { + files.forEach(this::stageFileForCommit) + } + + /** + * Removes the specified file using git rm, staging it for removal & removing it from the local + * filesystem. + * + * This does not perform a commit. See [commit] for actually committing the change. + */ + fun removeFileForCommit(file: File) { + executeSuccessfulGitCommand("rm", file.toRelativeString(rootDirectory)) + } + + /** + * Moves the [oldFile] to [newFile] using git mv, both performing the move on the local + * filesystem and staging the move for committing. + * + * This does not perform a commit. See [commit] for actually committing the change. + */ + fun moveFileForCommit(oldFile: File, newFile: File) { + executeSuccessfulGitCommand( + "mv", + oldFile.toRelativeString(rootDirectory), + newFile.toRelativeString(rootDirectory) + ) + } + + /** + * Commits the locally staged files. + * + * @param message the message to include as the context for the commit + * @param allowEmpty whether to allow empty commits (i.e. committing with no staged files) + */ + fun commit(message: String, allowEmpty: Boolean = false) { + val arguments = mutableListOf("commit", "-m", message) + if (allowEmpty) arguments += "--allow-empty" + executeSuccessfulGitCommand(*arguments.toTypedArray()) + } + + /** Returns the result of git status. */ + fun status(): String { + return commandExecutor.executeCommand(rootDirectory, "git", "status").output.joinOutputString() + } + + private fun executeSuccessfulGitCommand(vararg arguments: String) { + verifySuccessfulCommand(commandExecutor.executeCommand(rootDirectory, "git", *arguments)) + } + + private fun verifySuccessfulCommand(result: CommandResult) { + assertWithMessage("Output: ${result.output.joinOutputString()}") + .that(result.exitCode) + .isEqualTo(0) + } + + private fun List.joinOutputString(): String = joinToString(separator = "\n") { " $it" } +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel new file mode 100644 index 00000000000..5db3b49d21c --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel @@ -0,0 +1,18 @@ +""" +Tests corresponding to developer scripts that help with continuous integration workflows. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_test") + +kt_jvm_test( + name = "ComputeAffectedTestsTest", + srcs = ["ComputeAffectedTestsTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/ci:compute_affected_tests_lib", + "//scripts/src/java/org/oppia/android/scripts/testing:test_bazel_workspace", + "//scripts/src/java/org/oppia/android/scripts/testing:test_git_repository", + "//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/ci/ComputeAffectedTestsTest.kt b/scripts/src/javatests/org/oppia/android/scripts/ci/ComputeAffectedTestsTest.kt new file mode 100644 index 00000000000..c57f3c8b82c --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/ci/ComputeAffectedTestsTest.kt @@ -0,0 +1,412 @@ +package org.oppia.android.scripts.ci + +import com.google.common.truth.Truth.assertThat +import org.junit.After +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.testing.TestBazelWorkspace +import org.oppia.android.scripts.testing.TestGitRepository +import org.oppia.android.testing.assertThrows +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.OutputStream +import java.io.PrintStream + +/** + * Tests for the compute_affected_tests utility. + * + * Note that this test suite makes use of real Bazel & Git utilities on the local system. As a + * result, these tests could be affected by unexpected environment issues (such as inconsistencies + * across dependency versions or changes in behavior across different filesystems). + */ +// Same parameter value: helpers reduce test context, even if they are used by 1 test. +// Function name: test names are conventionally named with underscores. +@Suppress("SameParameterValue", "FunctionName") +class ComputeAffectedTestsTest { + @Rule + @JvmField + var tempFolder = TemporaryFolder() + + private lateinit var testBazelWorkspace: TestBazelWorkspace + private lateinit var testGitRepository: TestGitRepository + + private lateinit var pendingOutputStream: ByteArrayOutputStream + private lateinit var originalStandardOutputStream: OutputStream + + @Before + fun setUp() { + testBazelWorkspace = TestBazelWorkspace(tempFolder) + testGitRepository = TestGitRepository(tempFolder, CommandExecutorImpl()) + + // Redirect script output for testing purposes. + pendingOutputStream = ByteArrayOutputStream() + originalStandardOutputStream = System.out + System.setOut(PrintStream(pendingOutputStream)) + } + + @After + fun tearDown() { + // Reinstate test output redirection. + System.setOut(PrintStream(pendingOutputStream)) + + // Print the status of the git repository to help with debugging in the cases of test failures + // and to help manually verify the expect git state at the end of each test. + println("git status (at end of test):") + println(testGitRepository.status()) + } + + @Test + fun testUtility_noArguments_printsUsageStringAndExits() { + val exception = assertThrows(SecurityException::class) { main(arrayOf()) } + + // Bazel catches the System.exit() call and throws a SecurityException. This is a bit hacky way + // to verify that System.exit() is called, but it's helpful. + assertThat(exception).hasMessageThat().contains("System.exit()") + assertThat(pendingOutputStream.toString()).contains("Usage:") + } + + @Test + fun testUtility_oneArgument_printsUsageStringAndExits() { + val exception = assertThrows(SecurityException::class) { main(arrayOf("first")) } + + // Bazel catches the System.exit() call and throws a SecurityException. This is a bit hacky way + // to verify that System.exit() is called, but it's helpful. + assertThat(exception).hasMessageThat().contains("System.exit()") + assertThat(pendingOutputStream.toString()).contains("Usage:") + } + + @Test + fun testUtility_twoArguments_printsUsageStringAndExits() { + val exception = assertThrows(SecurityException::class) { main(arrayOf("first", "second")) } + + // Bazel catches the System.exit() call and throws a SecurityException. This is a bit hacky way + // to verify that System.exit() is called, but it's helpful. + assertThat(exception).hasMessageThat().contains("System.exit()") + assertThat(pendingOutputStream.toString()).contains("Usage:") + } + + @Test + fun testUtility_directoryRootDoesNotExist_throwsException() { + val exception = assertThrows(IllegalStateException::class) { + main(arrayOf("fake", "alsofake", "andstillfake")) + } + + assertThat(exception).hasMessageThat().contains("Expected 'fake' to be a directory") + } + + @Test + fun testUtility_emptyDirectory_throwsException() { + val exception = assertThrows(IllegalStateException::class) { runScript() } + + assertThat(exception).hasMessageThat().contains("run from the workspace's root directory") + } + + @Test + fun testUtility_emptyWorkspace_returnsNoTargets() { + // Need to be on a feature branch since the develop branch expects there to be targets. + initializeEmptyGitRepository() + switchToFeatureBranch() + createEmptyWorkspace() + + val reportedTargets = runScript() + + // An empty workspace should yield no targets. + assertThat(reportedTargets).isEmpty() + } + + @Test + fun testUtility_bazelWorkspace_developBranch_returnsAllTests() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + + val reportedTargets = runScript() + + // Since the develop branch is checked out, all test targets should be returned. + assertThat(reportedTargets).containsExactly("//:FirstTest", "//:SecondTest", "//:ThirdTest") + } + + @Test + fun testUtility_bazelWorkspace_featureBranch_noChanges_returnsNoTargets() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + switchToFeatureBranch() + + val reportedTargets = runScript() + + // No changes are on the feature branch, so no targets should be returned. + assertThat(reportedTargets).isEmpty() + } + + @Test + fun testUtility_bazelWorkspace_featureBranch_testChange_committed_returnsTestTarget() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + switchToFeatureBranch() + changeAndCommitTestFile("FirstTest") + + val reportedTargets = runScript() + + // Only the first test should be reported since the test file itself was changed & committed. + assertThat(reportedTargets).containsExactly("//:FirstTest") + } + + @Test + fun testUtility_bazelWorkspace_featureBranch_testChange_staged_returnsTestTarget() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + switchToFeatureBranch() + changeAndStageTestFile("FirstTest") + + val reportedTargets = runScript() + + // Only the first test should be reported since the test file itself was changed & staged. + assertThat(reportedTargets).containsExactly("//:FirstTest") + } + + @Test + fun testUtility_bazelWorkspace_featureBranch_testChange_unstaged_returnsTestTarget() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + switchToFeatureBranch() + changeTestFile("FirstTest") + + val reportedTargets = runScript() + + // The first test should still be reported since it was changed (even though it wasn't staged). + assertThat(reportedTargets).containsExactly("//:FirstTest") + } + + @Test + fun testUtility_bazelWorkspace_featureBranch_newTest_untracked_returnsNewTestTarget() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + switchToFeatureBranch() + // A separate subpackage is needed to avoid unintentionally changing the BUILD file used by the + // other already-committed tests. + createBasicTests("NewUntrackedTest", subpackage = "newtest") + + val reportedTargets = runScript() + + // The new test should still be reported since it was changed (even though it wasn't staged). + assertThat(reportedTargets).containsExactly("//newtest:NewUntrackedTest") + } + + @Test + fun testUtility_bazelWorkspace_featureBranch_dependencyChange_committed_returnsTestTarget() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest", "SecondTest", withGeneratedDependencies = true) + switchToFeatureBranch() + changeAndCommitDependencyFileForTest("FirstTest") + + val reportedTargets = runScript() + + // The first test should be reported since its dependency was changed. + assertThat(reportedTargets).containsExactly("//:FirstTest") + } + + @Test + fun testUtility_bazelWorkspace_featureBranch_commonDepChange_committed_returnsTestTargets() { + initializeEmptyGitRepository() + val targetName = createAndCommitLibrary("CommonDependency") + createAndCommitBasicTests("FirstTest", withGeneratedDependencies = true) + createAndCommitBasicTests("SecondTest", "ThirdTest", withExtraDependency = targetName) + switchToFeatureBranch() + changeAndCommitLibrary("CommonDependency") + + val reportedTargets = runScript() + + // The two tests with a common dependency should be reported since that dependency was changed. + assertThat(reportedTargets).containsExactly("//:SecondTest", "//:ThirdTest") + } + + @Test + fun testUtility_bazelWorkspace_featureBranch_buildFileChange_committed_returnsRelatedTargets() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest", "SecondTest") + switchToFeatureBranch() + createAndCommitBasicTests("ThirdTest") + + val reportedTargets = runScript() + + // Introducing a fourth test requires changing the common BUILD file which leads to the other + // tests becoming affected. + assertThat(reportedTargets).containsExactly("//:FirstTest", "//:SecondTest", "//:ThirdTest") + } + + @Test + fun testUtility_bazelWorkspace_featureBranch_deletedTest_committed_returnsNoTargets() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest") + switchToFeatureBranch() + removeAndCommitTestFileAndResetBuildFile("FirstTest") + + val reportedTargets = runScript() + + // Removing the test should result in no targets being returned (since the test target is gone). + // Note that if the BUILD file had other tests in it, those would be re-run per the verified + // behavior of the above test (the BUILD file is considered changed). + assertThat(reportedTargets).isEmpty() + } + + @Test + fun testUtility_bazelWorkspace_featureBranch_movedTest_staged_returnsNewTestTarget() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest") + switchToFeatureBranch() + moveTest(oldTestName = "FirstTest", newTestName = "RenamedTest", newSubpackage = "newpkg") + + val reportedTargets = runScript() + + // The test should show up under its new name since moving it is the same as changing it. + assertThat(reportedTargets).containsExactly("//newpkg:RenamedTest") + } + + @Test + fun testUtility_featureBranch_multipleTargetsChanged_committed_returnsAffectedTests() { + initializeEmptyGitRepository() + createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + switchToFeatureBranch() + changeAndCommitTestFile("FirstTest") + changeAndCommitTestFile("ThirdTest") + + val reportedTargets = runScript() + + // Changing multiple tests independently should be reflected in the script's results. + assertThat(reportedTargets).containsExactly("//:FirstTest", "//:ThirdTest") + } + + /** + * Runs the compute_affected_tests utility & returns all of the output lines. Note that the output + * here is that which is saved directly to the output file, not debug lines printed to the + * console. + */ + private fun runScript(): List { + val outputLog = tempFolder.newFile("output.log") + main(arrayOf(tempFolder.root.absolutePath, outputLog.absolutePath, "develop")) + return outputLog.readLines() + } + + private fun createEmptyWorkspace() { + testBazelWorkspace.initEmptyWorkspace() + } + + private fun initializeEmptyGitRepository() { + // Initialize the git repository with a base 'develop' branch & an initial empty commit (so that + // there's a HEAD commit). + testGitRepository.init() + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + testGitRepository.checkoutNewBranch("develop") + testGitRepository.commit(message = "Initial commit.", allowEmpty = true) + } + + private fun switchToFeatureBranch() { + testGitRepository.checkoutNewBranch("introduce-feature") + } + + /** + * Creates a new test for each specified test name. + * + * @param withGeneratedDependencies whether each test should have a corresponding test dependency + * generated + * @param withExtraDependency if present, an extra library dependency that should be added to each + * test + * @param subpackage if provided, the subpackage under which the tests should be created + */ + private fun createBasicTests( + vararg testNames: String, + withGeneratedDependencies: Boolean = false, + withExtraDependency: String? = null, + subpackage: String? = null + ): List { + return testNames.flatMap { testName -> + testBazelWorkspace.createTest( + testName, + withGeneratedDependencies, + withExtraDependency, + subpackage + ) + } + } + + private fun createAndCommitBasicTests( + vararg testNames: String, + withGeneratedDependencies: Boolean = false, + withExtraDependency: String? = null + ) { + val changedFiles = createBasicTests( + *testNames, + withGeneratedDependencies = withGeneratedDependencies, + withExtraDependency = withExtraDependency + ) + testGitRepository.stageFilesForCommit(changedFiles.toSet()) + testGitRepository.commit(message = "Introduce basic tests.") + } + + private fun changeTestFile(testName: String): File { + val testFile = testBazelWorkspace.retrieveTestFile(testName) + testFile.appendText(";") // Add a character to change the file. + return testFile + } + + private fun changeDependencyFileForTest(testName: String): File { + val depFile = testBazelWorkspace.retrieveTestDependencyFile(testName) + depFile.appendText(";") // Add a character to change the file. + return depFile + } + + private fun changeAndStageTestFile(testName: String) { + val testFile = changeTestFile(testName) + testGitRepository.stageFileForCommit(testFile) + } + + private fun changeAndStageDependencyFileForTest(testName: String) { + val depFile = changeDependencyFileForTest(testName) + testGitRepository.stageFileForCommit(depFile) + } + + private fun changeAndCommitTestFile(testName: String) { + changeAndStageTestFile(testName) + testGitRepository.commit(message = "Modified test $testName") + } + + private fun changeAndCommitDependencyFileForTest(testName: String) { + changeAndStageDependencyFileForTest(testName) + testGitRepository.commit(message = "Modified dependency for test $testName") + } + + private fun removeAndCommitTestFileAndResetBuildFile(testName: String) { + val testFile = testBazelWorkspace.retrieveTestFile(testName) + testGitRepository.removeFileForCommit(testFile) + // Clear the test's BUILD file. + testBazelWorkspace.rootBuildFile.writeText("") + testGitRepository.commit(message = "Remove test $testName") + } + + private fun moveTest(oldTestName: String, newTestName: String, newSubpackage: String) { + // Actually changing the BUILD file for a move is tricky, so just regenerate it, instead, and + // mark the file as moved. + val oldTestFile = testBazelWorkspace.retrieveTestFile(oldTestName) + testBazelWorkspace.rootBuildFile.writeText("") + val newTestFile = File(tempFolder.root, "$newSubpackage/$newTestName.kt") + testBazelWorkspace.addTestToBuildFile(newTestName, newTestFile, subpackage = newSubpackage) + testGitRepository.moveFileForCommit(oldTestFile, newTestFile) + } + + /** Creates a new library with the specified name & returns its generated target name. */ + private fun createAndCommitLibrary(name: String): String { + val (targetName, files) = testBazelWorkspace.createLibrary(name) + testGitRepository.stageFilesForCommit(files.toSet()) + testGitRepository.commit(message = "Add shareable library.") + return targetName + } + + private fun changeAndCommitLibrary(name: String) { + val libFile = testBazelWorkspace.retrieveLibraryFile(name) + libFile.appendText(";") // Add a character to change the file. + testGitRepository.stageFileForCommit(libFile) + testGitRepository.commit(message = "Modified library $name") + } +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/common/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/common/BUILD.bazel new file mode 100644 index 00000000000..a8ce3dfb318 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/common/BUILD.bazel @@ -0,0 +1,40 @@ +""" +Tests corresponding to common libraries that potentially support multiple scripts by performing +common or generic operations. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_test") + +kt_jvm_test( + name = "BazelClientTest", + srcs = ["BazelClientTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common:bazel_client", + "//scripts/src/java/org/oppia/android/scripts/testing:test_bazel_workspace", + "//testing:assertion_helpers", + "//testing/src/main/java/org/oppia/android/testing/mockito", + "//third_party:com_google_truth_truth", + "//third_party:org_mockito_mockito-core", + ], +) + +kt_jvm_test( + name = "CommandExecutorImplTest", + srcs = ["CommandExecutorImplTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common:command_executor", + "//testing:assertion_helpers", + "//third_party:com_google_truth_truth", + ], +) + +kt_jvm_test( + name = "GitClientTest", + srcs = ["GitClientTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common:git_client", + "//scripts/src/java/org/oppia/android/scripts/testing:test_git_repository", + "//testing:assertion_helpers", + "//third_party:com_google_truth_truth", + ], +) diff --git a/scripts/src/javatests/org/oppia/android/scripts/common/BazelClientTest.kt b/scripts/src/javatests/org/oppia/android/scripts/common/BazelClientTest.kt new file mode 100644 index 00000000000..aed32107cd6 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/common/BazelClientTest.kt @@ -0,0 +1,308 @@ +package org.oppia.android.scripts.common + +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.oppia.android.scripts.testing.TestBazelWorkspace +import org.oppia.android.testing.assertThrows +import org.oppia.android.testing.mockito.anyOrNull + +/** + * Tests for [BazelClient]. + * + * Note that this test executes real commands on the local filesystem & requires Bazel in the local + * environment. + */ +// Function name: test names are conventionally named with underscores. +@Suppress("FunctionName") +class BazelClientTest { + @Rule + @JvmField + var tempFolder = TemporaryFolder() + + @Rule + @JvmField + val mockitoRule: MockitoRule = MockitoJUnit.rule() + + private lateinit var testBazelWorkspace: TestBazelWorkspace + @Mock lateinit var mockCommandExecutor: CommandExecutor + + @Before + fun setUp() { + testBazelWorkspace = TestBazelWorkspace(tempFolder) + } + + @Test + fun testRetrieveTestTargets_emptyFolder_fails() { + val bazelClient = BazelClient(tempFolder.root) + + val exception = assertThrows(IllegalStateException::class) { + bazelClient.retrieveAllTestTargets() + } + + // Verify that the underlying Bazel command failed since it was run outside a Bazel workspace. + assertThat(exception).hasMessageThat().contains("Expected non-zero exit code") + assertThat(exception).hasMessageThat().contains("only supported from within a workspace") + } + + @Test + fun testRetrieveTestTargets_emptyWorkspace_fails() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + + val exception = assertThrows(IllegalStateException::class) { + bazelClient.retrieveAllTestTargets() + } + + // Verify that the underlying Bazel command failed since there are no test targets. + assertThat(exception).hasMessageThat().contains("Expected non-zero exit code") + assertThat(exception).hasMessageThat().contains("no targets found beneath ''") + } + + @Test + fun testRetrieveTestTargets_workspaceWithTest_returnsTestTarget() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest("ExampleTest") + + val testTargets = bazelClient.retrieveAllTestTargets() + + assertThat(testTargets).contains("//:ExampleTest") + } + + @Test + fun testRetrieveTestTargets_workspaceWithMultipleTests_returnsTestTargets() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest("FirstTest") + testBazelWorkspace.createTest("SecondTest") + + val testTargets = bazelClient.retrieveAllTestTargets() + + assertThat(testTargets).containsExactly("//:FirstTest", "//:SecondTest") + } + + @Test + fun testRetrieveTestTargets_resultsJumbled_returnsCorrectTestTargets() { + val bazelClient = BazelClient(tempFolder.root, mockCommandExecutor) + fakeCommandExecutorWithResult(singleLine = "//:FirstTest//:SecondTest") + + val testTargets = bazelClient.retrieveAllTestTargets() + + assertThat(testTargets).containsExactly("//:FirstTest", "//:SecondTest") + } + + @Test + fun testRetrieveBazelTargets_forFileNotInBuildGraph_returnsEmptyList() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + tempFolder.newFile("filenotingraph") + + val fileTargets = bazelClient.retrieveBazelTargets(listOf("filenotingraph")) + + assertThat(fileTargets).isEmpty() + } + + @Test + fun testRetrieveBazelTargets_forTestFile_returnsBazelTarget() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest("FirstTest") + + val fileTargets = bazelClient.retrieveBazelTargets(listOf("FirstTest.kt")) + + assertThat(fileTargets).containsExactly("//:FirstTest.kt") + } + + @Test + fun testRetrieveBazelTargets_forMultipleMixedFiles_returnsBazelTargets() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest("FirstTest") + testBazelWorkspace.createTest("SecondTest", withGeneratedDependency = true) + testBazelWorkspace.createTest("ThirdTest", subpackage = "subpackage") + testBazelWorkspace.createLibrary("ExtraDep") + + val fileTargets = + bazelClient.retrieveBazelTargets( + listOf("SecondTestDependency.kt", "subpackage/ThirdTest.kt", "ExtraDep.kt") + ) + + assertThat(fileTargets).containsExactly( + "//:SecondTestDependency.kt", "//subpackage:ThirdTest.kt", "//:ExtraDep.kt" + ) + } + + @Test + fun testRetrieveBazelTargets_resultsJumbled_returnsCorrectBazelTargets() { + val bazelClient = BazelClient(tempFolder.root, mockCommandExecutor) + fakeCommandExecutorWithResult(singleLine = "//:FirstTest.kt//:SecondTest.kt") + + val fileTargets = bazelClient.retrieveBazelTargets(listOf("FirstTest.kt", "SecondTest.kt")) + + assertThat(fileTargets).containsExactly("//:FirstTest.kt", "//:SecondTest.kt") + } + + @Test + fun testRetrieveRelatedTestTargets_forTargetWithNoTestDependency_returnsNoTargets() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createLibrary("SomeDependency") + testBazelWorkspace.createTest("FirstTest") + + val testTargets = bazelClient.retrieveRelatedTestTargets(listOf("//:SomeDependency.kt")) + + // Since the target doesn't have any tests depending on it, there are no targets to provide. + assertThat(testTargets).isEmpty() + } + + @Test + fun testRetrieveRelatedTestTargets_forTestFileTarget_returnsTestTarget() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest("FirstTest") + + val testTargets = bazelClient.retrieveRelatedTestTargets(listOf("//:FirstTest.kt")) + + assertThat(testTargets).containsExactly("//:FirstTest") + } + + @Test + fun testRetrieveRelatedTestTargets_forDependentFileTarget_returnsTestTarget() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest("FirstTest", withGeneratedDependency = true) + + val testTargets = bazelClient.retrieveRelatedTestTargets(listOf("//:FirstTestDependency.kt")) + + assertThat(testTargets).containsExactly("//:FirstTest") + } + + @Test + fun testRetrieveRelatedTestTargets_forMixedFileTargets_returnsRelatedTestTargets() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createLibrary("ExtraDep") + testBazelWorkspace.createTest("FirstTest", withExtraDependency = "//:ExtraDep_lib") + testBazelWorkspace.createTest("SecondTest", withGeneratedDependency = true) + testBazelWorkspace.createTest("ThirdTest") + testBazelWorkspace.createTest("FourthTest", subpackage = "subpackage") + + val testTargets = + bazelClient.retrieveRelatedTestTargets( + listOf("//:SecondTestDependency.kt", "//subpackage:FourthTest.kt", "//:ExtraDep.kt") + ) + + println(testBazelWorkspace.rootBuildFile.readLines().joinToString("\n")) + + // The function should provide all test targets related to the file targets provided (either via + // dependencies or because that file is part of the test itself). + assertThat(testTargets).containsExactly( + "//:FirstTest", "//:SecondTest", "//subpackage:FourthTest" + ) + } + + @Test + fun testRetrieveRelatedTestTargets_resultsJumbled_returnsCorrectTestTargets() { + val bazelClient = BazelClient(tempFolder.root, mockCommandExecutor) + fakeCommandExecutorWithResult(singleLine = "//:FirstTest//:SecondTest") + + val testTargets = + bazelClient.retrieveRelatedTestTargets(listOf("//:FirstTest.kt", "//:SecondTest.kt")) + + assertThat(testTargets).containsExactly("//:FirstTest", "//:SecondTest") + } + + @Test + fun testRetrieveTransitiveTestTargets_forNoFiles_returnsEmptyList() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + + val testTargets = bazelClient.retrieveTransitiveTestTargets(listOf()) + + // No test targets for no related build files. + assertThat(testTargets).isEmpty() + } + + @Test + fun testRetrieveTransitiveTestTargets_forBuildFile_returnsAllTestsInThatBuildFile() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest("FirstTest") + testBazelWorkspace.createTest("SecondTest") + testBazelWorkspace.createTest("ThirdTest", subpackage = "subpackage") + testBazelWorkspace.createTest("FourthTest") + + val testTargets = bazelClient.retrieveTransitiveTestTargets(listOf("BUILD.bazel")) + + // No test targets for no related build files. + assertThat(testTargets).containsExactly("//:FirstTest", "//:SecondTest", "//:FourthTest") + } + + @Test + fun testRetrieveTransitiveTestTargets_forMultipleBuildFiles_returnsAllRelatedTests() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest("FirstTest") + testBazelWorkspace.createTest("SecondTest", subpackage = "two") + testBazelWorkspace.createTest("ThirdTest", subpackage = "three") + testBazelWorkspace.createTest("FourthTest", subpackage = "four") + + val testTargets = + bazelClient.retrieveTransitiveTestTargets(listOf("two/BUILD.bazel", "three/BUILD.bazel")) + + // No test targets for no related build files. + assertThat(testTargets).containsExactly("//two:SecondTest", "//three:ThirdTest") + } + + @Test + @Ignore("Fails in GitHub Actions") // TODO(#2691): Re-enable this test once it can pass in CI. + fun testRetrieveTransitiveTestTargets_forWorkspace_returnsAllTests() { + val bazelClient = BazelClient(tempFolder.root) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest("FirstTest") + testBazelWorkspace.createTest("SecondTest", subpackage = "two") + testBazelWorkspace.createTest("ThirdTest", subpackage = "three") + testBazelWorkspace.createTest("FourthTest") + + val testTargets = bazelClient.retrieveTransitiveTestTargets(listOf("WORKSPACE")) + + // No test targets for no related build files. + assertThat(testTargets).containsExactly( + "//:FirstTest", "//two:SecondTest", "//three:ThirdTest", "//:FourthTest" + ) + } + + @Test + fun testRetrieveTransitiveTestTargets_resultsJumbled_returnsCorrectTestTargets() { + val bazelClient = BazelClient(tempFolder.root, mockCommandExecutor) + fakeCommandExecutorWithResult(singleLine = "//:FirstTest//:SecondTest") + + val testTargets = bazelClient.retrieveTransitiveTestTargets(listOf("WORKSPACE")) + + assertThat(testTargets).containsExactly("//:FirstTest", "//:SecondTest") + } + + private fun fakeCommandExecutorWithResult(singleLine: String) { + // Fake a Bazel command's results to return jumbled results. This has been observed to happen + // sometimes in CI, but doesn't have a known cause. The utility is meant to de-jumble these in + // circumstances where they occur, and the only way to guarantee this happens in the test + // environment is to force the command output. + `when`(mockCommandExecutor.executeCommand(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn( + CommandResult( + exitCode = 0, + output = listOf(singleLine), + errorOutput = listOf(), + command = listOf() + ) + ) + } +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/common/CommandExecutorImplTest.kt b/scripts/src/javatests/org/oppia/android/scripts/common/CommandExecutorImplTest.kt new file mode 100644 index 00000000000..e04c0954b85 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/common/CommandExecutorImplTest.kt @@ -0,0 +1,147 @@ +package org.oppia.android.scripts.common + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.oppia.android.testing.assertThrows +import java.io.File +import java.io.IOException +import java.lang.IllegalStateException +import java.util.concurrent.TimeUnit + +/** + * Tests for [CommandExecutorImpl]. + * + * Note that this test executes real commands on the local filesystem & requires being run in an + * environment which have echo and rmdir commands. + */ +// Function name: test names are conventionally named with underscores. +@Suppress("FunctionName") +class CommandExecutorImplTest { + @Rule + @JvmField + var tempFolder = TemporaryFolder() + + @Test + fun testExecute_echo_oneArgument_succeedsWithOutput() { + val commandExecutor = CommandExecutorImpl() + + val result = commandExecutor.executeCommand(tempFolder.root, "echo", "value") + + assertThat(result.exitCode).isEqualTo(0) + assertThat(result.output).containsExactly("value") + } + + @Test + fun testExecute_echo_invalidDirectory_throwsException() { + val commandExecutor = CommandExecutorImpl() + + val exception = assertThrows(IllegalStateException::class) { + commandExecutor.executeCommand(File("invaliddirectory"), "echo", "value") + } + + assertThat(exception).hasMessageThat().contains("working directory to be an actual directory") + } + + @Test + fun testExecute_echo_largeOutput_insufficientTimeout_throwsException() { + val commandExecutor = CommandExecutorImpl( + processTimeout = 0L, processTimeoutUnit = TimeUnit.MILLISECONDS + ) + + // Produce a large output so that echo takes a bit longer to reduce the likelihood of this test + // flaking on faster machines. + val largeOutput = "a".repeat(100_000) + val exception = assertThrows(IllegalStateException::class) { + commandExecutor.executeCommand(tempFolder.root, "echo", largeOutput) + } + + // Verify that processes that take too long are killed & result in a failure. + assertThat(exception).hasMessageThat().contains("Process did not finish within") + } + + @Test + fun testExecute_nonexistentCommand_throwsException() { + val commandExecutor = CommandExecutorImpl() + + val exception = assertThrows(IOException::class) { + commandExecutor.executeCommand(tempFolder.root, "commanddoesnotexist") + } + + assertThat(exception).hasMessageThat().contains("commanddoesnotexist") + } + + @Test + fun testExecute_echo_multipleArguments_succeedsWithOutput() { + val commandExecutor = CommandExecutorImpl() + + val result = commandExecutor.executeCommand(tempFolder.root, "echo", "first", "second", "third") + + assertThat(result.exitCode).isEqualTo(0) + assertThat(result.output).containsExactly("first second third") + } + + @Test + fun testExecute_echo_multipleArguments_resultHasCorrectCommand() { + val commandExecutor = CommandExecutorImpl() + + val result = commandExecutor.executeCommand(tempFolder.root, "echo", "first", "second", "third") + + assertThat(result.command).containsExactly("echo", "first", "second", "third") + } + + @Test + fun testExecute_defaultErrorOutput_rmdir_failed_failsWithCombinedOutput() { + val commandExecutor = CommandExecutorImpl() + + val result = commandExecutor.executeCommand(tempFolder.root, "rmdir", "filethatdoesnotexist") + + assertThat(result.exitCode).isNotEqualTo(0) + assertThat(result.output).hasSize(1) + assertThat(result.output.first()).contains("filethatdoesnotexist") + assertThat(result.errorOutput).isEmpty() + } + + @Test + fun testExecute_splitErrorOutput_rmdir_failed_failsWithErrorOutput() { + val commandExecutor = CommandExecutorImpl() + + val result = + commandExecutor.executeCommand( + tempFolder.root, "rmdir", "filethatdoesnotexist", includeErrorOutput = false + ) + + assertThat(result.exitCode).isNotEqualTo(0) + assertThat(result.errorOutput).hasSize(1) + assertThat(result.errorOutput.first()).contains("filethatdoesnotexist") + assertThat(result.output).isEmpty() + } + + @Test + fun testExecute_removeDirectoryInLocalDirectory_succeeds() { + val newFolder = tempFolder.newFolder("newfolder") + val commandExecutor = CommandExecutorImpl() + + val result = commandExecutor.executeCommand(tempFolder.root, "rmdir", "./newfolder") + + // Verify that the command succeeds & the directory is missing. This demonstrates local + // directory referencing is relative to the directory passed to executeCommand. + assertThat(result.exitCode).isEqualTo(0) + assertThat(newFolder.exists()).isFalse() + } + + @Test + fun testExecute_removeUnknownDirectoryInOtherDirectory_fails() { + val newFolder = tempFolder.newFolder("newfolder") + val alternateRoot = tempFolder.newFolder("alternateroot") + val commandExecutor = CommandExecutorImpl() + + val result = commandExecutor.executeCommand(alternateRoot, "rmdir", "./newfolder") + + // Trying to delete the folder somewhere should fail if it doesn't exist there since the command + // executes relative to the provided directory. + assertThat(result.exitCode).isNotEqualTo(0) + assertThat(newFolder.exists()).isTrue() + } +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/common/GitClientTest.kt b/scripts/src/javatests/org/oppia/android/scripts/common/GitClientTest.kt new file mode 100644 index 00000000000..0ec570a1225 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/common/GitClientTest.kt @@ -0,0 +1,257 @@ +package org.oppia.android.scripts.common + +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.oppia.android.scripts.testing.TestGitRepository +import org.oppia.android.testing.assertThrows +import java.io.File +import java.lang.IllegalStateException + +/** + * Tests for [GitClient]. + * + * Note that this test executes real commands on the local filesystem & requires Git in the local + * environment. + */ +// Function name: test names are conventionally named with underscores. +@Suppress("FunctionName") +class GitClientTest { + @Rule + @JvmField + var tempFolder = TemporaryFolder() + + private lateinit var testGitRepository: TestGitRepository + private val commandExecutor by lazy { CommandExecutorImpl() } + + @Before + fun setUp() { + testGitRepository = TestGitRepository(tempFolder, commandExecutor) + } + + @After + fun tearDown() { + // Print the status of the git repository to help with debugging in the cases of test failures + // and to help manually verify the expect git state at the end of each test. + println("git status (at end of test):") + println(testGitRepository.status()) + } + + @Test + fun testCurrentBranch_forNonRepository_throwsException() { + val gitClient = GitClient(tempFolder.root, "develop") + + val exception = assertThrows(IllegalStateException::class) { gitClient.currentBranch } + + assertThat(exception).hasMessageThat().contains("Expected non-zero exit code") + assertThat(exception).hasMessageThat().ignoringCase().contains("not a git repository") + } + + @Test + fun testCurrentBranch_forValidRepository_returnsCorrectBranch() { + initializeRepoWithDevelopBranch() + + val gitClient = GitClient(tempFolder.root, "develop") + val currentBranch = gitClient.currentBranch + + assertThat(currentBranch).isEqualTo("develop") + } + + @Test + fun testCurrentBranch_switchBranch_returnsCorrectBranch() { + initializeRepoWithDevelopBranch() + testGitRepository.checkoutNewBranch("introduce-feature") + + val gitClient = GitClient(tempFolder.root, "develop") + val currentBranch = gitClient.currentBranch + + assertThat(currentBranch).isEqualTo("introduce-feature") + } + + @Test + fun testBranchMergeBase_forDevelopBranch_returnsLatestCommit() { + initializeRepoWithDevelopBranch() + val developHash = getMostRecentCommitOnCurrentBranch() + + val gitClient = GitClient(tempFolder.root, "develop") + val mergeBase = gitClient.branchMergeBase + + assertThat(mergeBase).isEqualTo(developHash) + } + + @Test + fun testBranchMergeBase_forFeatureBranch_returnsCorrectHash() { + initializeRepoWithDevelopBranch() + val developHash = getMostRecentCommitOnCurrentBranch() + testGitRepository.checkoutNewBranch("introduce-feature") + + val gitClient = GitClient(tempFolder.root, "develop") + val mergeBase = gitClient.branchMergeBase + + assertThat(mergeBase).isEqualTo(developHash) + } + + @Test + fun testBranchMergeBase_forFeatureBranch_withCommit_returnsCorrectHash() { + initializeRepoWithDevelopBranch() + val developHash = getMostRecentCommitOnCurrentBranch() + testGitRepository.checkoutNewBranch("introduce-feature") + commitNewFile("example_file") + + val gitClient = GitClient(tempFolder.root, "develop") + val mergeBase = gitClient.branchMergeBase + + // The merge base is the latest common hash between this & the develop branch. + assertThat(mergeBase).isEqualTo(developHash) + } + + @Test + fun testChangedFiles_featureBranch_noChangedFiles_isEmpty() { + initializeRepoWithDevelopBranch() + testGitRepository.checkoutNewBranch("introduce-feature") + + val gitClient = GitClient(tempFolder.root, "develop") + val changedFiles = gitClient.changedFiles + + assertThat(changedFiles).isEmpty() + } + + @Test + fun testChangedFiles_featureBranch_newUntrackedFile_includesFile() { + initializeRepoWithDevelopBranch() + testGitRepository.checkoutNewBranch("introduce-feature") + createNewFile("example_file") + + val gitClient = GitClient(tempFolder.root, "develop") + val changedFiles = gitClient.changedFiles + + assertThat(changedFiles).containsExactly("example_file") + } + + @Test + fun testChangedFiles_featureBranch_stagedFile_includesFile() { + initializeRepoWithDevelopBranch() + testGitRepository.checkoutNewBranch("introduce-feature") + stageNewFile("example_file") + + val gitClient = GitClient(tempFolder.root, "develop") + val changedFiles = gitClient.changedFiles + + assertThat(changedFiles).containsExactly("example_file") + } + + @Test + fun testChangedFiles_featureBranch_committedFile_includesFile() { + initializeRepoWithDevelopBranch() + testGitRepository.checkoutNewBranch("introduce-feature") + commitNewFile("example_file") + + val gitClient = GitClient(tempFolder.root, "develop") + val changedFiles = gitClient.changedFiles + + assertThat(changedFiles).containsExactly("example_file") + } + + @Test + fun testChangedFiles_committedFileOnDevelopBranch_switchToFeatureBranch__doesNotIncludeFile() { + initializeRepoWithDevelopBranch() + commitNewFile("develop_file") + testGitRepository.checkoutNewBranch("introduce-feature") + + val gitClient = GitClient(tempFolder.root, "develop") + val changedFiles = gitClient.changedFiles + + // Committed files to the develop branch are not included since they aren't part of the feature + // branch. + assertThat(changedFiles).isEmpty() + } + + @Test + fun testChangedFiles_featureBranch_changedFile_unstaged_includesFile() { + initializeRepoWithDevelopBranch() + testGitRepository.checkoutNewBranch("introduce-feature") + commitNewFile("example_file") + modifyFile("example_file") + + val gitClient = GitClient(tempFolder.root, "develop") + val changedFiles = gitClient.changedFiles + + assertThat(changedFiles).containsExactly("example_file") + } + + @Test + fun testChangedFiles_featureBranch_deletedFile_includesFile() { + initializeRepoWithDevelopBranch() + commitNewFile("develop_file") + testGitRepository.checkoutNewBranch("introduce-feature") + deleteFile("develop_file") + + val gitClient = GitClient(tempFolder.root, "develop") + val changedFiles = gitClient.changedFiles + + assertThat(changedFiles).containsExactly("develop_file") + } + + @Test + fun testChangedFiles_featureBranch_mixedArrangementOfFiles_includesAllChangedFiles() { + initializeRepoWithDevelopBranch() + commitNewFile("develop_branch_file_not_changed") + commitNewFile("develop_branch_file_changed_not_staged") + commitNewFile("develop_branch_file_removed") + testGitRepository.checkoutNewBranch("introduce-feature") + commitNewFile("new_feature_file_committed") + createNewFile("new_untracked_file") + stageNewFile("new_staged_file") + modifyFile("develop_branch_file_changed_not_staged") + deleteFile("develop_branch_file_removed") + + val gitClient = GitClient(tempFolder.root, "develop") + val changedFiles = gitClient.changedFiles + + assertThat(changedFiles).containsExactly( + "develop_branch_file_changed_not_staged", "develop_branch_file_removed", + "new_feature_file_committed", "new_untracked_file", "new_staged_file" + ) + } + + private fun initializeRepoWithDevelopBranch() { + testGitRepository.init() + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + testGitRepository.checkoutNewBranch("develop") + testGitRepository.commit(message = "Initial commit.", allowEmpty = true) + } + + private fun getMostRecentCommitOnCurrentBranch(): String { + // See https://stackoverflow.com/a/949391 for a reference to validate that this is correct. + return commandExecutor.executeCommand( + tempFolder.root, "git", "rev-parse", "HEAD" + ).output.single() + } + + private fun createNewFile(name: String): File { + return tempFolder.newFile(name).let { file -> + file.writeText("with data") + file + } + } + + private fun stageNewFile(name: String) { + testGitRepository.stageFileForCommit(createNewFile(name)) + } + + private fun commitNewFile(name: String) { + stageNewFile(name) + testGitRepository.commit(message = "Add file $name.") + } + + private fun modifyFile(name: String) { + File(tempFolder.root, name).appendText("More text") + } + + private fun deleteFile(name: String) { + assertThat(File(tempFolder.root, name).delete()).isTrue() // Sanity check. + } +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/testing/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/testing/BUILD.bazel new file mode 100644 index 00000000000..5a18102f1f5 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/testing/BUILD.bazel @@ -0,0 +1,27 @@ +""" +Tests corresponding to script test-only utility libraries. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_test") + +kt_jvm_test( + name = "TestBazelWorkspaceTest", + srcs = ["TestBazelWorkspaceTest.kt"], + deps = [ + "//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", + ], +) + +kt_jvm_test( + name = "TestGitRepositoryTest", + srcs = ["TestGitRepositoryTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/testing:test_git_repository", + "//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/testing/TestBazelWorkspaceTest.kt b/scripts/src/javatests/org/oppia/android/scripts/testing/TestBazelWorkspaceTest.kt new file mode 100644 index 00000000000..6b10ef9c528 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/testing/TestBazelWorkspaceTest.kt @@ -0,0 +1,770 @@ +package org.oppia.android.scripts.testing + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.oppia.android.testing.assertThrows +import java.io.File +import java.lang.AssertionError +import java.lang.IllegalStateException + +/** + * Tests for [TestBazelWorkspace]. + * + * Note also that this suite isn't really sufficient for ensuring that the generated Bazel files are + * actually correct or well formatted. The utility depends on tests that utilize Bazel on the + * filesystem to ensure correctness. This is because otherwise there's a circular logic dependency: + * Bazel code is needed to verify the test Bazel utility, but the test Bazel utility is needed to + * arrange the environment for ensuring that the Bazel code is correct. Rather than utilizing even + * simple queries to ensure correctness, we leverage both specific expectations tested below & other + * tests to ensure the utility is operating correctly. + */ +// Function name: test names are conventionally named with underscores. +@Suppress("FunctionName") +class TestBazelWorkspaceTest { + @Rule + @JvmField + var tempFolder = TemporaryFolder() + + @Test + fun testCreateTestUtility_doesNotImmediatelyCreateAnyFiles() { + TestBazelWorkspace(tempFolder) + + // Simply creating the utility should not create any files. This ensures later tests are + // beginning in a sane state. + assertThat(tempFolder.root.listFiles()).isEmpty() + } + + @Test + fun testInitEmptyWorkspace_emptyDirectory_createsEmptyWorkspace() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + + testBazelWorkspace.initEmptyWorkspace() + + // The WORKSPACE file should now exist, but it won't have any content yet. + val workspaceFile = File(tempFolder.root, "WORKSPACE") + assertThat(workspaceFile.exists()).isTrue() + assertThat(workspaceFile.readLines()).isEmpty() + } + + @Test + fun testInitEmptyWorkspace_fileCreationFails_throwsAssertionError() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + // Delete the WORKSPACE after initializing it--this is what puts the workspace in a bad place. + // Theoretically, the new folder creation could also fail & the underlying assertion check would + // catch this case and fail. + testBazelWorkspace.initEmptyWorkspace() + File(tempFolder.root, "WORKSPACE").delete() + + // Verify that when initializing an empty workspace fails, an AssertionError is thrown (which + // would fail for calling tests). + assertThrows(AssertionError::class) { testBazelWorkspace.initEmptyWorkspace() } + } + + @Test + fun testRootBuildFileProperty_retrieve_createsBuildFile() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + + val buildFile = testBazelWorkspace.rootBuildFile + + // Verify that the BUILD file is a top-level file that exists within the root, but is empty. + assertThat(buildFile.exists()).isTrue() + assertThat(buildFile.name).isEqualTo("BUILD.bazel") + assertThat(buildFile.isRelativeTo(tempFolder.root)).isTrue() + assertThat(buildFile.toRelativeString(tempFolder.root)).isEqualTo("BUILD.bazel") + assertThat(buildFile.readLines()).isEmpty() + } + + @Test + fun testRootBuildFileProperty_retrieve_afterCreateTest_isChanged() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + val originalLength = testBazelWorkspace.rootBuildFile.length() + testBazelWorkspace.createTest(testName = "ExampleTest") + + val buildFile = testBazelWorkspace.rootBuildFile + + assertThat(buildFile.length()).isNotEqualTo(originalLength) + } + + @Test + fun testRootBuildFileProperty_retrieve_afterCreateLibrary_isChanged() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + val originalLength = testBazelWorkspace.rootBuildFile.length() + testBazelWorkspace.createLibrary(dependencyName = "ExampleLib") + + val buildFile = testBazelWorkspace.rootBuildFile + + assertThat(buildFile.length()).isNotEqualTo(originalLength) + } + + @Test + fun testAddTestToBuildFile_reusedTestName_throwsException() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest(testName = "FirstTest") + + val exception = assertThrows(IllegalStateException::class) { + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTestOther.kt") + ) + } + + assertThat(exception).hasMessageThat().contains("Test 'FirstTest' already set up") + } + + @Test + fun testAddTestToBuildFile_firstTest_setsUpWorkspace() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt") + ) + + val workspaceContent = tempFolder.getWorkspaceFile().readAsJoinedString() + assertThat(workspaceContent).contains("kt_register_toolchains()") + } + + @Test + fun testAddTestToBuildFile_firstTest_returnsTestBuildWorkspaceFiles() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + val files = testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt") + ) + + assertThat(files.getFileNames()).containsExactly("WORKSPACE", "BUILD.bazel", "FirstTest.kt") + } + + @Test + fun testAddTestToBuildFile_secondTest_doesNotChangeWorkspace() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt") + ) + val workspaceSize = tempFolder.getWorkspaceFile().length() + + val files = testBazelWorkspace.addTestToBuildFile( + testName = "SecondTest", + testFile = tempFolder.newFile("SecondTest.kt") + ) + + // WORKSPACE not included since it doesn't need to be reinitialized. + assertThat(files.getFileNames()).containsExactly("BUILD.bazel", "SecondTest.kt") + assertThat(workspaceSize).isEqualTo(tempFolder.getWorkspaceFile().length()) + } + + @Test + fun testAddTestToBuildFile_firstTest_initializesBuildFileOnlyForTests() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt") + ) + + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("load\\(.+?kt_jvm_test")).isEqualTo(1) + assertThat(buildContent.countMatches("load\\(.+?kt_jvm_library")).isEqualTo(0) + } + + @Test + fun testAddTestToBuildFile_secondTest_doesNotReinitializeBuildFile() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt") + ) + + testBazelWorkspace.addTestToBuildFile( + testName = "SecondTest", + testFile = tempFolder.newFile("SecondTest.kt") + ) + + // The load line should only exist once in the file. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("load\\(.+?kt_jvm_test")).isEqualTo(1) + } + + @Test + fun testAddTestToBuildFile_unusedTestName_appendsBasicTest() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt") + ) + + // There should be 1 test in the file with empty deps and correct source. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) + assertThat(buildContent).contains("srcs = [\"FirstTest.kt\"]") + assertThat(buildContent).contains("deps = []") + } + + @Test + fun testAddTestToBuildFile_unusedTestName_withGeneratedDep_configuresBuildFileForLibraries() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt"), + withGeneratedDependency = true + ) + + // The build file should now be initialized for libraries. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("load\\(.+?kt_jvm_library")).isEqualTo(1) + } + + @Test + fun testAddTestToBuildFile_unusedTestName_withGeneratedDep_appendsLibraryAndTestWithDep() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt"), + withGeneratedDependency = true + ) + + // Ensure the test is arranged correctly. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) + assertThat(buildContent).contains("srcs = [\"FirstTest.kt\"]") + assertThat(buildContent).contains("deps = [\":FirstTestDependency_lib\",]") + // And the generated library. + assertThat(buildContent.countMatches("kt_jvm_library\\(")).isEqualTo(1) + assertThat(buildContent).contains("srcs = [\"FirstTestDependency.kt\"]") + } + + @Test + fun testAddTestToBuildFile_firstTest_withGeneratedDep_returnsTestDepBuildWorkspaceFiles() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + val files = testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt"), + withGeneratedDependency = true + ) + + assertThat(files.getFileNames()) + .containsExactly("WORKSPACE", "BUILD.bazel", "FirstTest.kt", "FirstTestDependency.kt") + } + + @Test + fun testAddTestToBuildFile_secondTest_withGeneratedDep_returnsTestDepBuildWorkspaceFiles() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt") + ) + + val files = testBazelWorkspace.addTestToBuildFile( + testName = "SecondTest", + testFile = tempFolder.newFile("SecondTest.kt"), + withGeneratedDependency = true + ) + + assertThat(files.getFileNames()) + .containsExactly("BUILD.bazel", "SecondTest.kt", "SecondTestDependency.kt") + } + + @Test + fun testAddTestToBuildFile_unusedTestName_withExtraDep_appendsTestWithDep() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt"), + withExtraDependency = "//:ExtraDep" + ) + + // Ensure the test is arranged correctly. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) + assertThat(buildContent).contains("srcs = [\"FirstTest.kt\"]") + assertThat(buildContent).contains("deps = [\"//:ExtraDep\",]") + } + + @Test + fun testAddTestToBuildFile_unusedTestName_withSubpackage_appendsToSubpackageBuildFile() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + tempFolder.newFolder("subpackage") + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("subpackage/FirstTest.kt"), + subpackage = "subpackage" + ) + + // The root build file shouldn't be changed, but there should be new files in the subpackage + // directory. + val subpackageDirectory = File(tempFolder.root, "subpackage") + assertThat(testBazelWorkspace.rootBuildFile.readLines()).isEmpty() + assertThat(subpackageDirectory.exists()).isTrue() + assertThat(subpackageDirectory.isDirectory).isTrue() + assertThat(File(subpackageDirectory, "BUILD.bazel").exists()).isTrue() + assertThat(File(subpackageDirectory, "FirstTest.kt").exists()).isTrue() + } + + @Test + fun testAddTestToBuildFile_unusedTestName_withSubpackage_returnsNewBuildAndTestFiles() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + tempFolder.newFolder("subpackage") + val files = testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("subpackage/FirstTest.kt"), + subpackage = "subpackage" + ) + + assertThat(files.getRelativeFileNames(tempFolder.root)) + .containsExactly("WORKSPACE", "subpackage/BUILD.bazel", "subpackage/FirstTest.kt") + } + + @Test + fun testAddTestToBuildFile_unusedTestName_withGeneratedAndExtraDeps_includesBothInTestDeps() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.addTestToBuildFile( + testName = "FirstTest", + testFile = tempFolder.newFile("FirstTest.kt"), + withGeneratedDependency = true, + withExtraDependency = "//:ExtraDep" + ) + + // Both dependencies should be included in the test's deps. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) + assertThat(buildContent).contains("deps = [\":FirstTestDependency_lib\",\"//:ExtraDep\",]") + } + + @Test + fun testCreateTest_reusedTestName_throwsException() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest(testName = "FirstTest") + + val exception = assertThrows(IllegalStateException::class) { + testBazelWorkspace.createTest(testName = "FirstTest") + } + + assertThat(exception).hasMessageThat().contains("Test 'FirstTest' already exists") + } + + @Test + fun testCreateTest_firstTest_setsUpWorkspace() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.createTest(testName = "FirstTest") + + val workspaceContent = tempFolder.getWorkspaceFile().readAsJoinedString() + assertThat(workspaceContent).contains("kt_register_toolchains()") + } + + @Test + fun testCreateTest_firstTest_returnsTestBuildWorkspaceFiles() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + val files = testBazelWorkspace.createTest(testName = "FirstTest") + + assertThat(files.getFileNames()).containsExactly("WORKSPACE", "BUILD.bazel", "FirstTest.kt") + } + + @Test + fun testCreateTest_secondTest_doesNotChangeWorkspace() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest(testName = "FirstTest") + val workspaceSize = tempFolder.getWorkspaceFile().length() + + val files = testBazelWorkspace.createTest(testName = "SecondTest") + + // WORKSPACE not included since it doesn't need to be reinitialized. + assertThat(files.getFileNames()).containsExactly("BUILD.bazel", "SecondTest.kt") + assertThat(workspaceSize).isEqualTo(tempFolder.getWorkspaceFile().length()) + } + + @Test + fun testCreateTest_firstTest_initializesBuildFileOnlyForTests() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.createTest(testName = "FirstTest") + + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("load\\(.+?kt_jvm_test")).isEqualTo(1) + assertThat(buildContent.countMatches("load\\(.+?kt_jvm_library")).isEqualTo(0) + } + + @Test + fun testCreateTest_secondTest_doesNotReinitializeBuildFile() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest(testName = "FirstTest") + + testBazelWorkspace.createTest(testName = "SecondTest") + + // The load line should only exist once in the file. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("load\\(.+?kt_jvm_test")).isEqualTo(1) + } + + @Test + fun testCreateTest_unusedTestName_appendsBasicTest() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.createTest(testName = "FirstTest") + + // There should be 1 test in the file with empty deps and correct source. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) + assertThat(buildContent).contains("srcs = [\"FirstTest.kt\"]") + assertThat(buildContent).contains("deps = []") + } + + @Test + fun testCreateTest_unusedTestName_withGeneratedDep_configuresBuildFileForLibraries() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.createTest( + testName = "FirstTest", + withGeneratedDependency = true + ) + + // The build file should now be initialized for libraries. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("load\\(.+?kt_jvm_library")).isEqualTo(1) + } + + @Test + fun testCreateTest_unusedTestName_withGeneratedDep_appendsLibraryAndTestWithDep() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.createTest( + testName = "FirstTest", + withGeneratedDependency = true + ) + + // Ensure the test is arranged correctly. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) + assertThat(buildContent).contains("srcs = [\"FirstTest.kt\"]") + assertThat(buildContent).contains("deps = [\":FirstTestDependency_lib\",]") + // And the generated library. + assertThat(buildContent.countMatches("kt_jvm_library\\(")).isEqualTo(1) + assertThat(buildContent).contains("srcs = [\"FirstTestDependency.kt\"]") + } + + @Test + fun testCreateTest_firstTest_withGeneratedDep_returnsTestDepBuildWorkspaceFiles() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + val files = testBazelWorkspace.createTest( + testName = "FirstTest", + withGeneratedDependency = true + ) + + assertThat(files.getFileNames()) + .containsExactly("WORKSPACE", "BUILD.bazel", "FirstTest.kt", "FirstTestDependency.kt") + } + + @Test + fun testCreateTest_secondTest_withGeneratedDep_returnsTestDepBuildWorkspaceFiles() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createTest(testName = "FirstTest") + + val files = testBazelWorkspace.createTest( + testName = "SecondTest", + withGeneratedDependency = true + ) + + assertThat(files.getFileNames()) + .containsExactly("BUILD.bazel", "SecondTest.kt", "SecondTestDependency.kt") + } + + @Test + fun testCreateTest_unusedTestName_withExtraDep_appendsTestWithDep() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.createTest(testName = "FirstTest", withExtraDependency = "//:ExtraDep") + + // Ensure the test is arranged correctly. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) + assertThat(buildContent).contains("srcs = [\"FirstTest.kt\"]") + assertThat(buildContent).contains("deps = [\"//:ExtraDep\",]") + } + + @Test + fun testCreateTest_unusedTestName_withSubpackage_appendsToSubpackageBuildFile() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.createTest(testName = "FirstTest", subpackage = "subpackage") + + // The root build file shouldn't be changed, but there should be new files in the subpackage + // directory. + val subpackageDirectory = File(tempFolder.root, "subpackage") + assertThat(testBazelWorkspace.rootBuildFile.readLines()).isEmpty() + assertThat(subpackageDirectory.exists()).isTrue() + assertThat(subpackageDirectory.isDirectory).isTrue() + assertThat(File(subpackageDirectory, "BUILD.bazel").exists()).isTrue() + assertThat(File(subpackageDirectory, "FirstTest.kt").exists()).isTrue() + } + + @Test + fun testCreateTest_unusedTestName_withSubpackage_returnsNewBuildAndTestFiles() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + val files = testBazelWorkspace.createTest(testName = "FirstTest", subpackage = "subpackage") + + assertThat(files.getRelativeFileNames(tempFolder.root)) + .containsExactly("WORKSPACE", "subpackage/BUILD.bazel", "subpackage/FirstTest.kt") + } + + @Test + fun testCreateTest_unusedTestName_withGeneratedAndExtraDeps_includesBothInTestDeps() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.createTest( + testName = "FirstTest", + withGeneratedDependency = true, + withExtraDependency = "//:ExtraDep" + ) + + // Both dependencies should be included in the test's deps. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) + assertThat(buildContent).contains("deps = [\":FirstTestDependency_lib\",\"//:ExtraDep\",]") + } + + @Test + fun testCreateLibrary_firstLib_unusedName_configuresWorkspaceAndBuild() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.createLibrary(dependencyName = "ExampleDep") + + val workspaceContent = tempFolder.getWorkspaceFile().readAsJoinedString() + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(workspaceContent).contains("kt_register_toolchains()") + assertThat(buildContent.countMatches("load\\(.+?kt_jvm_library")).isEqualTo(1) + } + + @Test + fun testCreateLibrary_firstLib_unusedName_appendsJvmLibraryDeclaration() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + testBazelWorkspace.createLibrary(dependencyName = "ExampleDep") + + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("kt_jvm_library\\(")).isEqualTo(1) + assertThat(buildContent).contains("name = \"ExampleDep_lib\"") + assertThat(buildContent).contains("srcs = [\"ExampleDep.kt\"]") + } + + @Test + fun testCreateLibrary_firstLib_unusedName_returnsBuildLibAndWorkspaceFilesWithTargetName() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + + val (targetName, files) = testBazelWorkspace.createLibrary(dependencyName = "ExampleDep") + + assertThat(targetName).isEqualTo("ExampleDep_lib") + assertThat(files.getFileNames()).containsExactly("WORKSPACE", "BUILD.bazel", "ExampleDep.kt") + } + + @Test + fun testCreateLibrary_secondLib_unusedName_doesNotChangeWorkspace() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createLibrary(dependencyName = "FirstLib") + val workspaceSize = tempFolder.getWorkspaceFile().length() + + val (_, files) = testBazelWorkspace.createLibrary(dependencyName = "SecondLib") + + assertThat(files.getFileNames()).doesNotContain("WORKSPACE") + assertThat(workspaceSize).isEqualTo(tempFolder.getWorkspaceFile().length()) + } + + @Test + fun testCreateLibrary_secondLib_unusedName_appendsJvmLibraryDeclaration() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createLibrary(dependencyName = "FirstLib") + + testBazelWorkspace.createLibrary(dependencyName = "SecondLib") + + // The kt_jvm_library declaration should only exist once, and both libraries should exist. + val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() + assertThat(buildContent.countMatches("load\\(.+?kt_jvm_library")).isEqualTo(1) + assertThat(buildContent.countMatches("kt_jvm_library\\(")).isEqualTo(2) + assertThat(buildContent).contains("name = \"FirstLib_lib\"") + assertThat(buildContent).contains("srcs = [\"FirstLib.kt\"]") + assertThat(buildContent).contains("name = \"SecondLib_lib\"") + assertThat(buildContent).contains("srcs = [\"SecondLib.kt\"]") + } + + @Test + fun testCreateLibrary_secondLib_reusedName_throwsException() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.initEmptyWorkspace() + testBazelWorkspace.createLibrary(dependencyName = "FirstLib") + + val exception = assertThrows(IllegalStateException::class) { + testBazelWorkspace.createLibrary(dependencyName = "FirstLib") + } + + assertThat(exception).hasMessageThat().contains("Library 'FirstLib' already exists") + } + + @Test + fun testWorkspaceInitialization_createLibrary_thenTest_workspaceInitedForKotlinForLibrary() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.createLibrary(dependencyName = "FirstLib") + + testBazelWorkspace.createTest(testName = "FirstTest") + + // The workspace should only be configured once (due to the library initialization). + val workspaceContent = tempFolder.getWorkspaceFile().readAsJoinedString() + assertThat(workspaceContent.countMatches("http_archive\\(")).isEqualTo(1) + } + + @Test + fun testWorkspaceInitialization_createTest_thenLibrary_workspaceInitedForKotlinForTest() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.createTest(testName = "FirstTest") + + testBazelWorkspace.createLibrary(dependencyName = "FirstLib") + + // The workspace should only be configured once (due to the test initialization). + val workspaceContent = tempFolder.getWorkspaceFile().readAsJoinedString() + assertThat(workspaceContent.countMatches("http_archive\\(")).isEqualTo(1) + } + + @Test + fun testRetrieveTestFile_noTest_throwsException() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + + // A non-existent test file cannot be retrieved. + assertThrows(NoSuchElementException::class) { + testBazelWorkspace.retrieveTestFile(testName = "Invalid") + } + } + + @Test + fun testRetrieveTestFile_forRealTest_returnsFileForTest() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.createTest(testName = "ExampleTest") + + val testFile = testBazelWorkspace.retrieveTestFile(testName = "ExampleTest") + + assertThat(testFile.exists()).isTrue() + assertThat(testFile.isRelativeTo(tempFolder.root)).isTrue() + assertThat(testFile.name).isEqualTo("ExampleTest.kt") + } + + @Test + fun testRetrieveLibraryFile_noLibrary_throwsException() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + + // A non-existent library file cannot be retrieved. + assertThrows(NoSuchElementException::class) { + testBazelWorkspace.retrieveLibraryFile(dependencyName = "Invalid") + } + } + + @Test + fun testRetrieveLibraryFile_forRealLibrary_returnsFileForLibrary() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.createLibrary(dependencyName = "ExampleLib") + + val libFile = testBazelWorkspace.retrieveLibraryFile(dependencyName = "ExampleLib") + + assertThat(libFile.exists()).isTrue() + assertThat(libFile.isRelativeTo(tempFolder.root)).isTrue() + assertThat(libFile.name).isEqualTo("ExampleLib.kt") + } + + @Test + fun testRetrieveTestDependencyFile_noTest_throwsExceptionWithHelpfulMessage() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + + val exception = assertThrows(IllegalStateException::class) { + testBazelWorkspace.retrieveTestDependencyFile(testName = "Invalid") + } + + assertThat(exception).hasMessageThat().contains("No entry for 'Invalid'.") + assertThat(exception).hasMessageThat().contains("Was the test created without dependencies?") + } + + @Test + fun testRetrieveTestDependencyFile_testWithoutGeneratedDep_throwsExceptionWithHelpfulMessage() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.createTest("ValidWithoutDep") + + val exception = assertThrows(IllegalStateException::class) { + testBazelWorkspace.retrieveTestDependencyFile(testName = "ValidWithoutDep") + } + + // Since the test does not have a generated dependency, there is no entry to retrieve. + assertThat(exception).hasMessageThat().contains("No entry for 'ValidWithoutDep'.") + assertThat(exception).hasMessageThat().contains("Was the test created without dependencies?") + } + + @Test + fun testRetrieveTestDependencyFile_testWithGeneratedDep_returnsFileForTest() { + val testBazelWorkspace = TestBazelWorkspace(tempFolder) + testBazelWorkspace.createTest("ValidWithDep", withGeneratedDependency = true) + + val libFile = testBazelWorkspace.retrieveTestDependencyFile(testName = "ValidWithDep") + + assertThat(libFile.exists()).isTrue() + assertThat(libFile.isRelativeTo(tempFolder.root)).isTrue() + assertThat(libFile.name).isEqualTo("ValidWithDepDependency.kt") + } + + private fun TemporaryFolder.getWorkspaceFile(): File = File(root, "WORKSPACE") + + private fun File.readAsJoinedString(): String = readLines().joinToString(separator = "\n") + + private fun File.isRelativeTo(base: File): Boolean = relativeToOrNull(base) != null + + private fun Iterable.getFileNames(): List = map { it.name } + + private fun Iterable.getRelativeFileNames(root: File): List = map { + it.toRelativeString(root) + } + + private fun String.countMatches(regex: String): Int = Regex(regex).findAll(this).toList().size +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/testing/TestGitRepositoryTest.kt b/scripts/src/javatests/org/oppia/android/scripts/testing/TestGitRepositoryTest.kt new file mode 100644 index 00000000000..59aa28bdc84 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/testing/TestGitRepositoryTest.kt @@ -0,0 +1,506 @@ +package org.oppia.android.scripts.testing + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.oppia.android.scripts.common.CommandExecutor +import org.oppia.android.scripts.common.CommandExecutorImpl +import org.oppia.android.scripts.common.CommandResult +import org.oppia.android.testing.assertThrows +import java.io.File +import java.util.UUID + +/** + * Tests for [TestGitRepository]. + * + * Note that this test suite operates similarly to the one for TestBazelWorkspace in that it relies + * on how it's used with other tests to ensure correctness. However, it also utilizes trivial and + * well understood Git commands to ensure the utility is performing the operations as expected. This + * does result in the suite largely testing Git itself for an otherwise simple utility. While true, + * this suite is meant to ensure the contract of [TestGitRepository] is enforced and that utility + * behaves exactly in the way guaranteed per this test suite. + * + * Finally, as indicated above, this test depends both on a real filesystem and requires the + * presence of Git in the user's local environment. One consequence is that since Git's version is + * not configurable at the project level, subtle differences in different Git installations or + * inconsistencies across versions could result in failures in this test. The team will refine these + * tests over time to try and be as broadly inclusive for different Git clients/versions as + * possible. + */ +// Function name: test names are conventionally named with underscores. +@Suppress("FunctionName") +class TestGitRepositoryTest { + @Rule + @JvmField + var tempFolder = TemporaryFolder() + + private val commandExecutorInterceptor by lazy { CommandExecutorInterceptor() } + + @Test + fun testCreateTestUtility_doesNotImmediatelyCreateAnyFiles() { + TestGitRepository(tempFolder, commandExecutorInterceptor) + + // Simply creating the utility should not create any files. This ensures later tests are + // beginning in a sane state. + assertThat(tempFolder.root.listFiles()).isEmpty() + } + + @Test + fun testInit_newDirectory_initializesRepository() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + + testGitRepository.init() + + assertThat(tempFolder.root.list().toList()).containsExactly(".git") + } + + @Test + fun testSetUser_noGitRepository_throwsAssertionError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + + val error = assertThrows(AssertionError::class) { + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + } + + assertThat(error).hasMessageThat().contains("not in a git directory") + } + + @Test + fun testSetUser_validGitRepository_setsCorrectEmail() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + + val email = executeGitCommand("config", "user.email").getOnlyOutputLine() + assertThat(email).isEqualTo("test@oppia.org") + } + + @Test + fun testSetUser_validGitRepository_setsCorrectName() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + + val email = executeGitCommand("config", "user.name").getOnlyOutputLine() + assertThat(email).isEqualTo("Test User") + } + + @Test + fun testCheckOutNewBranch_notGitRepository_throwsAssertionError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + + val error = assertThrows(AssertionError::class) { + testGitRepository.checkoutNewBranch("develop") + } + + assertThat(error).hasMessageThat().ignoringCase().contains("not a git repository") + } + + @Test + fun testCheckOutNewBranch_validGitRepository_newBranch_createsAndSwitchesToBranch() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + + testGitRepository.checkoutNewBranch("develop") + + val output = commandExecutorInterceptor.getLastCommandResult().getOutputAsJoinedString() + assertThat(output).contains("Switched to a new branch") + } + + @Test + fun testStageFileForCommit_nonexistentFile_throwsAssertionError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + + val error = assertThrows(AssertionError::class) { + testGitRepository.stageFileForCommit(File(tempFolder.root, "fake_file")) + } + + assertThat(error).hasMessageThat().contains("did not match any files") + } + + @Test + fun testStageFileForCommit_newFile_stagesFileForAdding() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + + testGitRepository.stageFileForCommit(tempFolder.newFile("example_file")) + + val status = executeGitCommand("status").getOutputAsJoinedString() + assertThat(status).contains("Changes to be committed") + assertThat(status).containsMatch("new file:.+?example_file") + } + + @Test + fun testStageFilesForCommit_emptyList_doesNothing() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + + testGitRepository.stageFilesForCommit(listOf()) + + val status = executeGitCommand("status").getOutputAsJoinedString() + assertThat(status).contains("nothing to commit") + } + + @Test + fun testStageFilesForCommit_oneNewFile_stagesFile() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + + testGitRepository.stageFilesForCommit(listOf(tempFolder.newFile("example_file"))) + + val status = executeGitCommand("status").getOutputAsJoinedString() + assertThat(status).contains("Changes to be committed") + assertThat(status).containsMatch("new file:.+?example_file") + } + + @Test + fun testStageFilesForCommit_multipleFiles_stagesFilesForAdding() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + + testGitRepository.stageFilesForCommit( + listOf( + tempFolder.newFile("new_file1"), + tempFolder.newFile("new_file2"), + tempFolder.newFile("new_file3") + ) + ) + + val status = executeGitCommand("status").getOutputAsJoinedString() + assertThat(status).contains("Changes to be committed") + assertThat(status).containsMatch("new file:.+?new_file1") + assertThat(status).containsMatch("new file:.+?new_file2") + assertThat(status).containsMatch("new file:.+?new_file3") + } + + @Test + fun testStageFilesForCommit_multipleFiles_oneDoesNotExist_throwsAssertionError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + + val error = assertThrows(AssertionError::class) { + testGitRepository.stageFilesForCommit( + listOf( + tempFolder.newFile("new_file1"), + File(tempFolder.root, "nonexistent_file"), + tempFolder.newFile("new_file2") + ) + ) + } + + assertThat(error).hasMessageThat().contains("did not match any files") + } + + @Test + fun testRemoveFileForCommit_nonexistentFile_throwsAssertionError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + + val error = assertThrows(AssertionError::class) { + testGitRepository.removeFileForCommit(File(tempFolder.root, "nonexistent_file")) + } + + assertThat(error).hasMessageThat().contains("did not match any files") + } + + @Test + fun testRemoveFileForCommit_untrackedFile_throwsAssertionError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + tempFolder.newFile("untracked_file") + + val error = assertThrows(AssertionError::class) { + testGitRepository.removeFileForCommit(File(tempFolder.root, "untracked_file")) + } + + assertThat(error).hasMessageThat().contains("did not match any files") + } + + @Test + fun testRemoveFileForCommit_committedFile_stagesFileForRemovalAndRemovesFileFromFilesystem() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + testGitRepository.checkoutNewBranch("develop") + testGitRepository.stageFileForCommit(tempFolder.newFile("committed_file")) + testGitRepository.commit("Commit new file.") + + testGitRepository.removeFileForCommit(File(tempFolder.root, "committed_file")) + + val status = executeGitCommand("status").getOutputAsJoinedString() + assertThat(status).contains("Changes to be committed") + assertThat(status).containsMatch("deleted:.+?committed_file") + } + + @Test + fun testMoveFileForCommit_oldFileDoesNotExist_throwsAssertionError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + + val error = assertThrows(AssertionError::class) { + testGitRepository.moveFileForCommit( + File(tempFolder.root, "nonexistent_file"), File(tempFolder.root, "new_file") + ) + } + + assertThat(error).hasMessageThat().contains("bad source") + } + + @Test + fun testMoveFileForCommit_oldFileIsUntracked_throwsAssertionError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + tempFolder.newFile("untracked_file") + + val error = assertThrows(AssertionError::class) { + testGitRepository.moveFileForCommit( + File(tempFolder.root, "untracked_file"), File(tempFolder.root, "new_file") + ) + } + + assertThat(error).hasMessageThat().contains("not under version control") + } + + @Test + fun testMoveFileForCommit_oldFileCommitted_createsNewFileAndStagesItForMove() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + testGitRepository.checkoutNewBranch("develop") + testGitRepository.stageFileForCommit(tempFolder.newFile("committed_file")) + testGitRepository.commit("Commit new file.") + + testGitRepository.moveFileForCommit( + File(tempFolder.root, "committed_file"), File(tempFolder.root, "moved_file") + ) + + // Verify that the moved file was actually moved, and verify via Git status. + assertThat(File(tempFolder.root, "committed_file").exists()).isFalse() + assertThat(File(tempFolder.root, "moved_file").exists()).isTrue() + + val status = executeGitCommand("status").getOutputAsJoinedString() + assertThat(status).contains("Changes to be committed") + assertThat(status).containsMatch("renamed:.+?committed_file.+?moved_file") + } + + @Test + fun testCommit_noUser_throwsAssertionError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + testGitRepository.stageFileForCommit(tempFolder.newFile("file_to_be_committed")) + + val error = assertThrows(AssertionError::class) { + testGitRepository.commit("Commit new file.") + } + + assertThat(error).hasMessageThat().contains("Please tell me who you are") + } + + @Test + fun testCommit_doNotAllowEmptyCommit_nothingStaged_throwsAssertionError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + + val error = assertThrows(AssertionError::class) { + testGitRepository.commit("Attempting empty commit.", allowEmpty = false) + } + + assertThat(error).hasMessageThat().contains("nothing to commit") + } + + @Test + fun testCommit_allowEmptyCommit_nothingStaged_createsEmptyCommitWithMessage() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + + testGitRepository.commit("Attempting empty commit.", allowEmpty = true) + + val log = executeGitCommand("log").getOutputAsJoinedString() + assertThat(log).contains("Attempting empty commit.") + } + + @Test + fun testCommit_filesStaged_createsCommitWithMessage() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + testGitRepository.stageFileForCommit(tempFolder.newFile("file_to_commit")) + + testGitRepository.commit("Committing new file.") + + // Verify that the file was committed & that the commit exists. + val status = executeGitCommand("status").getOutputAsJoinedString() + val log = executeGitCommand("log").getOutputAsJoinedString() + assertThat(status).contains("nothing to commit") + assertThat(log).contains("Committing new file.") + assertThat(File(tempFolder.root, "file_to_commit").exists()).isTrue() + } + + @Test + fun testStatus_noGitRepository_hasStatusWithError() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + + val status = testGitRepository.status() + + assertThat(status).ignoringCase().contains("not a git repository") + } + + @Test + fun testStatus_onBranch_nothingStaged_statusEmpty() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + + val status = testGitRepository.status() + + assertThat(status).contains("nothing to commit") + } + + @Test + fun testStatus_afterStageFileForAdd_statusIncludesStagedFile() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.checkoutNewBranch("develop") + testGitRepository.stageFileForCommit(tempFolder.newFile("staged_file")) + + val status = testGitRepository.status() + + assertThat(status).contains("Changes to be committed") + assertThat(status).containsMatch("new file:.+?staged_file") + } + + @Test + fun testStatus_afterStageFileForDelete_statusIncludesStagedFile() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + testGitRepository.checkoutNewBranch("develop") + testGitRepository.stageFileForCommit(tempFolder.newFile("committed_file")) + testGitRepository.commit("Commit new file.") + testGitRepository.removeFileForCommit(File(tempFolder.root, "committed_file")) + + val status = testGitRepository.status() + + assertThat(status).contains("Changes to be committed") + assertThat(status).containsMatch("deleted:.+?committed_file") + } + + @Test + fun testStatus_afterStageFileForMove_statusIncludesFileForMove() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + testGitRepository.checkoutNewBranch("develop") + testGitRepository.stageFileForCommit(tempFolder.newFile("committed_file")) + testGitRepository.commit("Commit new file.") + testGitRepository.moveFileForCommit( + File(tempFolder.root, "committed_file"), File(tempFolder.root, "moved_file") + ) + + val status = testGitRepository.status() + + assertThat(status).contains("Changes to be committed") + assertThat(status).containsMatch("renamed:.+?committed_file.+?moved_file") + } + + @Test + fun testStatus_multipleFilesStaged_statusIncludesAll() { + // Note that the test files in this test require content so that Git doesn't think the + // delete/add is a move. + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + testGitRepository.checkoutNewBranch("develop") + testGitRepository.stageFileForCommit(generateFileWithRandomContent("file_to_remove")) + testGitRepository.stageFileForCommit(generateFileWithRandomContent("committed_file")) + testGitRepository.commit("Commit new files.") + testGitRepository.stageFileForCommit(generateFileWithRandomContent("staged_file")) + testGitRepository.removeFileForCommit(File(tempFolder.root, "file_to_remove")) + testGitRepository.moveFileForCommit( + File(tempFolder.root, "committed_file"), File(tempFolder.root, "moved_file") + ) + + val status = testGitRepository.status() + + assertThat(status).contains("Changes to be committed") + assertThat(status).containsMatch("new file:.+?staged_file") + assertThat(status).containsMatch("renamed:.+?committed_file.+?moved_file") + assertThat(status).containsMatch("deleted:.+?file_to_remove") + } + + @Test + fun testStatus_multipleFilesStaged_committed_statusIsEmpty() { + val testGitRepository = TestGitRepository(tempFolder, commandExecutorInterceptor) + testGitRepository.init() + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + testGitRepository.checkoutNewBranch("develop") + testGitRepository.stageFileForCommit(tempFolder.newFile("committed_file1")) + testGitRepository.stageFileForCommit(tempFolder.newFile("committed_file2")) + testGitRepository.commit("Commit new files.") + + val status = testGitRepository.status() + + assertThat(status).contains("nothing to commit") + } + + private fun executeGitCommand(vararg args: String): CommandResult = + commandExecutorInterceptor.executeCommand(tempFolder.root, "git", *args) + + private fun CommandResult.getOnlyOutputLine(): String = output.single() + + private fun CommandResult.getOutputAsJoinedString(): String = + output.joinToString(separator = "\n") + + private fun generateFileWithRandomContent(name: String): File { + val file = tempFolder.newFile(name) + file.writeText(UUID.randomUUID().toString()) + return file + } + + private class CommandExecutorInterceptor : CommandExecutor { + private val commandResults = mutableListOf() + private val realCommandExecutor by lazy { CommandExecutorImpl() } + + override fun executeCommand( + workingDir: File, + command: String, + vararg arguments: String, + includeErrorOutput: Boolean + ): CommandResult { + val result = + realCommandExecutor.executeCommand( + workingDir, + command, + *arguments, + includeErrorOutput = includeErrorOutput + ) + commandResults += result + return result + } + + /** + * Returns the [CommandResult] of the most recent command executed by this executor, or throws + * an exception if none have yet been executed. + */ + fun getLastCommandResult(): CommandResult = commandResults.last() + } +} diff --git a/testing/src/main/java/org/oppia/android/testing/mockito/BUILD.bazel b/testing/src/main/java/org/oppia/android/testing/mockito/BUILD.bazel index 41b818ab147..009aa532a12 100644 --- a/testing/src/main/java/org/oppia/android/testing/mockito/BUILD.bazel +++ b/testing/src/main/java/org/oppia/android/testing/mockito/BUILD.bazel @@ -12,7 +12,10 @@ kt_android_library( srcs = [ "MockitoKotlinHelper.kt", ], - visibility = ["//:oppia_testing_visibility"], + visibility = [ + "//:oppia_testing_visibility", + "//scripts:oppia_script_test_visibility", + ], deps = [ "//third_party:org_mockito_mockito-core", ],