diff --git a/CHANGELOG.md b/CHANGELOG.md index 930ec8d2bb..3dd1b5644f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Added - Use Gradle JVM toolchain with language version 8 to compile the project +- Basic tests for CLI ([#540](https://github.com/pinterest/ktlint/issues/540)) ### Fixed - Fix false positive in rule spacing-between-declarations-with-annotations ([#1281](https://github.com/pinterest/ktlint/issues/1281)) diff --git a/build.gradle b/build.gradle index 54ff1c5740..934ae6bda5 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,9 @@ ext.deps = [ 'picocli' : 'info.picocli:picocli:3.9.6', // Testing libraries 'junit' : 'junit:junit:4.13.1', + 'junit5Api' : 'org.junit.jupiter:junit-jupiter-api:5.8.2', + 'junit5Jupiter' : 'org.junit.jupiter:junit-jupiter-engine:5.8.2', + 'junit5Vintage' : 'org.junit.vintage:junit-vintage-engine:5.8.2', 'assertj' : 'org.assertj:assertj-core:3.12.2', 'sarif4k' : 'io.github.detekt.sarif4k:sarif4k:0.0.1', 'jimfs' : 'com.google.jimfs:jimfs:1.1' @@ -45,7 +48,7 @@ task ktlint(type: JavaExec, group: LifecycleBasePlugin.VERIFICATION_GROUP) { description = "Check Kotlin code style." classpath = configurations.ktlint main = 'com.pinterest.ktlint.Main' - args '**/src/**/*.kt', '--baseline=ktlint-baseline.xml', '--verbose' + args '**/src/**/*.kt', '!**/resources/cli/**', '--baseline=ktlint-baseline.xml', '--verbose' } /** diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 5db5ecb563..4d62381522 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -128,6 +128,8 @@ + + diff --git a/ktlint/build.gradle b/ktlint/build.gradle index 40396a900f..56bc0700d5 100644 --- a/ktlint/build.gradle +++ b/ktlint/build.gradle @@ -35,8 +35,12 @@ dependencies { implementation deps.picocli testImplementation deps.junit + testImplementation deps.junit5Api testImplementation deps.assertj testImplementation deps.jimfs + + testRuntimeOnly deps.junit5Jupiter + testRuntimeOnly deps.junit5Vintage } // Implements https://github.com/brianm/really-executable-jars-maven-plugin maven plugin behaviour. @@ -75,3 +79,19 @@ tasks.register("shadowJarExecutableChecksum", Checksum.class) { algorithm = Checksum.Algorithm.MD5 } + +tasks.withType(Test).configureEach { + it.dependsOn(shadowJarExecutableTask) + it.useJUnitPlatform() + + doFirst { + it.systemProperty( + "ktlint-cli", + shadowJarExecutableTask.get().outputs.files.find { it.name == "ktlint" }.absolutePath + ) + it.systemProperty( + "ktlint-version", + version + ) + } +} diff --git a/ktlint/src/test/kotlin/com/pinterest/ktlint/BaseCLITest.kt b/ktlint/src/test/kotlin/com/pinterest/ktlint/BaseCLITest.kt new file mode 100644 index 0000000000..5159f5983b --- /dev/null +++ b/ktlint/src/test/kotlin/com/pinterest/ktlint/BaseCLITest.kt @@ -0,0 +1,116 @@ +package com.pinterest.ktlint + +import java.io.File +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes +import java.util.concurrent.TimeUnit +import org.junit.jupiter.api.io.TempDir + +abstract class BaseCLITest { + private val ktlintCli: String = System.getProperty("ktlint-cli") + + @TempDir + private lateinit var tempDir: Path + + fun runKtLintCliProcess( + testProjectName: String, + arguments: List = emptyList(), + executionAssertions: ExecutionResult.() -> Unit + ) { + val projectPath = prepareTestProject(testProjectName) + val ktlintCommand = "$ktlintCli ${arguments.joinToString()}" + // Forking in a new shell process, so 'ktlint' will pickup new 'PATH' env variable value + val pb = ProcessBuilder("/bin/sh", "-c", ktlintCommand) + pb.directory(projectPath.toAbsolutePath().toFile()) + + // Overriding user path to java executable to use java version test is running on + val environment = pb.environment() + environment["PATH"] = "${System.getProperty("java.home")}${File.separator}bin${File.pathSeparator}${System.getenv()["PATH"]}" + + val process = pb.start() + val output = process.inputStream.bufferedReader().use { it.readLines() } + val error = process.errorStream.bufferedReader().use { it.readLines() } + process.waitFor(WAIT_TIME_SEC, TimeUnit.SECONDS) + + executionAssertions(ExecutionResult(process.exitValue(), output, error, projectPath)) + + process.destroy() + } + + private fun prepareTestProject(testProjectName: String): Path { + val testProjectPath = testProjectsPath.resolve(testProjectName) + assert(Files.exists(testProjectPath)) { + "Test project $testProjectName does not exist!" + } + + return tempDir.resolve(testProjectName).also { testProjectPath.copyRecursively(it) } + } + + private fun Path.copyRecursively(dest: Path) { + Files.walkFileTree( + this, + object : SimpleFileVisitor() { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + Files.createDirectories(dest.resolve(relativize(dir))) + return FileVisitResult.CONTINUE + } + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + Files.copy(file, dest.resolve(relativize(file))) + return FileVisitResult.CONTINUE + } + } + ) + } + + data class ExecutionResult( + val exitCode: Int, + val normalOutput: List, + val errorOutput: List, + val testProject: Path + ) { + fun assertNormalExitCode() { + assert(exitCode == 0) { + "Execution was not finished normally: $exitCode" + } + } + + fun assertErrorExitCode() { + assert(exitCode == 1) { + "Execution was finished without error: $exitCode" + } + } + + fun assertErrorOutputIsEmpty() { + assert(errorOutput.isEmpty()) { + "Error output contains following lines:\n${errorOutput.joinToString(separator = "\n")}" + } + } + + fun assertSourceFileWasFormatted( + filePathInProject: String + ) { + val originalFile = testProjectsPath.resolve(testProject.last()).resolve(filePathInProject) + val newFile = testProject.resolve(filePathInProject) + + assert(originalFile.toFile().readText() != newFile.toFile().readText()) { + "Format did not change source file $filePathInProject content:\n${originalFile.toFile().readText()}" + } + } + } + + companion object { + private const val WAIT_TIME_SEC = 3L + val testProjectsPath: Path = Paths.get("src", "test", "resources", "cli") + } +} diff --git a/ktlint/src/test/kotlin/com/pinterest/ktlint/SimpleCLITest.kt b/ktlint/src/test/kotlin/com/pinterest/ktlint/SimpleCLITest.kt new file mode 100644 index 0000000000..b6d94c2466 --- /dev/null +++ b/ktlint/src/test/kotlin/com/pinterest/ktlint/SimpleCLITest.kt @@ -0,0 +1,84 @@ +package com.pinterest.ktlint + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledOnOs +import org.junit.jupiter.api.condition.OS + +@DisabledOnOs(OS.WINDOWS) +@DisplayName("CLI basic checks") +class SimpleCLITest : BaseCLITest() { + + @DisplayName("Should print help") + @Test + fun shouldOutputHelp() { + runKtLintCliProcess( + "no-code-style-error", + listOf("--help") + ) { + assertNormalExitCode() + assertErrorOutputIsEmpty() + + assert(normalOutput.contains("Usage:") && normalOutput.contains("Examples:")) { + "Did not produced help output!\n ${normalOutput.joinToString(separator = "\n")}" + } + } + } + + @DisplayName("Should print correct version") + @Test + fun shouldCorrectlyPrintVersion() { + runKtLintCliProcess( + "no-code-style-error", + listOf("--version") + ) { + assertNormalExitCode() + assertErrorOutputIsEmpty() + + val expectedVersion = System.getProperty("ktlint-version") + assert(normalOutput.contains(expectedVersion)) { + "Output did not contain expected $expectedVersion version:\n ${normalOutput.joinToString(separator = "\n")}" + } + } + } + + @DisplayName("Should complete lint without errors") + @Test + internal fun lintWithoutErrors() { + runKtLintCliProcess( + "no-code-style-error" + ) { + assertNormalExitCode() + assertErrorOutputIsEmpty() + } + } + + @DisplayName("Should complete lint with error") + @Test + internal fun lintWithError() { + runKtLintCliProcess( + "too-many-empty-lines" + ) { + assertErrorExitCode() + + assert(normalOutput.find { it.contains("Needless blank line(s)") } != null) { + "Unexpected output:\n${normalOutput.joinToString(separator = "\n")}" + } + } + } + + @DisplayName("Should format without errors") + @Test + internal fun formatWorks() { + runKtLintCliProcess( + "too-many-empty-lines", + listOf("-F") + ) { + assertNormalExitCode() + // on JDK11+ contains warning about illegal reflective access operation + // assertErrorOutputIsEmpty() + + assertSourceFileWasFormatted("main.kt") + } + } +} diff --git a/ktlint/src/test/resources/cli/no-code-style-error/main.kt b/ktlint/src/test/resources/cli/no-code-style-error/main.kt new file mode 100644 index 0000000000..09c2a7d582 --- /dev/null +++ b/ktlint/src/test/resources/cli/no-code-style-error/main.kt @@ -0,0 +1,5 @@ +package test + +fun main() { + println("Hello world!") +} diff --git a/ktlint/src/test/resources/cli/too-many-empty-lines/main.kt b/ktlint/src/test/resources/cli/too-many-empty-lines/main.kt new file mode 100644 index 0000000000..07e48d12da --- /dev/null +++ b/ktlint/src/test/resources/cli/too-many-empty-lines/main.kt @@ -0,0 +1,8 @@ +package test + +fun main() { + + + + println("Hello world!") +}