diff --git a/README.md b/README.md index 1c98193..566d6d8 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,12 @@ dokka( ) ``` -The target produces one directory artifact named after the target. HTML is the -default output format. Set `format` to `gfm` or `javadoc` for the other bundled -formats. +The target produces one directory artifact named after the target. If all sources +are suppressed, the directory is empty. HTML is the default output format. Set +`format` to `gfm` or `javadoc` for the other bundled formats. + +Logged Dokka errors always fail the action. Set `fail_on_warning = True` on a +`dokka_config` to fail on warnings too. Without a target-level `config`, `dokka` uses the default `dokka_config` provided by its selected toolchain. The bundled default sets diff --git a/dokka/private/DokkaGeneratorRunner.kt b/dokka/private/DokkaGeneratorRunner.kt index 9d9eb7d..c870d90 100644 --- a/dokka/private/DokkaGeneratorRunner.kt +++ b/dokka/private/DokkaGeneratorRunner.kt @@ -14,8 +14,10 @@ import org.jetbrains.dokka.apply import org.jetbrains.dokka.defaultLinks import org.jetbrains.dokka.utilities.DokkaConsoleLogger import java.io.File +import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +import kotlin.system.exitProcess /** * Runs Dokka in-process with paths resolved inside the Bazel action's execution root. @@ -39,7 +41,17 @@ object DokkaGeneratorRunner { } else { standardConfiguration(root) } - DokkaGenerator(configuration, DokkaConsoleLogger()).generate() + Files.createDirectories(configuration.outputDir.toPath()) + val logger = DokkaConsoleLogger() + val exitCode = try { + DokkaGenerator(configuration, logger).generate() + if (logger.errorsCount > 0 || (configuration.failOnWarning && logger.warningsCount > 0)) 1 else 0 + } catch (failure: Throwable) { + failure.printStackTrace() + 1 + } + // Dokka can return without completing analysis cleanup. + exitProcess(exitCode) } private fun standardConfiguration(root: ObjectNode): DokkaConfiguration { diff --git a/dokka/private/config.bzl b/dokka/private/config.bzl index cdecf9b..44280fa 100644 --- a/dokka/private/config.bzl +++ b/dokka/private/config.bzl @@ -27,7 +27,7 @@ def _dokka_config_attrs(): doc = "Declaration visibilities to document.", ), "fail_on_warning": attr.bool( - doc = "Fail when Dokka emits a warning or error.", + doc = "Treat Dokka warnings as build failures. Errors always fail.", ), "jdk_version": attr.int( default = 8, diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel index 52d3283..6784c06 100644 --- a/tests/BUILD.bazel +++ b/tests/BUILD.bazel @@ -1,3 +1,4 @@ +load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_test") load(":dokka_tests.bzl", "dokka_test_suite") load(":generated_docs_tests.bzl", "generated_docs_test_suite") load(":multi_module_tests.bzl", "multi_module_test_suite") @@ -8,4 +9,24 @@ generated_docs_test_suite(name = "generated_docs") multi_module_test_suite(name = "multi_module") +kt_jvm_test( + name = "generator_exit_test", + size = "medium", + srcs = [ + "DokkaGeneratorRunnerTest.kt", + "//dokka/private:DokkaGeneratorRunner.kt", + ], + main_class = "rules.dokka.DokkaGeneratorRunnerTest", + runtime_deps = [ + "@rules_dokka_dependencies//:org_freemarker_freemarker", + "@rules_dokka_dependencies//:org_jetbrains_dokka_analysis_kotlin_symbols", + "@rules_dokka_dependencies//:org_jetbrains_dokka_dokka_base", + "@rules_dokka_dependencies//:org_jetbrains_kotlinx_kotlinx_html_jvm", + ], + deps = [ + "@rules_dokka_dependencies//:com_fasterxml_jackson_core_jackson_databind", + "@rules_dokka_dependencies//:org_jetbrains_dokka_dokka_cli", + ], +) + test_suite(name = "all_tests") diff --git a/tests/DokkaGeneratorRunnerTest.kt b/tests/DokkaGeneratorRunnerTest.kt new file mode 100644 index 0000000..b0a44f4 --- /dev/null +++ b/tests/DokkaGeneratorRunnerTest.kt @@ -0,0 +1,85 @@ +package rules.dokka + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.TimeUnit + +/** Exercises the standalone runner's exit status in a separate JVM. */ +object DokkaGeneratorRunnerTest { + @JvmStatic + fun main(args: Array) { + val root = Files.createTempDirectory(Paths.get(requireNotNull(System.getenv("TEST_TMPDIR"))), "dokka-exit-") + val source = root.resolve("Api.kt") + Files.writeString(source, "package fixture\nclass Api\n") + val classpath = System.getProperty("java.class.path").split(File.pathSeparator) + .map { Paths.get(it).toAbsolutePath().toString() } + val plugins = listOf( + "org.jetbrains.dokka.base.DokkaBase", + "org.jetbrains.dokka.analysis.kotlin.symbols.plugin.SymbolsAnalysisPlugin", + ).map { Paths.get(Class.forName(it).protectionDomain.codeSource.location.toURI()).toString() } + val mapper = ObjectMapper() + val base = mapper.createObjectNode().apply { + put("moduleName", "Exit status fixture") + put("offlineMode", true) + putArray("pluginsClasspath").also { jars -> plugins.forEach { jars.add(it) } } + putArray("sourceSets").addObject().apply { + putObject("sourceSetID").put("scopeId", "fixture").put("sourceSetName", "main") + putArray("sourceRoots").add(source.toString()) + put("noJdkLink", true) + put("noStdlibLink", true) + } + } + + fun run(name: String, expectedExit: Int, configure: (ObjectNode, Path) -> Unit): Path { + val output = root.resolve(name) + val config = base.deepCopy().apply { put("outputDir", output.toString()) } + configure(config, output) + val manifest = root.resolve("$name.json") + mapper.writeValue(manifest.toFile(), config) + val log = root.resolve("$name.log") + val java = if (System.getProperty("os.name").startsWith("Windows")) "java.exe" else "java" + val process = ProcessBuilder( + Paths.get(System.getProperty("java.home"), "bin", java).toString(), + "-cp", classpath.joinToString(File.pathSeparator), + "rules.dokka.DokkaGeneratorRunner", manifest.toString(), + ).redirectErrorStream(true).redirectOutput(log.toFile()).start() + try { + check(process.waitFor(60, TimeUnit.SECONDS)) { "$name timed out: ${Files.readString(log)}" } + check(process.exitValue() == expectedExit) { + "$name exited ${process.exitValue()}, expected $expectedExit: ${Files.readString(log)}" + } + } finally { + process.destroyForcibly().waitFor() + } + return output + } + + run("write-error", 1) { config, output -> + config.put("failOnWarning", false) + Files.createDirectories(output.resolve("index.html")) + } + val writeErrorLog = Files.readString(root.resolve("write-error.log")) + check(writeErrorLog.contains("ERROR: Failed to write")) { writeErrorLog } + + val empty = run("suppressed", 0) { config, _ -> + (config["sourceSets"][0] as ObjectNode).putArray("suppressedFiles").add(source.toString()) + } + check(Files.isDirectory(empty)) + Files.list(empty).use { check(!it.findAny().isPresent) } + + run("warning", 0) { config, _ -> + (config["sourceSets"][0] as ObjectNode).put("reportUndocumented", true) + } + check(Files.readString(root.resolve("warning.log")).contains("WARN:")) + + run("strict-warning", 1) { config, _ -> + config.put("failOnWarning", true) + (config["sourceSets"][0] as ObjectNode).put("reportUndocumented", true) + } + check(Files.readString(root.resolve("strict-warning.log")).contains("DokkaException")) + } +}