diff --git a/dokka-integration-tests/gradle/src/testExampleProjects/kotlin/ExampleProjectsTest.kt b/dokka-integration-tests/gradle/src/testExampleProjects/kotlin/ExampleProjectsTest.kt index 905c25b261..0631a25597 100644 --- a/dokka-integration-tests/gradle/src/testExampleProjects/kotlin/ExampleProjectsTest.kt +++ b/dokka-integration-tests/gradle/src/testExampleProjects/kotlin/ExampleProjectsTest.kt @@ -210,8 +210,7 @@ class ExampleProjectsTest { } withClue("expect directories are the same") { - dokkaOutputDir.shouldHaveSameStructureAs(expectedDataDir, skipEmptyDirs = true) - dokkaOutputDir.shouldHaveSameStructureAndContentAs(expectedDataDir, skipEmptyDirs = true) + dokkaOutputDir shouldBeADirectoryWithSameContentAs expectedDataDir } } } diff --git a/dokka-runners/dokka-gradle-plugin/build.gradle.kts b/dokka-runners/dokka-gradle-plugin/build.gradle.kts index 7e80391eee..d2e6e7e738 100644 --- a/dokka-runners/dokka-gradle-plugin/build.gradle.kts +++ b/dokka-runners/dokka-gradle-plugin/build.gradle.kts @@ -61,6 +61,8 @@ dependencies { testFixturesImplementation(gradleApi()) testFixturesImplementation(gradleTestKit()) + testFixturesImplementation(libs.javaDiffUtils) + testFixturesCompileOnly("org.jetbrains.dokka:dokka-core:${project.version}") testFixturesImplementation(platform(libs.kotlinxSerialization.bom)) testFixturesImplementation(libs.kotlinxSerialization.json) diff --git a/dokka-runners/dokka-gradle-plugin/src/test/kotlin/utils/ShouldBeADirectoryWithSameContentAsTest.kt b/dokka-runners/dokka-gradle-plugin/src/test/kotlin/utils/ShouldBeADirectoryWithSameContentAsTest.kt new file mode 100644 index 0000000000..e2fc573fad --- /dev/null +++ b/dokka-runners/dokka-gradle-plugin/src/test/kotlin/utils/ShouldBeADirectoryWithSameContentAsTest.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2014-2024 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ +package org.jetbrains.dokka.gradle.utils + +import io.kotest.assertions.shouldFail +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.throwable.shouldHaveMessage +import kotlin.io.path.deleteRecursively +import kotlin.io.path.writeBytes +import kotlin.io.path.writeText + +class ShouldBeADirectoryWithSameContentAsTest : FunSpec({ + + test("when expected directory doesn't exist, expect failure") { + val expectedDir = tempDir() + val actualDir = tempDir() + + expectedDir.deleteRecursively() + + val failure = shouldFail { expectedDir.shouldBeADirectoryWithSameContentAs(actualDir) } + + failure.shouldHaveMessage("expectedDir '$expectedDir' is not a directory (exists:false, file:false)") + } + + test("when actual directory doesn't exist, expect failure") { + val expectedDir = tempDir() + val actualDir = tempDir() + + actualDir.deleteRecursively() + + val failure = shouldFail { expectedDir.shouldBeADirectoryWithSameContentAs(actualDir) } + + failure.shouldHaveMessage("actualDir '$actualDir' is not a directory (exists:false, file:false)") + } + + test("when directories have different files, expect failure") { + val expectedDir = tempDir().apply { + resolve("file0.txt").writeText("valid file") + resolve("file1.txt").writeText("not in actual") + resolve("file2.txt").writeText("not in actual") + resolve("file3.txt").writeText("not in actual") + } + + val actualDir = tempDir().apply { + resolve("file0.txt").writeText("valid file") + resolve("file-a.txt").writeText("not in expected") + resolve("file-b.txt").writeText("not in expected") + resolve("file-c.txt").writeText("not in expected") + } + + val failure = shouldFail { expectedDir.shouldBeADirectoryWithSameContentAs(actualDir) } + + failure.shouldHaveMessage( + """ + actualDir is missing 3 files: + - file1.txt + - file2.txt + - file3.txt + actualDir has 3 unexpected files: + - file-a.txt + - file-b.txt + - file-c.txt + """.trimIndent() + ) + } + + test("when file in directories has different content, expect failure with contents of file") { + val expectedDir = tempDir().apply { + resolve("file.txt").writeText("content") + } + + val actualDir = tempDir().apply { + resolve("file.txt").writeText("unexpected content") + } + + val failure = shouldFail { expectedDir.shouldBeADirectoryWithSameContentAs(actualDir) } + + failure.shouldHaveMessage( + """ + file.txt has 1 differences in content: + --- file.txt + +++ file.txt + @@ -1,1 +1,1 @@ + -content + +unexpected content + """.trimIndent() + ) + } + + test("when binary file in directories has different content, expect failure with contents of file") { + val expectedDir = tempDir().apply { + resolve("file.bin").writeBytes(PNG_BYTES) + } + + val actualDir = tempDir().apply { + resolve("file.bin").writeBytes("This file in the actual dir has valid UTF-8 content".toByteArray()) + } + + val failure = shouldFail { expectedDir.shouldBeADirectoryWithSameContentAs(actualDir) } + + failure.shouldHaveMessage( + """ + file.bin has 1 differences in content: + --- file.bin + +++ file.bin + @@ -1,4 +1,1 @@ + -Failed to read file content + -java.nio.charset.MalformedInputException Input length = 1 + -file size: 100 + -checksum: c+g2IzgALWMi8irrW5xr/gB32XVU8WTtaQ8hkD8qXzE= + +This file in the actual dir has valid UTF-8 content + """.trimIndent() + ) + } +}) + +/** + * The first 100 bytes from a PNG. + * + * Obtained by running `xxd -l 100 -g 1 -u ui-icons_444444_256x240.png` + * + * ``` + * 00000000: 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 .PNG........IHDR + * 00000010: 00 00 01 00 00 00 00 F0 08 04 00 00 00 45 9E 72 .............E.r + * 00000020: 40 00 00 00 04 67 41 4D 41 00 00 B1 8F 0B FC 61 @....gAMA......a + * 00000030: 05 00 00 00 20 63 48 52 4D 00 00 7A 26 00 00 80 .... cHRM..z&... + * 00000040: 84 00 00 FA 00 00 00 80 E8 00 00 75 30 00 00 EA ...........u0... + * 00000050: 60 00 00 3A 98 00 00 17 70 9C BA 51 3C 00 00 00 `..:....p..Q<... + * 00000060: 02 62 4B 47 .bKG + * ``` + */ +private val PNG_BYTES: ByteArray = + intArrayOf( + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x08, 0x04, 0x00, 0x00, 0x00, 0x45, 0x9E, 0x72, + 0x40, 0x00, 0x00, 0x00, 0x04, 0x67, 0x41, 0x4D, 0x41, 0x00, 0x00, 0xB1, 0x8F, 0x0B, 0xFC, 0x61, + 0x05, 0x00, 0x00, 0x00, 0x20, 0x63, 0x48, 0x52, 0x4D, 0x00, 0x00, 0x7A, 0x26, 0x00, 0x00, 0x80, + 0x84, 0x00, 0x00, 0xFA, 0x00, 0x00, 0x00, 0x80, 0xE8, 0x00, 0x00, 0x75, 0x30, 0x00, 0x00, 0xEA, + 0x60, 0x00, 0x00, 0x3A, 0x98, 0x00, 0x00, 0x17, 0x70, 0x9C, 0xBA, 0x51, 0x3C, 0x00, 0x00, 0x00, + 0x02, 0x62, 0x4B, 0x47, + ).map { it.toByte() }.toByteArray() diff --git a/dokka-runners/dokka-gradle-plugin/src/testFixtures/kotlin/files.kt b/dokka-runners/dokka-gradle-plugin/src/testFixtures/kotlin/files.kt index 0bd8657802..839bea57ce 100644 --- a/dokka-runners/dokka-gradle-plugin/src/testFixtures/kotlin/files.kt +++ b/dokka-runners/dokka-gradle-plugin/src/testFixtures/kotlin/files.kt @@ -3,8 +3,11 @@ */ package org.jetbrains.dokka.gradle.utils +import io.kotest.core.TestConfiguration import java.io.File import java.nio.file.Path +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteRecursively import kotlin.io.path.invariantSeparatorsPathString import kotlin.io.path.walk @@ -21,3 +24,18 @@ fun Path.listRelativePathsMatching(predicate: (Path) -> Boolean): List { .toList() .sorted() } + +/** + * Create a temporary directory. + * + * Kotest will attempt to delete the file after the current spec has completed. + * + * (@see [io.kotest.engine.spec.tempdir], but this returns a [Path], not a [java.io.File].) + */ +fun TestConfiguration.tempDir(prefix: String? = null): Path { + val dir = createTempDirectory(prefix ?: javaClass.name) + afterSpec { + dir.deleteRecursively() + } + return dir +} diff --git a/dokka-runners/dokka-gradle-plugin/src/testFixtures/kotlin/kotestFiles.kt b/dokka-runners/dokka-gradle-plugin/src/testFixtures/kotlin/kotestFiles.kt index 17a62dd17d..e86f251eb0 100644 --- a/dokka-runners/dokka-gradle-plugin/src/testFixtures/kotlin/kotestFiles.kt +++ b/dokka-runners/dokka-gradle-plugin/src/testFixtures/kotlin/kotestFiles.kt @@ -3,106 +3,162 @@ */ package org.jetbrains.dokka.gradle.utils -import io.kotest.matchers.collections.shouldBeSameSizeAs -import io.kotest.matchers.file.shouldBeADirectory -import io.kotest.matchers.file.shouldHaveSameContentAs -import io.kotest.matchers.shouldBe -import java.io.File -import java.nio.file.Files +import com.github.difflib.DiffUtils +import com.github.difflib.UnifiedDiffUtils +import io.kotest.assertions.fail +import java.io.IOException +import java.io.OutputStream import java.nio.file.Path +import java.security.DigestOutputStream +import java.security.MessageDigest +import java.util.* +import kotlin.io.path.* -fun Path.shouldHaveSameStructureAs(path: Path, skipEmptyDirs: Boolean) { - if (skipEmptyDirs) { - toFile().shouldHaveSameStructureAs2(path.toFile(), ::isNotEmptyDir, ::isNotEmptyDir) - } else { - toFile().shouldHaveSameStructureAs2(path.toFile()) + +/** + * Compare the contents of this directory with that of [path]. + * + * Only files will be compared, directories are ignored. + */ +infix fun Path.shouldBeADirectoryWithSameContentAs(path: Path) { + val differences = describeFileDifferences(this, path) + if (differences.isNotEmpty()) { + fail(differences) } } -fun Path.shouldHaveSameStructureAndContentAs(path: Path, skipEmptyDirs: Boolean) { - if (skipEmptyDirs) { - toFile().shouldHaveSameStructureAndContentAs2(path.toFile(), ::isNotEmptyDir, ::isNotEmptyDir) - } else { - toFile().shouldHaveSameStructureAndContentAs2(path.toFile()) + +/** + * Build a string that describes the differences between [expectedDir] and [actualDir]. + * + * Both the location and content of files is compared. + * Only files are compared, directories are excluded. + * + * If the string is empty then no differences were detected. + */ +private fun describeFileDifferences( + expectedDir: Path, + actualDir: Path, +): String = buildString { + if (!expectedDir.isDirectory()) { + appendLine("expectedDir '$expectedDir' is not a directory (exists:${expectedDir.exists()}, file:${expectedDir.isRegularFile()})") + return@buildString } -} + if (!actualDir.isDirectory()) { + appendLine("actualDir '$actualDir' is not a directory (exists:${actualDir.exists()}, file:${actualDir.isRegularFile()})") + return@buildString + } + + // Collect all files from directories recursively + fun Path.allFiles(): Set = + walk().filter { it.isRegularFile() }.map { it.relativeTo(this@allFiles) }.toSet() + + val expectedFiles = expectedDir.allFiles() + val actualFiles = actualDir.allFiles() -private fun isNotEmptyDir(file: File): Boolean = - file.isFile || Files.newDirectoryStream(file.toPath()).use { it.count() } > 0 - - -private fun File.shouldHaveSameStructureAs2( - file: File, - filterLhs: (File) -> Boolean = { false }, - filterRhs: (File) -> Boolean = { false }, -) { - shouldHaveSameStructureAndContentAs2( - file, - filterLhs = filterLhs, - filterRhs = filterRhs - ) { expect, actual -> - val expectPath = expect.invariantSeparatorsPath.removePrefix(expectParentPath) - val actualPath = actual.invariantSeparatorsPath.removePrefix(actualParentPath) - expectPath shouldBe actualPath + // Check for files present in one directory but not the other + val onlyInExpected = expectedFiles - actualFiles + val onlyInActual = actualFiles - expectedFiles + + if (onlyInExpected.isNotEmpty()) { + appendLine("actualDir is missing ${onlyInExpected.size} files:") + appendLine(onlyInExpected.sorted().joinToFormattedList()) + } + if (onlyInActual.isNotEmpty()) { + appendLine("actualDir has ${onlyInActual.size} unexpected files:") + appendLine(onlyInActual.sorted().joinToFormattedList()) } + + // Compare contents of files that are present in both directories + val commonFiles = actualFiles intersect expectedFiles + + commonFiles + .sorted() + .forEach { relativePath -> + val expectedFile = expectedDir.resolve(relativePath) + val actualFile = actualDir.resolve(relativePath) + + val expectedLines = expectedFile.readLinesOrComputeChecksum() + val actualLines = actualFile.readLinesOrComputeChecksum() + + val patch = DiffUtils.diff(expectedLines, actualLines) + + if (patch.deltas.isNotEmpty()) { + appendLine("${relativePath.invariantSeparatorsPathString} has ${patch.deltas.size} differences in content:") + + val diff = UnifiedDiffUtils.generateUnifiedDiff( + /* originalFileName = */ expectedFile.relativeTo(expectedDir).invariantSeparatorsPathString, + /* revisedFileName = */ actualFile.relativeTo(actualDir).invariantSeparatorsPathString, + /* originalLines = */ expectedLines, + /* patch = */ patch, + /* contextSize = */ 3, + ) + + appendLine(diff.joinToString("\n").prependIndent()) + } + } } -fun File.shouldHaveSameStructureAndContentAs2( - file: File, - filterLhs: (File) -> Boolean = { false }, - filterRhs: (File) -> Boolean = { false }, -) { - shouldHaveSameStructureAndContentAs2( - file, - filterLhs = filterLhs, - filterRhs = filterRhs - ) { expect, actual -> - val expectPath = expect.invariantSeparatorsPath.removePrefix(expectParentPath) - val actualPath = actual.invariantSeparatorsPath.removePrefix(actualParentPath) - expectPath shouldBe actualPath - - expect.shouldHaveSameContentAs(actual) + +/** + * Pretty print files as a list. + */ +private fun Collection.joinToFormattedList(limit: Int = 10): String = + joinToString("\n", limit = limit) { " - ${it.invariantSeparatorsPathString}" } + + +/** + * Read lines from a file, or returns the [checksum] of the file if reading the file causes an [IOException]. + * (Which could happen if the file contains binary data.) + * + * @see kotlin.io.path.readLines + */ +private fun Path.readLinesOrComputeChecksum(): List { + return try { + readLines() + } catch (e: IOException) { + listOf( + "Failed to read file content", + "${e::class.qualifiedName} ${e.message}", + "file size: ${fileSizeOrNull()}", + "checksum: ${checksum()}" + ) } } +private fun Path.fileSizeOrNull(): Long? = + try { + fileSize() + } catch (ex: IOException) { + null + } -private fun File.shouldHaveSameStructureAndContentAs2( - file: File, - filterLhs: (File) -> Boolean = { false }, - filterRhs: (File) -> Boolean = { false }, - fileAssert: FileAsserter, -) { - val expectFiles = this.walkTopDown().filter(filterLhs).toList() - val actualFiles = file.walkTopDown().filter(filterRhs).toList() - - expectFiles shouldBeSameSizeAs actualFiles - - val assertContext = FileAsserter.Context( - expectParentPath = this.invariantSeparatorsPath, - actualParentPath = file.invariantSeparatorsPath, - ) - - expectFiles.zip(actualFiles) { expect, actual -> - when { - expect.isDirectory -> actual.shouldBeADirectory() - expect.isFile -> { - with(fileAssert) { - assertContext.assert(expect, actual) - } +/** + * Create a checksum of a single file, or if this throws an exception then return an error message. + * + * The file must be an existing, regular file. + */ +private fun Path.checksum(): String? { + try { + val messageDigester = MessageDigest.getInstance("SHA-256") + inputStream().buffered().use { input -> + DigestOutputStream(NullOutputStream(), messageDigester).use { digestStream -> + input.copyTo(digestStream) } - - else -> error("There is an unexpected error analyzing file trees. Failed to determine filetype of $expect") } + return Base64.getEncoder().encodeToString(messageDigester.digest()) + } catch (ex: Exception) { + return "Error computing checksum: ${ex::class.qualifiedName} ${ex.message}" } } - -private fun interface FileAsserter { - - data class Context( - val expectParentPath: String, - val actualParentPath: String, - ) - - fun Context.assert(expect: File, actual: File) +/** + * An [OutputStream] that discards all bytes. + * + * (Because [OutputStream.nullOutputStream] requires Java 9+.) + */ +class NullOutputStream : OutputStream() { + override fun write(i: Int) { + // do nothing + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e48e2423c..ce00053816 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,8 @@ kotlinx-bcv = "0.13.2" ktor = "2.3.11" +javaDiffUtils = "4.12" + ## Analysis kotlin-compiler = "2.0.20" kotlin-compiler-k2 = "2.1.0-dev-5441" @@ -131,6 +133,9 @@ apacheMaven-pluginAnnotations = { module = "org.apache.maven.plugin-tools:maven- apacheMaven-pluginApi = { module = "org.apache.maven:maven-plugin-api", version.ref = "apacheMaven-core" } apacheMaven-artifact = { module = "org.apache.maven:maven-artifact", version.ref = "apacheMaven-artifact" } +#### Diff Utils #### +javaDiffUtils = { module = "io.github.java-diff-utils:java-diff-utils", version.ref = "javaDiffUtils" } + #### CLI ##### kotlinx-cli = { module = "org.jetbrains.kotlinx:kotlinx-cli-jvm", version.ref = "kotlinx-cli" }