diff --git a/application/src/main/kotlin/application/BackwardCompatibilityCheckCommand.kt b/application/src/main/kotlin/application/BackwardCompatibilityCheckCommand.kt index 2e1f64e34..f6476e0ea 100644 --- a/application/src/main/kotlin/application/BackwardCompatibilityCheckCommand.kt +++ b/application/src/main/kotlin/application/BackwardCompatibilityCheckCommand.kt @@ -1,11 +1,16 @@ package application -import application.BackwardCompatibilityCheckCommand.CompatibilityResult.* +import application.BackwardCompatibilityCheckCommand.CompatibilityResult.FAILED +import application.BackwardCompatibilityCheckCommand.CompatibilityResult.PASSED import io.specmatic.conversions.OpenApiSpecification -import io.specmatic.core.* +import io.specmatic.core.CONTRACT_EXTENSION +import io.specmatic.core.CONTRACT_EXTENSIONS +import io.specmatic.core.Feature +import io.specmatic.core.WSDL import io.specmatic.core.git.GitCommand import io.specmatic.core.git.SystemGit import io.specmatic.core.log.logger +import io.specmatic.core.testBackwardCompatibility import io.specmatic.stub.isOpenAPI import org.springframework.stereotype.Component import picocli.CommandLine.Command @@ -26,7 +31,12 @@ const val TWO_INDENTS = "${ONE_INDENT}${ONE_INDENT}" @Command( name = "backwardCompatibilityCheck", mixinStandardHelpOptions = true, - description = ["Checks backward compatibility of a directory across the current HEAD and the main branch"] + description = [ +""" +Checks backward compatibility of a directory across the current HEAD and the main branch. +DEPRECATED: This command will be removed in the next major release. Use 'backward-compatibility-check' command instead. +""" + ] ) class BackwardCompatibilityCheckCommand( private val gitCommand: GitCommand = SystemGit(), @@ -149,7 +159,11 @@ class BackwardCompatibilityCheckCommand( // newer => the file with changes on the branch val (newer, unusedExamples) = OpenApiSpecification.fromFile(specFilePath).toFeature().loadExternalisedExamplesAndListUnloadableExamples() - val olderFile = gitCommand.getFileInTheDefaultBranch(specFilePath, treeishWithChanges) + val olderFile = gitCommand.getFileInBranch( + specFilePath, + treeishWithChanges, + gitCommand.defaultBranch() + ) if (olderFile == null) { println("$specFilePath is a new file.$newLine") return@mapIndexed PASSED @@ -289,7 +303,9 @@ class BackwardCompatibilityCheckCommand( } private fun getOpenAPISpecFilesChangedInCurrentBranch(): Set { - return gitCommand.getFilesChangeInCurrentBranch().filter { + return gitCommand.getFilesChangedInCurrentBranch( + gitCommand.defaultBranch() + ).filter { File(it).exists() && File(it).isOpenApiSpec() }.toSet() } diff --git a/application/src/main/kotlin/application/CompareCommand.kt b/application/src/main/kotlin/application/CompareCommand.kt index bdb0b7dbc..61918d226 100644 --- a/application/src/main/kotlin/application/CompareCommand.kt +++ b/application/src/main/kotlin/application/CompareCommand.kt @@ -12,7 +12,12 @@ import kotlin.system.exitProcess @Command(name = "compare", mixinStandardHelpOptions = true, - description = ["Checks if two contracts are equivalent"]) + description = [ +""" +Checks if two contracts are equivalent. +DEPRECATED: This command will be removed in the next major release. Use 'backward-compatibility-check' command instead. +""" + ]) class CompareCommand : Callable { @Parameters(index = "0", description = ["Older contract file path"]) lateinit var olderContractFilePath: String diff --git a/application/src/main/kotlin/application/CompatibleCommand.kt b/application/src/main/kotlin/application/CompatibleCommand.kt index e5c32dd27..234e28546 100644 --- a/application/src/main/kotlin/application/CompatibleCommand.kt +++ b/application/src/main/kotlin/application/CompatibleCommand.kt @@ -180,7 +180,12 @@ class GitCompatibleCommand : Callable { @Command(name = "compatible", mixinStandardHelpOptions = true, - description = ["Checks if the newer contract is backward compatible with the older one"], + description = [ +""" +Checks if the newer contract is backward compatible with the older one +DEPRECATED: This command will be removed in the next major release. Use 'backward-compatibility-check' command instead. +""" + ], subcommands = [ GitCompatibleCommand::class ]) internal class CompatibleCommand : Callable { override fun call() { diff --git a/application/src/main/kotlin/application/DifferenceCommand.kt b/application/src/main/kotlin/application/DifferenceCommand.kt index 1effdcc73..e6ce1ee61 100644 --- a/application/src/main/kotlin/application/DifferenceCommand.kt +++ b/application/src/main/kotlin/application/DifferenceCommand.kt @@ -11,7 +11,13 @@ import kotlin.system.exitProcess @Command(name = "similar", mixinStandardHelpOptions = true, - description = ["Show the difference between two contracts"]) + description = [ +""" +Show the difference between two contracts. +DEPRECATED: This command will be removed in the next major release. Use 'backward-compatibility-check' command instead. +""" + ] +) class DifferenceCommand : Callable { @Parameters(index = "0", description = ["Older contract file path"]) lateinit var olderContractFilePath: String diff --git a/application/src/main/kotlin/application/PushCommand.kt b/application/src/main/kotlin/application/PushCommand.kt index 6243370ae..46049033d 100644 --- a/application/src/main/kotlin/application/PushCommand.kt +++ b/application/src/main/kotlin/application/PushCommand.kt @@ -18,7 +18,16 @@ import kotlin.system.exitProcess private const val pipelineKeyInSpecmaticConfig = "pipeline" -@CommandLine.Command(name = "push", description = ["Check the new contract for backward compatibility with the specified version, then overwrite the old one with it."], mixinStandardHelpOptions = true) +@CommandLine.Command( + name = "push", + description = [ +""" +Check the new contract for backward compatibility with the specified version, then overwrite the old one with it. +DEPRECATED: This command will be removed in the next major release. Use 'backward-compatibility-check' command instead. +""" + ], + mixinStandardHelpOptions = true +) class PushCommand: Callable { override fun call() { val userHome = File(System.getProperty("user.home")) @@ -160,4 +169,4 @@ fun registerPipelineCredentials(manifestData: JSONObjectValue, contractPath: Str sourceGit.add() } } -} \ No newline at end of file +} diff --git a/application/src/main/kotlin/application/SpecmaticCommand.kt b/application/src/main/kotlin/application/SpecmaticCommand.kt index f8a43a1bb..d6591fe32 100644 --- a/application/src/main/kotlin/application/SpecmaticCommand.kt +++ b/application/src/main/kotlin/application/SpecmaticCommand.kt @@ -1,5 +1,6 @@ package application +import application.backwardCompatibility.BackwardCompatibilityCheckCommandV2 import org.springframework.stereotype.Component import picocli.AutoComplete.GenerateCompletion import picocli.CommandLine.Command @@ -10,7 +11,30 @@ import java.util.concurrent.Callable name = "specmatic", mixinStandardHelpOptions = true, versionProvider = VersionProvider::class, - subcommands = [BackwardCompatibilityCheckCommand::class, BundleCommand::class, CompareCommand::class, CompatibleCommand::class, DifferenceCommand::class, GenerateCompletion::class, GraphCommand::class, MergeCommand::class, ToOpenAPICommand::class, ImportCommand::class, InstallCommand::class, ProxyCommand::class, PushCommand::class, ReDeclaredAPICommand::class, ExamplesCommand::class, SamplesCommand::class, StubCommand::class, SubscribeCommand::class, TestCommand::class, ValidateViaLogs::class, CentralContractRepoReportCommand::class] + subcommands = [ + BackwardCompatibilityCheckCommandV2::class, + BackwardCompatibilityCheckCommand::class, + BundleCommand::class, + CompareCommand::class, + CompatibleCommand::class, + DifferenceCommand::class, + GenerateCompletion::class, + GraphCommand::class, + MergeCommand::class, + ToOpenAPICommand::class, + ImportCommand::class, + InstallCommand::class, + ProxyCommand::class, + PushCommand::class, + ReDeclaredAPICommand::class, + ExamplesCommand::class, + SamplesCommand::class, + StubCommand::class, + SubscribeCommand::class, + TestCommand::class, + ValidateViaLogs::class, + CentralContractRepoReportCommand::class + ] ) class SpecmaticCommand : Callable { override fun call(): Int { diff --git a/application/src/main/kotlin/application/backwardCompatibility/BackwardCompatibilityCheckBaseCommand.kt b/application/src/main/kotlin/application/backwardCompatibility/BackwardCompatibilityCheckBaseCommand.kt new file mode 100644 index 000000000..37980d827 --- /dev/null +++ b/application/src/main/kotlin/application/backwardCompatibility/BackwardCompatibilityCheckBaseCommand.kt @@ -0,0 +1,285 @@ +package application.backwardCompatibility + +import io.specmatic.core.IFeature +import io.specmatic.core.Results +import io.specmatic.core.git.GitCommand +import io.specmatic.core.git.SystemGit +import io.specmatic.core.log.logger +import io.specmatic.core.utilities.exitWithMessage +import picocli.CommandLine.Option +import java.io.File +import java.util.concurrent.Callable +import java.util.regex.Pattern +import kotlin.system.exitProcess + +abstract class BackwardCompatibilityCheckBaseCommand : Callable { + private val gitCommand: GitCommand = SystemGit() + private val newLine = System.lineSeparator() + private var areLocalChangesStashed = false + + @Option( + names = ["--base-branch"], + description = [ + "Base branch to compare the changes against", + "Default value is the local origin HEAD of the current branch" + ], + required = false + ) + var baseBranch: String? = null + + @Option( + names = ["--target-path"], + description = ["Specify the file or directory to limit the backward compatibility check scope. If omitted, all changed files will be checked."], + required = false + ) + var targetPath: String = "" + + abstract fun checkBackwardCompatibility(oldFeature: IFeature, newFeature: IFeature): Results + abstract fun File.isValidSpec(): Boolean + abstract fun getFeatureFromSpecPath(path: String): IFeature + + abstract fun getSpecsOfChangedExternalisedExamples( + filesChangedInCurrentBranch: Set + ): Set + + open fun regexForMatchingReferred(schemaFileName: String): String = "" + open fun areExamplesValid(feature: IFeature, which: String): Boolean = true + open fun getUnusedExamples(feature: IFeature): Set = emptySet() + + final override fun call() { + addShutdownHook() + val filteredSpecs = getChangedSpecs(logSpecs = true) + val result = try { + runBackwardCompatibilityCheckFor( + files = filteredSpecs, + baseBranch = baseBranch() + ) + } catch(e: Throwable) { + logger.newLine() + logger.newLine() + logger.log(e) + exitProcess(1) + } + + logger.log(result.report) + exitProcess(result.exitCode) + } + + fun getChangedSpecs(logSpecs: Boolean = false): Set { + val filesChangedInCurrentBranch = getChangedSpecsInCurrentBranch().filter { + it.contains(targetPath) + }.toSet() + val filesReferringToChangedSchemaFiles = getSpecsReferringTo(filesChangedInCurrentBranch) + val specificationsOfChangedExternalisedExamples = + getSpecsOfChangedExternalisedExamples(filesChangedInCurrentBranch) + + if(logSpecs) { + logFilesToBeCheckedForBackwardCompatibility( + filesChangedInCurrentBranch, + filesReferringToChangedSchemaFiles, + specificationsOfChangedExternalisedExamples + ) + } + + return filesChangedInCurrentBranch + + filesReferringToChangedSchemaFiles + + specificationsOfChangedExternalisedExamples + } + + private fun getChangedSpecsInCurrentBranch(): Set { + return gitCommand.getFilesChangedInCurrentBranch( + baseBranch() + ).filter { + File(it).exists() && File(it).isValidSpec() + }.toSet().also { + if(it.isEmpty()) exitWithMessage("No specs were changed, skipping the check.") + } + } + + open fun getSpecsReferringTo(schemaFiles: Set): Set { + if (schemaFiles.isEmpty()) return emptySet() + + val inputFileNames = schemaFiles.map { File(it).name } + val result = allSpecFiles().filter { + it.readText().trim().let { specContent -> + inputFileNames.any { inputFileName -> + val pattern = Pattern.compile("\\b${regexForMatchingReferred(inputFileName)}\\b") + val matcher = pattern.matcher(specContent) + matcher.find() + } + } + }.map { it.path }.toSet() + + return result.flatMap { + getSpecsReferringTo(setOf(it)).ifEmpty { setOf(it) } + }.toSet() + } + + internal fun allSpecFiles(): List { + return File(".").walk().toList().filterNot { + ".git" in it.path + }.filter { it.isFile && it.isValidSpec() } + } + + private fun logFilesToBeCheckedForBackwardCompatibility( + changedFiles: Set, + filesReferringToChangedFiles: Set, + specificationsOfChangedExternalisedExamples: Set + ) { + logger.log("Checking backward compatibility of the following specs:$newLine") + changedFiles.printSummaryOfChangedSpecs("Specs that have changed") + filesReferringToChangedFiles.printSummaryOfChangedSpecs("Specs referring to the changed specs") + specificationsOfChangedExternalisedExamples + .printSummaryOfChangedSpecs("Specs whose externalised examples were changed") + logger.log("-".repeat(20)) + logger.log(newLine) + } + + private fun Set.printSummaryOfChangedSpecs(message: String) { + if(this.isNotEmpty()) { + logger.log("${ONE_INDENT}- $message: ") + this.forEachIndexed { index, it -> + logger.log(it.prependIndent("$TWO_INDENTS${index.inc()}. ")) + } + logger.log(newLine) + } + } + + private fun getCurrentBranch(): String { + val branchWithChanges = gitCommand.currentBranch() + return if (branchWithChanges == HEAD) gitCommand.detachedHEAD() else branchWithChanges + } + + private fun runBackwardCompatibilityCheckFor(files: Set, baseBranch: String): CompatibilityReport { + val treeishWithChanges = getCurrentBranch() + + try { + val results = files.mapIndexed { index, specFilePath -> + try { + logger.log("${index.inc()}. Running the check for $specFilePath:") + + // newer => the file with changes on the branch + val newer = getFeatureFromSpecPath(specFilePath) + val unusedExamples = getUnusedExamples(newer) + + val olderFile = gitCommand.getFileInBranch( + specFilePath, + treeishWithChanges, + baseBranch + ) + if (olderFile == null) { + logger.log("$ONE_INDENT$specFilePath is a new file.$newLine") + return@mapIndexed CompatibilityResult.PASSED + } + + areLocalChangesStashed = gitCommand.stash() + gitCommand.checkout(baseBranch) + // older => the same file on the default (e.g. main) branch + val older = getFeatureFromSpecPath(olderFile.path) + + val backwardCompatibilityResult = checkBackwardCompatibility(older, newer) + + return@mapIndexed getCompatibilityResult( + backwardCompatibilityResult, + specFilePath, + newer, + unusedExamples + ) + } finally { + gitCommand.checkout(treeishWithChanges) + if (areLocalChangesStashed) { + gitCommand.stashPop() + areLocalChangesStashed = false + } + } + } + + return CompatibilityReport(results) + } finally { + gitCommand.checkout(treeishWithChanges) + } + } + + private fun baseBranch() = baseBranch ?: gitCommand.currentRemoteBranch() + + private fun getCompatibilityResult( + backwardCompatibilityResult: Results, + specFilePath: String, + newer: IFeature, + unusedExamples: Set + ): CompatibilityResult { + if(backwardCompatibilityResult.success().not()) { + logger.log("_".repeat(40).prependIndent(ONE_INDENT)) + logger.log("The Incompatibility Report:$newLine".prependIndent(ONE_INDENT)) + logger.log(backwardCompatibilityResult.report().prependIndent(TWO_INDENTS)) + logVerdictFor( + specFilePath, + "(INCOMPATIBLE) The changes to the spec are NOT backward compatible with the corresponding spec from ${baseBranch()}".prependIndent(ONE_INDENT) + ) + return CompatibilityResult.FAILED + } + + val errorsFound = printExampleValiditySummaryAndReturnResult(newer, unusedExamples, specFilePath) + + val message = if(errorsFound) { + "(INCOMPATIBLE) The spec is backward compatible but the examples are NOT backward compatible or are INVALID." + } else { + "(COMPATIBLE) The spec is backward compatible with the corresponding spec from ${baseBranch()}" + } + logVerdictFor(specFilePath, message.prependIndent(ONE_INDENT), startWithNewLine = errorsFound) + + return if (errorsFound) CompatibilityResult.FAILED + else CompatibilityResult.PASSED + } + + private fun logVerdictFor(specFilePath: String, message: String, startWithNewLine: Boolean = true) { + if (startWithNewLine) logger.log(newLine) + logger.log("-".repeat(20).prependIndent(ONE_INDENT)) + logger.log("Verdict for spec $specFilePath:".prependIndent(ONE_INDENT)) + logger.log("$ONE_INDENT$message") + logger.log("-".repeat(20).prependIndent(ONE_INDENT)) + logger.log(newLine) + } + + private fun printExampleValiditySummaryAndReturnResult( + newer: IFeature, + unusedExamples: Set, + specFilePath: String + ): Boolean { + var errorsFound = false + val areExamplesInvalid = areExamplesValid(newer, "newer").not() + + if(areExamplesInvalid || unusedExamples.isNotEmpty()) { + logger.log("_".repeat(40).prependIndent(ONE_INDENT)) + logger.log("The Examples Validity Summary:$newLine".prependIndent(ONE_INDENT)) + } + if (areExamplesInvalid) { + logger.log("Examples in $specFilePath are not valid.$newLine".prependIndent(TWO_INDENTS)) + errorsFound = true + } + + if (unusedExamples.isNotEmpty()) { + logger.log("Some examples for $specFilePath could not be loaded.$newLine".prependIndent(TWO_INDENTS)) + errorsFound = true + } + return errorsFound + } + + private fun addShutdownHook() { + Runtime.getRuntime().addShutdownHook(object: Thread() { + override fun run() { + runCatching { + gitCommand.checkout(getCurrentBranch()) + if(areLocalChangesStashed) gitCommand.stashPop() + } + } + }) + } + + companion object { + private const val HEAD = "HEAD" + private const val MARGIN_SPACE = " " + private const val ONE_INDENT = " " + private const val TWO_INDENTS = "${ONE_INDENT}${ONE_INDENT}" + } +} diff --git a/application/src/main/kotlin/application/backwardCompatibility/BackwardCompatibilityCheckCommandV2.kt b/application/src/main/kotlin/application/backwardCompatibility/BackwardCompatibilityCheckCommandV2.kt new file mode 100644 index 000000000..c942e898b --- /dev/null +++ b/application/src/main/kotlin/application/backwardCompatibility/BackwardCompatibilityCheckCommandV2.kt @@ -0,0 +1,116 @@ +package application.backwardCompatibility + +import io.specmatic.conversions.OpenApiSpecification +import io.specmatic.core.CONTRACT_EXTENSION +import io.specmatic.core.CONTRACT_EXTENSIONS +import io.specmatic.core.Feature +import io.specmatic.core.IFeature +import io.specmatic.core.Results +import io.specmatic.core.WSDL +import io.specmatic.core.log.logger +import io.specmatic.core.testBackwardCompatibility +import io.specmatic.stub.isOpenAPI +import org.springframework.stereotype.Component +import picocli.CommandLine.Command +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.extension +import kotlin.io.path.pathString + +@Component +@Command( + name = "backward-compatibility-check", + mixinStandardHelpOptions = true, + description = ["Checks backward compatibility of OpenAPI specifications"] +) +class BackwardCompatibilityCheckCommandV2: BackwardCompatibilityCheckBaseCommand() { + + override fun checkBackwardCompatibility(oldFeature: IFeature, newFeature: IFeature): Results { + return testBackwardCompatibility(oldFeature as Feature, newFeature as Feature) + } + + override fun File.isValidSpec(): Boolean { + if (this.extension !in CONTRACT_EXTENSIONS) return false + return OpenApiSpecification.isParsable(this.path) + } + + override fun getFeatureFromSpecPath(path: String): Feature { + logger.disableInfoLogging() + return OpenApiSpecification.fromFile(path).toFeature().also { + logger.enableInfoLogging() + } + } + + override fun regexForMatchingReferred(schemaFileName: String) = schemaFileName + + override fun getSpecsOfChangedExternalisedExamples(filesChangedInCurrentBranch: Set): Set { + data class CollectedFiles( + val specifications: MutableSet = mutableSetOf(), + val examplesMissingSpecifications: MutableList = mutableListOf(), + val ignoredFiles: MutableList = mutableListOf() + ) + + val collectedFiles = filesChangedInCurrentBranch.fold(CollectedFiles()) { acc, filePath -> + val path = Paths.get(filePath) + val examplesDir = path.find { it.toString().endsWith("_examples") || it.toString().endsWith("_tests") } + + if (examplesDir == null) { + acc.ignoredFiles.add(filePath) + } else { + val parentPath = examplesDir.parent + val strippedPath = parentPath.resolve(examplesDir.fileName.toString().removeSuffix("_examples")) + val specFiles = findSpecFiles(strippedPath) + + if (specFiles.isNotEmpty()) { + acc.specifications.addAll(specFiles.map { it.toString() }) + } else { + acc.examplesMissingSpecifications.add(filePath) + } + } + acc + } + + val result = collectedFiles.specifications.toMutableSet() + + collectedFiles.examplesMissingSpecifications.forEach { filePath -> + val path = Paths.get(filePath) + val examplesDir = path.find { it.toString().endsWith("_examples") || it.toString().endsWith("_tests") } + if (examplesDir != null) { + val parentPath = examplesDir.parent + val strippedPath = parentPath.resolve(examplesDir.fileName.toString().removeSuffix("_examples")) + val specFiles = findSpecFiles(strippedPath) + if (specFiles.isNotEmpty()) { + result.addAll(specFiles.map { it.toString() }) + } else { + result.add("${strippedPath}.yaml") + } + } + } + + return result + } + + override fun areExamplesValid(feature: IFeature, which: String): Boolean { + feature as Feature + return try { + feature.validateExamplesOrException() + true + } catch (t: Throwable) { + println() + false + } + } + + override fun getUnusedExamples(feature: IFeature): Set { + feature as Feature + return feature.loadExternalisedExamplesAndListUnloadableExamples().second + } + + private fun findSpecFiles(path: Path): List { + val extensions = CONTRACT_EXTENSIONS + return extensions.map { path.resolveSibling(path.fileName.toString() + it) } + .filter { Files.exists(it) && (isOpenAPI(it.pathString) || it.extension in listOf(WSDL, CONTRACT_EXTENSION)) } + } +} \ No newline at end of file diff --git a/application/src/main/kotlin/application/backwardCompatibility/CompatibilityReport.kt b/application/src/main/kotlin/application/backwardCompatibility/CompatibilityReport.kt new file mode 100644 index 000000000..07a2f4a60 --- /dev/null +++ b/application/src/main/kotlin/application/backwardCompatibility/CompatibilityReport.kt @@ -0,0 +1,16 @@ +package application.backwardCompatibility + +class CompatibilityReport(results: List) { + val report: String + val exitCode: Int + + init { + val failed: Boolean = results.any { it == CompatibilityResult.FAILED } + val failedCount = results.count { it == CompatibilityResult.FAILED } + val passedCount = results.count { it == CompatibilityResult.PASSED } + + report = "Files checked: ${results.size} (Passed: ${passedCount}, Failed: $failedCount)" + exitCode = if(failed) 1 else 0 + } + +} diff --git a/application/src/main/kotlin/application/backwardCompatibility/CompatibilityResult.kt b/application/src/main/kotlin/application/backwardCompatibility/CompatibilityResult.kt new file mode 100644 index 000000000..c53e7255f --- /dev/null +++ b/application/src/main/kotlin/application/backwardCompatibility/CompatibilityResult.kt @@ -0,0 +1,5 @@ +package application.backwardCompatibility + +enum class CompatibilityResult { + PASSED, FAILED +} diff --git a/application/src/test/kotlin/application/BackwardCompatibilityCheckCommandTest.kt b/application/src/test/kotlin/application/BackwardCompatibilityCheckCommandTest.kt deleted file mode 100644 index 4cb9a4de4..000000000 --- a/application/src/test/kotlin/application/BackwardCompatibilityCheckCommandTest.kt +++ /dev/null @@ -1,75 +0,0 @@ -package application - -import io.mockk.every -import io.mockk.mockk -import io.mockk.spyk -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import java.io.File - -class BackwardCompatibilityCheckCommandTest { - - @Test - fun `filesReferringToChangedSchemaFiles returns empty set when input is empty`() { - val command = BackwardCompatibilityCheckCommand(mockk()) - val result = command.filesReferringToChangedSchemaFiles(emptySet()) - assertTrue(result.isEmpty()) - } - - @Test - fun `filesReferringToChangedSchemaFiles returns empty set when no files refer to changed schema files`() { - val command = spyk() - every { command.allOpenApiSpecFiles() } returns listOf( - File("file1.yaml").apply { writeText("content1") }, - File("file2.yaml").apply { writeText("content2") } - ) - val result = command.filesReferringToChangedSchemaFiles(setOf("file3.yaml")) - assertTrue(result.isEmpty()) - } - - @Test - fun `filesReferringToChangedSchemaFiles returns set of files that refer to changed schema files`() { - val command = spyk() - every { command.allOpenApiSpecFiles() } returns listOf( - File("file1.yaml").apply { writeText("file3.yaml") }, - File("file2.yaml").apply { writeText("file4.yaml") } - ) - val result = command.filesReferringToChangedSchemaFiles(setOf("file3.yaml")) - assertEquals(setOf("file1.yaml"), result) - } - - @Test - fun `filesReferringToChangedSchemaFiles returns set of files which are referring to a changed schema that is one level down`() { - val command = spyk() - every { command.allOpenApiSpecFiles() } returns listOf( - File("file1.yaml").apply { referTo("schema_file1.yaml") }, - File("schema_file2.yaml").apply { referTo("schema_file1.yaml") }, // schema within a schema - File("file2.yaml").apply { referTo("schema_file2.yaml") } - ) - val result = command.filesReferringToChangedSchemaFiles(setOf("schema_file1.yaml")) - assertEquals(setOf("file1.yaml", "file2.yaml"), result) - } - - @AfterEach - fun `cleanup files`() { - listOf("file1.yaml", "file2.yaml", "file3.yaml", "file4.yaml", "schema_file1.yaml", "schema_file2.yaml").forEach { - File(it).delete() - } - } - - private fun File.referTo(schemaFileName: String) { - val specContent = """ - openapi: 3.1.0 # OpenAPI version specified here - info: - title: My API - version: 1.0.0 - components: - schemas: - User: - ${"$"}ref: '#/components/schemas/$schemaFileName' - """.trimIndent() - this.writeText(specContent) - } -} \ No newline at end of file diff --git a/application/src/test/kotlin/application/BackwardCompatibilityCheckCommandV2Test.kt b/application/src/test/kotlin/application/BackwardCompatibilityCheckCommandV2Test.kt new file mode 100644 index 000000000..401b504d4 --- /dev/null +++ b/application/src/test/kotlin/application/BackwardCompatibilityCheckCommandV2Test.kt @@ -0,0 +1,194 @@ +package application + +import application.backwardCompatibility.BackwardCompatibilityCheckCommandV2 +import io.mockk.every +import io.mockk.spyk +import io.specmatic.core.git.SystemGit +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.io.File +import java.nio.file.Files + +class BackwardCompatibilityCheckCommandV2Test { + private lateinit var tempDir: File + private lateinit var remoteDir: File + + @BeforeEach + fun setup() { + tempDir = Files.createTempDirectory("git-local").toFile() + tempDir.deleteOnExit() + + remoteDir = Files.createTempDirectory("git-remote").toFile() + remoteDir.deleteOnExit() + + ProcessBuilder("git", "init", "--bare") + .directory(remoteDir) + .inheritIO() + .start() + .waitFor() + + ProcessBuilder("git", "init") + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + + ProcessBuilder("git", "config", "--local", "user.name", "developer") + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + + ProcessBuilder("git", "config", "--local", "user.email", "developer@example.com") + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + + ProcessBuilder("git", "remote", "add", "origin", remoteDir.absolutePath) + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + } + + @Test + fun `getSpecsReferringTo returns empty set when input is empty`() { + val command = BackwardCompatibilityCheckCommandV2() + val result = command.getSpecsReferringTo(emptySet()) + assertTrue(result.isEmpty()) + } + + @Test + fun `getSpecsReferringTo returns empty set when no files refer to changed schema files`() { + val command = spyk() + every { command.allSpecFiles() } returns listOf( + File("file1.yaml").apply { writeText("content1") }, + File("file2.yaml").apply { writeText("content2") } + ) + val result = command.getSpecsReferringTo(setOf("file3.yaml")) + assertTrue(result.isEmpty()) + } + + @Test + fun `getSpecsReferringTo returns set of files that refer to changed schema files`() { + val command = spyk() + every { command.allSpecFiles() } returns listOf( + File("file1.yaml").apply { writeText("file3.yaml") }, + File("file2.yaml").apply { writeText("file4.yaml") } + ) + val result = command.getSpecsReferringTo(setOf("file3.yaml")) + assertEquals(setOf("file1.yaml"), result) + } + + @Test + fun `getSpecsReferringTo returns set of files which are referring to a changed schema that is one level down`() { + val command = spyk() + every { command.allSpecFiles() } returns listOf( + File("file1.yaml").apply { referTo("schema_file1.yaml") }, + File("schema_file2.yaml").apply { referTo("schema_file1.yaml") }, // schema within a schema + File("file2.yaml").apply { referTo("schema_file2.yaml") } + ) + val result = command.getSpecsReferringTo(setOf("schema_file1.yaml")) + assertEquals(setOf("file1.yaml", "file2.yaml"), result) + } + + @Nested + inner class SystemGitTestsSpecificToBackwardCompatibility { + @Test + fun `getFilesChangedInCurrentBranch returns the uncommitted, unstaged changed file`() { + File(tempDir, "file1.txt").writeText("File 1 content") + ProcessBuilder("git", "add", "file1.txt") + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + ProcessBuilder("git", "commit", "-m", "Add file1") + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + // Push the committed changes to the remote repository + ProcessBuilder("git", "push", "origin", "main") + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + + + val uncommittedFile = File(tempDir, "file1.txt") + uncommittedFile.writeText("File 1 changed content") + + val gitCommand = SystemGit(tempDir.absolutePath) + val result = gitCommand.getFilesChangedInCurrentBranch( + gitCommand.currentRemoteBranch() + ) + + assert(result.contains("file1.txt")) + } + + @Test + fun `getFilesChangedInCurrentBranch returns the uncommitted, staged changed file`() { + File(tempDir, "file1.txt").writeText("File 1 content") + ProcessBuilder("git", "add", "file1.txt") + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + ProcessBuilder("git", "commit", "-m", "Add file1") + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + // Push the committed changes to the remote repository + ProcessBuilder("git", "push", "origin", "main") + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + + + val uncommittedFile = File(tempDir, "file1.txt") + uncommittedFile.writeText("File 1 changed content") + ProcessBuilder("git", "add", "file1.txt") + .directory(tempDir) + .inheritIO() + .start() + .waitFor() + + val gitCommand = SystemGit(tempDir.absolutePath) + val result = gitCommand.getFilesChangedInCurrentBranch( + gitCommand.currentRemoteBranch() + ) + + assert(result.contains("file1.txt")) + } + } + + @AfterEach + fun `cleanup files`() { + listOf("file1.yaml", "file2.yaml", "file3.yaml", "file4.yaml", "schema_file1.yaml", "schema_file2.yaml").forEach { + File(it).delete() + } + tempDir.deleteRecursively() + remoteDir.deleteRecursively() + } + + private fun File.referTo(schemaFileName: String) { + val specContent = """ + openapi: 3.1.0 # OpenAPI version specified here + info: + title: My API + version: 1.0.0 + components: + schemas: + User: + ${"$"}ref: '#/components/schemas/$schemaFileName' + """.trimIndent() + this.writeText(specContent) + } +} \ No newline at end of file diff --git a/application/src/test/kotlin/application/CompatibleCommandKtTest.kt b/application/src/test/kotlin/application/CompatibleCommandKtTest.kt index 120a05317..99c0acc54 100644 --- a/application/src/test/kotlin/application/CompatibleCommandKtTest.kt +++ b/application/src/test/kotlin/application/CompatibleCommandKtTest.kt @@ -6,6 +6,7 @@ import io.specmatic.core.Result import io.specmatic.core.Results import io.specmatic.core.git.GitCommand import io.mockk.every +import io.specmatic.core.git.SystemGit import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -46,8 +47,12 @@ internal class CompatibleCommandKtTest { override val workingDirectory: String get() = "" - override fun getFilesChangeInCurrentBranch() = emptyList() - override fun getFileInTheDefaultBranch(fileName: String, currentBranch: String) = null + override fun stashPop(): SystemGit { + TODO("Not yet implemented") + } + + override fun getFilesChangedInCurrentBranch(baseBranch: String) = emptyList() + override fun getFileInBranch(fileName: String, currentBranch: String, baseBranch: String) = null override fun relativeGitPath(newerContractPath: String): Pair { assertThat(newerContractPath).isEqualTo("/Users/fakeuser/newer.$CONTRACT_EXTENSION") @@ -77,9 +82,9 @@ internal class CompatibleCommandKtTest { override fun fileIsInGitDir(newerContractPath: String): Boolean = true override val workingDirectory: String get() = "" - override fun getFileInTheDefaultBranch(fileName: String, currentBranch: String) = null + override fun getFileInBranch(fileName: String, currentBranch: String, baseBranch: String) = null - override fun getFilesChangeInCurrentBranch() = emptyList() + override fun getFilesChangedInCurrentBranch(baseBranch: String) = emptyList() override fun relativeGitPath(newerContractPath: String): Pair { assertThat(newerContractPath).isEqualTo("/Users/fakeuser/newer.$CONTRACT_EXTENSION") @@ -114,10 +119,10 @@ internal class CompatibleCommandKtTest { val fakeGit = object : FakeGit() { override fun fileIsInGitDir(newerContractPath: String): Boolean = true - override fun getFilesChangeInCurrentBranch() = emptyList() + override fun getFilesChangedInCurrentBranch(baseBranch: String) = emptyList() override val workingDirectory: String get() = "" - override fun getFileInTheDefaultBranch(fileName: String, currentBranch: String) = null + override fun getFileInBranch(fileName: String, currentBranch: String, baseBranch: String) = null override fun relativeGitPath(newerContractPath: String): Pair { assertThat(newerContractPath).isEqualTo("/Users/fakeuser/newer.$CONTRACT_EXTENSION") @@ -151,10 +156,10 @@ internal class CompatibleCommandKtTest { val fakeGit = object : FakeGit() { override fun fileIsInGitDir(newerContractPath: String): Boolean = true - override fun getFilesChangeInCurrentBranch() = emptyList() + override fun getFilesChangedInCurrentBranch(baseBranch: String) = emptyList() override val workingDirectory: String get() = "" - override fun getFileInTheDefaultBranch(fileName: String, currentBranch: String) = null + override fun getFileInBranch(fileName: String, currentBranch: String, baseBranch: String) = null override fun relativeGitPath(newerContractPath: String): Pair { assertThat(newerContractPath).isEqualTo("/Users/fakeuser/newer.$CONTRACT_EXTENSION") diff --git a/application/src/test/kotlin/application/FakeGit.kt b/application/src/test/kotlin/application/FakeGit.kt index 4e9fe8022..4a67739d7 100644 --- a/application/src/test/kotlin/application/FakeGit.kt +++ b/application/src/test/kotlin/application/FakeGit.kt @@ -16,6 +16,18 @@ abstract class FakeGit: GitCommand { TODO("Not yet implemented") } + override fun stash(): Boolean { + TODO("Not yet implemented") + } + + override fun stashPop(): SystemGit { + TODO("Not yet implemented") + } + + override fun currentRemoteBranch(): String { + TODO("Not yet implemented") + } + override fun add(relativePath: String): SystemGit { TODO("Not yet implemented") } diff --git a/core/src/main/kotlin/io/specmatic/core/Feature.kt b/core/src/main/kotlin/io/specmatic/core/Feature.kt index a67c0c5f3..68bc043ee 100644 --- a/core/src/main/kotlin/io/specmatic/core/Feature.kt +++ b/core/src/main/kotlin/io/specmatic/core/Feature.kt @@ -108,7 +108,7 @@ data class Feature( val stubsFromExamples: Map>> = emptyMap(), val specmaticConfig: SpecmaticConfig = SpecmaticConfig(), val flagsBased: FlagsBased = strategiesFromFlags(specmaticConfig) -) { +): IFeature { fun enableGenerativeTesting(onlyPositive: Boolean = false): Feature { val updatedSpecmaticConfig = specmaticConfig.copy( test = specmaticConfig.test?.copy( diff --git a/core/src/main/kotlin/io/specmatic/core/IFeature.kt b/core/src/main/kotlin/io/specmatic/core/IFeature.kt new file mode 100644 index 000000000..9b7f4881b --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/core/IFeature.kt @@ -0,0 +1,3 @@ +package io.specmatic.core + +interface IFeature \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/core/Scenario.kt b/core/src/main/kotlin/io/specmatic/core/Scenario.kt index 26f069c9a..04aeaed33 100644 --- a/core/src/main/kotlin/io/specmatic/core/Scenario.kt +++ b/core/src/main/kotlin/io/specmatic/core/Scenario.kt @@ -399,9 +399,8 @@ data class Scenario( "Error loading example named ${row.name} for ${this.apiDescription.trim()}" } - listOf(title).plus(errors).joinToString("${System.lineSeparator()}${System.lineSeparator()}") - .also { message -> - logger.log(message) + listOf(title).plus(errors).joinToString("${System.lineSeparator()}${System.lineSeparator()}").also { message -> + logger.logError(Exception(message)) logger.newLine() } diff --git a/core/src/main/kotlin/io/specmatic/core/git/GitCommand.kt b/core/src/main/kotlin/io/specmatic/core/git/GitCommand.kt index 904c0c28a..7ca8bc827 100644 --- a/core/src/main/kotlin/io/specmatic/core/git/GitCommand.kt +++ b/core/src/main/kotlin/io/specmatic/core/git/GitCommand.kt @@ -9,6 +9,8 @@ interface GitCommand { fun commit(): SystemGit fun push(): SystemGit fun pull(): SystemGit + fun stash(): Boolean + fun stashPop(): SystemGit fun resetHard(): SystemGit fun resetMixed(): SystemGit fun mergeAbort(): SystemGit @@ -30,8 +32,9 @@ interface GitCommand { fun revisionsBehindCount(): Int fun getRemoteUrl(name: String = "origin"): String fun checkIgnore(path: String): String - fun getFilesChangeInCurrentBranch(): List - fun getFileInTheDefaultBranch(fileName: String, currentBranch: String): File? + fun getFilesChangedInCurrentBranch(baseBranch: String): List + fun getFileInBranch(fileName: String, currentBranch: String, baseBranch: String): File? + fun currentRemoteBranch(): String fun currentBranch(): String { return "" } @@ -43,4 +46,5 @@ interface GitCommand { fun detachedHEAD(): String { return "" } + } diff --git a/core/src/main/kotlin/io/specmatic/core/git/SystemGit.kt b/core/src/main/kotlin/io/specmatic/core/git/SystemGit.kt index 48f0c5113..4b46ced05 100644 --- a/core/src/main/kotlin/io/specmatic/core/git/SystemGit.kt +++ b/core/src/main/kotlin/io/specmatic/core/git/SystemGit.kt @@ -58,6 +58,14 @@ class SystemGit(override val workingDirectory: String = ".", private val prefix: } } + override fun stash(): Boolean { + val stashListSizeBefore = getStashListSize() + execute(Configuration.gitCommand, "stash", "push", "-m", "tmp") + return getStashListSize() > stashListSizeBefore + } + + override fun stashPop(): SystemGit = this.also { execute(Configuration.gitCommand, "stash", "pop") } + override fun getCurrentBranch(): String { return execute(Configuration.gitCommand, "git", "diff", "--name-only", "master") } @@ -82,17 +90,20 @@ class SystemGit(override val workingDirectory: String = ".", private val prefix: } } - override fun getFilesChangeInCurrentBranch(): List { - val defaultBranch = defaultBranch() + override fun getFilesChangedInCurrentBranch(baseBranch: String): List { + val committedLocalChanges = execute(Configuration.gitCommand, "diff", baseBranch, "HEAD", "--name-status") + .split("\n") + val uncommittedChanges = execute(Configuration.gitCommand, "diff", "HEAD", "--name-status") + .split("\n") - val result = execute(Configuration.gitCommand, "diff", defaultBranch, "HEAD", "--name-only") - - return result.split(System.lineSeparator()).filter { it.isNotBlank() } + return (committedLocalChanges + uncommittedChanges).map { + it.split("\t").last() + }.distinct() } - override fun getFileInTheDefaultBranch(fileName: String, currentBranch: String): File? { + override fun getFileInBranch(fileName: String, currentBranch: String, baseBranch: String): File? { try { - checkout(defaultBranch()) + if(baseBranch != currentBranch) checkout(baseBranch) if (!File(fileName).exists()) return null return File(fileName) @@ -156,6 +167,15 @@ class SystemGit(override val workingDirectory: String = ".", private val prefix: return execute(Configuration.gitCommand, "rev-parse", "--abbrev-ref", "HEAD").trim() } + override fun currentRemoteBranch(): String { + val branchStatus = execute(Configuration.gitCommand, "status", "-b", "--porcelain=2").trim() + val hasUpstream = branchStatus.lines().any { it.startsWith("# branch.upstream") } + if (hasUpstream) { + return execute(Configuration.gitCommand, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}").trim() + } + return currentBranch() + } + override fun defaultBranch(): String { System.getenv("LOCAL_GIT_BRANCH")?.let { return it @@ -174,6 +194,10 @@ class SystemGit(override val workingDirectory: String = ".", private val prefix: return symbolicRef.split("/")[1].trim() } + private fun getStashListSize(): Int { + return execute(Configuration.gitCommand, "stash", "list").trim().lines().size + } + override fun detachedHEAD(): String { val result = execute(Configuration.gitCommand, "show", "-s", "--pretty=%D", "HEAD") return result.trim().split(",")[1].trim() diff --git a/core/src/main/kotlin/io/specmatic/core/log/LogStrategy.kt b/core/src/main/kotlin/io/specmatic/core/log/LogStrategy.kt index 3d58daa2b..b6ab01f6e 100644 --- a/core/src/main/kotlin/io/specmatic/core/log/LogStrategy.kt +++ b/core/src/main/kotlin/io/specmatic/core/log/LogStrategy.kt @@ -2,9 +2,9 @@ package io.specmatic.core.log interface LogStrategy { val printer: CompositePrinter + var infoLoggingEnabled: Boolean fun keepReady(msg: LogMessage) - fun exceptionString(e: Throwable, msg: String? = null): String fun ofTheException(e: Throwable, msg: String? = null): LogMessage fun log(e: Throwable, msg: String? = null) @@ -15,4 +15,10 @@ interface LogStrategy { fun debug(msg: String): String fun debug(msg: LogMessage) fun debug(e: Throwable, msg: String? = null) + fun disableInfoLogging() { + infoLoggingEnabled = false + } + fun enableInfoLogging() { + infoLoggingEnabled = true + } } \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/core/log/NonVerbose.kt b/core/src/main/kotlin/io/specmatic/core/log/NonVerbose.kt index 7b2b4effb..94e0eec7b 100644 --- a/core/src/main/kotlin/io/specmatic/core/log/NonVerbose.kt +++ b/core/src/main/kotlin/io/specmatic/core/log/NonVerbose.kt @@ -2,7 +2,10 @@ package io.specmatic.core.log import io.specmatic.core.utilities.exceptionCauseMessage -class NonVerbose(override val printer: CompositePrinter) : LogStrategy { +class NonVerbose( + override val printer: CompositePrinter, + override var infoLoggingEnabled: Boolean = true +) : LogStrategy { private val readyMessage = ReadyMessage() override fun keepReady(msg: LogMessage) { @@ -30,11 +33,11 @@ class NonVerbose(override val printer: CompositePrinter) : LogStrategy { } override fun log(msg: String) { - log(StringLog(msg)) + if (infoLoggingEnabled) log(StringLog(msg)) } override fun log(msg: LogMessage) { - print(msg) + if (infoLoggingEnabled) print(msg) } override fun logError(e: Throwable) { diff --git a/core/src/main/kotlin/io/specmatic/core/log/Verbose.kt b/core/src/main/kotlin/io/specmatic/core/log/Verbose.kt index e2717ea95..a5394c4a4 100644 --- a/core/src/main/kotlin/io/specmatic/core/log/Verbose.kt +++ b/core/src/main/kotlin/io/specmatic/core/log/Verbose.kt @@ -2,7 +2,10 @@ package io.specmatic.core.log import io.specmatic.core.utilities.exceptionCauseMessage -class Verbose(override val printer: CompositePrinter = CompositePrinter()) : LogStrategy { +class Verbose( + override val printer: CompositePrinter = CompositePrinter(), + override var infoLoggingEnabled: Boolean = true +) : LogStrategy { private val readyMessage = ReadyMessage() override fun keepReady(msg: LogMessage) { @@ -32,11 +35,11 @@ class Verbose(override val printer: CompositePrinter = CompositePrinter()) : Log } override fun log(msg: String) { - log(StringLog(msg)) + if (infoLoggingEnabled) log(StringLog(msg)) } override fun log(msg: LogMessage) { - print(msg) + if (infoLoggingEnabled) print(msg) } override fun logError(e: Throwable) { diff --git a/core/src/main/kotlin/io/specmatic/core/utilities/Utilities.kt b/core/src/main/kotlin/io/specmatic/core/utilities/Utilities.kt index 91d21bfef..12e8cd752 100644 --- a/core/src/main/kotlin/io/specmatic/core/utilities/Utilities.kt +++ b/core/src/main/kotlin/io/specmatic/core/utilities/Utilities.kt @@ -37,7 +37,8 @@ import javax.xml.transform.stream.StreamResult import kotlin.system.exitProcess fun exitWithMessage(message: String): Nothing { - logger.log(message) + val newLine = System.lineSeparator() + logger.log("$newLine$message$newLine") exitProcess(1) } diff --git a/core/src/test/kotlin/io/specmatic/conversions/OpenApiSpecificationTest.kt b/core/src/test/kotlin/io/specmatic/conversions/OpenApiSpecificationTest.kt index 6a4ad5614..fe6dfef9b 100644 --- a/core/src/test/kotlin/io/specmatic/conversions/OpenApiSpecificationTest.kt +++ b/core/src/test/kotlin/io/specmatic/conversions/OpenApiSpecificationTest.kt @@ -6232,6 +6232,9 @@ paths: val messages = mutableListOf() override val printer: CompositePrinter get() = TODO("Not yet implemented") + override var infoLoggingEnabled: Boolean + get() = true + set(value) {} override fun keepReady(msg: LogMessage) { TODO("Not yet implemented") @@ -6319,6 +6322,9 @@ paths: val messages = mutableListOf() override val printer: CompositePrinter get() = TODO("Not yet implemented") + override var infoLoggingEnabled: Boolean + get() = true + set(value) {} override fun keepReady(msg: LogMessage) { TODO("Not yet implemented")