diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4a6fa4ff..26ea9824 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,8 @@ kotlin = "1.9.0" # should match Gradle's embedded Kotlin version https://docs.gr kotlin-dokka = "1.9.0" kotlinx-serialization = "1.6.0" +ktor = "2.3.6" + kotest = "5.6.2" gradlePlugin-android = "8.0.2" @@ -24,6 +26,13 @@ kotlinxSerialization-bom = { module = "org.jetbrains.kotlinx:kotlinx-serializati kotlinxSerialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json" } #kotlinxSerialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +##region ktor +ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } + +ktorServer-core = { group = "io.ktor", name = "ktor-server-core" } +ktorServer-cio = { group = "io.ktor", name = "ktor-server-cio" } +##endregion + ### Test libraries ### diff --git a/modules/dokkatoo-plugin/build.gradle.kts b/modules/dokkatoo-plugin/build.gradle.kts index 277c1e5f..b6d6ace9 100644 --- a/modules/dokkatoo-plugin/build.gradle.kts +++ b/modules/dokkatoo-plugin/build.gradle.kts @@ -164,6 +164,12 @@ testing.suites { shouldRunAfter(test) } } + + dependencies { + implementation(project.dependencies.platform(libs.ktor.bom)) + implementation(libs.ktorServer.core) + implementation(libs.ktorServer.cio) + } } tasks.check { dependsOn(test, testFunctional) } diff --git a/modules/dokkatoo-plugin/src/main/kotlin/formats/DokkatooFormatPlugin.kt b/modules/dokkatoo-plugin/src/main/kotlin/formats/DokkatooFormatPlugin.kt index 2966a040..90aabbff 100644 --- a/modules/dokkatoo-plugin/src/main/kotlin/formats/DokkatooFormatPlugin.kt +++ b/modules/dokkatoo-plugin/src/main/kotlin/formats/DokkatooFormatPlugin.kt @@ -5,7 +5,7 @@ import dev.adamko.dokkatoo.DokkatooExtension import dev.adamko.dokkatoo.adapters.DokkatooAndroidAdapter import dev.adamko.dokkatoo.adapters.DokkatooJavaAdapter import dev.adamko.dokkatoo.adapters.DokkatooKotlinAdapter -import dev.adamko.dokkatoo.internal.* +import dev.adamko.dokkatoo.internal.DokkatooInternalApi import javax.inject.Inject import org.gradle.api.Plugin import org.gradle.api.Project diff --git a/modules/dokkatoo-plugin/src/main/kotlin/formats/DokkatooHtmlPlugin.kt b/modules/dokkatoo-plugin/src/main/kotlin/formats/DokkatooHtmlPlugin.kt index 5100c39c..0e5331fb 100644 --- a/modules/dokkatoo-plugin/src/main/kotlin/formats/DokkatooHtmlPlugin.kt +++ b/modules/dokkatoo-plugin/src/main/kotlin/formats/DokkatooHtmlPlugin.kt @@ -53,7 +53,9 @@ constructor() : DokkatooFormatPlugin(formatName = "html") { private fun DokkatooFormatPluginContext.registerLogHtmlUrlTask(): TaskProvider { - val indexHtmlFile = dokkatooTasks.generatePublication + val generatePublicationTask = dokkatooTasks.generatePublication + + val indexHtmlFile = generatePublicationTask .flatMap { it.outputDirectory.file("index.html") } val indexHtmlPath = indexHtmlFile.map { indexHtml -> @@ -63,8 +65,10 @@ constructor() : DokkatooFormatPlugin(formatName = "html") { } return project.tasks.register( - "logLink" + dokkatooTasks.generatePublication.name.uppercaseFirstChar() + "logLink" + generatePublicationTask.name.uppercaseFirstChar() ) { + // default port of IntelliJ built-in server is defined in the docs + // https://www.jetbrains.com/help/idea/settings-debugger.html#24aabda8 serverUri.convention("http://localhost:63342") this.indexHtmlPath.convention(indexHtmlPath) } diff --git a/modules/dokkatoo-plugin/src/main/kotlin/tasks/LogHtmlPublicationLinkTask.kt b/modules/dokkatoo-plugin/src/main/kotlin/tasks/LogHtmlPublicationLinkTask.kt index 8493daa9..270b9b14 100644 --- a/modules/dokkatoo-plugin/src/main/kotlin/tasks/LogHtmlPublicationLinkTask.kt +++ b/modules/dokkatoo-plugin/src/main/kotlin/tasks/LogHtmlPublicationLinkTask.kt @@ -17,6 +17,7 @@ import org.gradle.api.tasks.Console import org.gradle.api.tasks.TaskAction import org.gradle.kotlin.dsl.* import org.gradle.work.DisableCachingByDefault +import org.slf4j.LoggerFactory /** * Prints an HTTP link in the console when the HTML publication is generated. @@ -24,10 +25,16 @@ import org.gradle.work.DisableCachingByDefault * The HTML publication requires a web server, since it loads resources via javascript. * * By default, it uses - * [IntelliJ's built-in server](https://www.jetbrains.com/help/idea/php-built-in-web-server.html) + * [IntelliJ's built-in server](https://www.jetbrains.com/help/phpstorm/php-built-in-web-server.html#ws_html_preview_output_built_in_browser)† * to host the file. * + * * This task can be disabled using the [ENABLE_TASK_PROPERTY_NAME] project property. + * + * --- + * + * † For some reason there only doc page for the built-in server I could find is for PhpStorm, + * but the built-in server is also available in IntelliJ IDEA.) */ @DisableCachingByDefault(because = "logging-only task") abstract class LogHtmlPublicationLinkTask @@ -56,7 +63,7 @@ constructor( * ``` * /Users/rachel/projects/my-project/docs/build/dokka/html/index.html * ```` - * * then IntelliJ requires the [indexHtmlPath] is + * * then IntelliJ requires [indexHtmlPath] is * ``` * my-project/docs/build/dokka/html/index.html * ``` @@ -85,17 +92,37 @@ constructor( super.onlyIf("task is enabled via property") { logHtmlPublicationLinkTaskEnabled.get() } + + super.onlyIf("${::serverUri.name} is present") { + !serverUri.orNull.isNullOrBlank() + } + + super.onlyIf("${::indexHtmlPath.name} is present") { + !indexHtmlPath.orNull.isNullOrBlank() + } } @TaskAction fun exec() { - val serverUri = serverUri.orNull - val filePath = indexHtmlPath.orNull + val serverUri = serverUri.get() + val indexHtmlPath = indexHtmlPath.get() - if (serverUri != null && !filePath.isNullOrBlank()) { - val link = URI(serverUri).appendPath(filePath).toString() + logger.info( + "LogHtmlPublicationLinkTask received variables " + + "serverUri:$serverUri, " + + "indexHtmlPath:$indexHtmlPath" + ) - logger.lifecycle("Generated Dokka HTML publication: $link") + val link = URI(serverUri).appendPath(indexHtmlPath) + + logger.lifecycle("Generated Dokka HTML publication: $link") + + val statusCode = httpGet(link).statusCode() + if (statusCode !in 200..299) { + logger.warn( + "Warning: $link returned unsuccessful status code $statusCode. " + + "Does the index.html file exist, or is the server misconfigured?" + ) } } @@ -110,27 +137,27 @@ constructor( */ internal abstract class ServerActiveCheck : ValueSource { + private val logger = LoggerFactory.getLogger(ServerActiveCheck::class.java) + interface Parameters : ValueSourceParameters { - /** E.g. `http://localhost:63342` */ + /** + * IntelliJ built-in server's default address is `http://localhost:63342` + * See https://www.jetbrains.com/help/idea/settings-debugger.html. + */ val uri: Property } override fun obtain(): Boolean { - try { + return try { val uri = URI.create(parameters.uri.get()) - val client = HttpClient.newHttpClient() - val request = HttpRequest - .newBuilder() - .uri(uri) - .timeout(Duration.ofSeconds(1)) - .GET() - .build() - val response = client.send(request, HttpResponse.BodyHandlers.ofString()) + val response = httpGet(uri) // don't care about the status - only if the server is available - return response.statusCode() > 0 + logger.info("got ${response.statusCode()} from $uri") + response.statusCode() > 0 } catch (ex: Exception) { - return false + logger.info("could not reach URI ${parameters.uri.get()}: $ex") + false } } } @@ -140,8 +167,10 @@ constructor( * Control whether the [LogHtmlPublicationLinkTask] task is enabled. Useful for disabling the * task locally, or in CI/CD, or for tests. * + * It can be set in any `gradle.properties` file. For example, on a specific machine: + * * ```properties - * #$GRADLE_USER_HOME/gradle.properties + * # $GRADLE_USER_HOME/gradle.properties * dev.adamko.dokkatoo.tasks.logHtmlPublicationLinkEnabled=false * ``` * @@ -152,5 +181,16 @@ constructor( * ``` */ const val ENABLE_TASK_PROPERTY_NAME = "dev.adamko.dokkatoo.tasks.logHtmlPublicationLinkEnabled" + + private fun httpGet(uri: URI): HttpResponse { + val client = HttpClient.newHttpClient() + val request = HttpRequest + .newBuilder() + .uri(uri) + .timeout(Duration.ofSeconds(1)) + .GET() + .build() + return client.send(request, HttpResponse.BodyHandlers.ofString()) + } } } diff --git a/modules/dokkatoo-plugin/src/testFixtures/kotlin/GradleTestKitUtils.kt b/modules/dokkatoo-plugin/src/testFixtures/kotlin/GradleTestKitUtils.kt index 3d2b38b6..675ebc63 100644 --- a/modules/dokkatoo-plugin/src/testFixtures/kotlin/GradleTestKitUtils.kt +++ b/modules/dokkatoo-plugin/src/testFixtures/kotlin/GradleTestKitUtils.kt @@ -22,16 +22,19 @@ class GradleProjectTest( baseDir: Path = funcTestTempDir, ) : this(projectDir = baseDir.resolve(testProjectName)) + /** Args that will be added to every [runner] */ + val defaultRunnerArgs: MutableList = mutableListOf( + // disable the logging task so the tests work consistently on local machines and CI/CD + "-P" + "dev.adamko.dokkatoo.tasks.logHtmlPublicationLinkEnabled=false" + ) + val runner: GradleRunner get() = GradleRunner.create() .withProjectDir(projectDir.toFile()) .withJvmArguments( "-XX:MaxMetaspaceSize=512m", "-XX:+AlwaysPreTouch", // https://github.com/gradle/gradle/issues/3093#issuecomment-387259298 - ).addArguments( - // disable the logging task so the tests work consistently on local machines and CI/CD - "-P" + "dev.adamko.dokkatoo.tasks.logHtmlPublicationLinkEnabled=false" - ) + ).addArguments(*defaultRunnerArgs.toTypedArray()) val testMavenRepoRelativePath: String = projectDir.relativize(testMavenRepoDir).toFile().invariantSeparatorsPath diff --git a/modules/dokkatoo-plugin/src/testFunctional/kotlin/DokkatooPluginFunctionalTest.kt b/modules/dokkatoo-plugin/src/testFunctional/kotlin/DokkatooPluginFunctionalTest.kt index 04c6badf..6ad88a06 100644 --- a/modules/dokkatoo-plugin/src/testFunctional/kotlin/DokkatooPluginFunctionalTest.kt +++ b/modules/dokkatoo-plugin/src/testFunctional/kotlin/DokkatooPluginFunctionalTest.kt @@ -51,7 +51,7 @@ class DokkatooPluginFunctionalTest : FunSpec({ } test("expect Dokka Plugin creates Dokka outgoing variants") { - val build = testProject.runner + testProject.runner .addArguments("outgoingVariants", "-q") .build { val variants = output.invariantNewlines().replace('\\', '/') diff --git a/modules/dokkatoo-plugin/src/testFunctional/kotlin/tasks/LogHtmlPublicationLinkTaskTest.kt b/modules/dokkatoo-plugin/src/testFunctional/kotlin/tasks/LogHtmlPublicationLinkTaskTest.kt new file mode 100644 index 00000000..a8eef704 --- /dev/null +++ b/modules/dokkatoo-plugin/src/testFunctional/kotlin/tasks/LogHtmlPublicationLinkTaskTest.kt @@ -0,0 +1,168 @@ +package dev.adamko.dokkatoo.tasks + +import dev.adamko.dokkatoo.internal.DokkatooConstants +import dev.adamko.dokkatoo.tasks.LogHtmlPublicationLinkTask.Companion.ENABLE_TASK_PROPERTY_NAME +import dev.adamko.dokkatoo.utils.* +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import org.gradle.testkit.runner.TaskOutcome.SKIPPED +import org.gradle.testkit.runner.TaskOutcome.SUCCESS + +class LogHtmlPublicationLinkTaskTest : FunSpec({ + + context("given an active file-host server") { + val server = embeddedServer(CIO, port = 0) { } + server.start(wait = false) + val serverPort = server.resolvedConnectors().first().port + + val validServerUri = "http://localhost:$serverPort" + val invalidServerUri = "http://localhost:$serverPort/wrong/path/" + val validServerUriParam = `-P`("testServerUri=$validServerUri") + val invalidServerUriParam = `-P`("testServerUri=$invalidServerUri") + + context("and a Kotlin project") { + val project = initDokkatooProject() + + context("when generate task is run with incorrect server URI") { + project.runner + .addArguments( + "clean", + "dokkatooGeneratePublicationHtml", + "--stacktrace", + "--info", + invalidServerUriParam, + ) + .forwardOutput() + .build { + test("expect project builds successfully") { + output shouldContain "BUILD SUCCESSFUL" + } + test("LogHtmlPublicationLinkTask should run") { + shouldHaveTasksWithAnyOutcome( + ":logLinkDokkatooGeneratePublicationHtml" to listOf(SUCCESS) + ) + } + test("expect invalid link is logged, with warning") { + output shouldContain "Generated Dokka HTML publication: $invalidServerUri" + output shouldContain "Warning: ${invalidServerUri}log-html-publication-link-task/build/dokka/html/index.html returned unsuccessful status code 404" + output shouldContain "Does the index.html file exist, or is the server misconfigured?" + } + } + } + + context("when generate task is run with correct server URI") { + project.runner + .addArguments( + "clean", + "dokkatooGeneratePublicationHtml", + "--stacktrace", + "--info", + validServerUriParam, + ) + .forwardOutput() + .build { + test("expect project builds successfully") { + output shouldContain "BUILD SUCCESSFUL" + } + test("LogHtmlPublicationLinkTask should run") { + shouldHaveTasksWithAnyOutcome( + ":logLinkDokkatooGeneratePublicationHtml" to listOf(SUCCESS) + ) + } + test("expect link is logged") { + output shouldContain "Generated Dokka HTML publication: $validServerUri/log-html-publication-link-task/build/dokka/html/index.html" + } + } + } + + context("and the server is down") { + // stop the server immediately + server.stop(gracePeriodMillis = 0, timeoutMillis = 0) + + context("when running the generate task") { + project.runner + .addArguments( + "clean", + "dokkatooGeneratePublicationHtml", + "--stacktrace", + "--info", + validServerUriParam, + ) + .forwardOutput() + .build { + test("expect project builds successfully") { + output shouldContain "BUILD SUCCESSFUL" + } + test("LogHtmlPublicationLinkTask should be skipped") { + shouldHaveTasksWithAnyOutcome( + ":logLinkDokkatooGeneratePublicationHtml" to listOf(SKIPPED) + ) + output shouldContain "Skipping task ':logLinkDokkatooGeneratePublicationHtml' as task onlyIf 'server URL is reachable' is false" + } + test("expect link is not logged") { + output shouldNotContain "Generated Dokka HTML publication" + } + } + } + } + } + } +}) { + companion object { + @Suppress("SpellCheckingInspection") + /** + * prefix [param] with `-P`. + * + * (this exists to avoid annoying typo warnings, e.g. `-Pserver=localhost` -> `Typo: In word 'Pserver'`) + */ + private fun `-P`(param: String): String = "-P$param" + } +} + + +private fun initDokkatooProject( + config: GradleProjectTest.() -> Unit = {}, +): GradleProjectTest { + return gradleKtsProjectTest("log-html-publication-link-task") { + buildGradleKts = """ + |plugins { + | kotlin("jvm") version "1.8.22" + | id("dev.adamko.dokkatoo") version "${DokkatooConstants.DOKKATOO_VERSION}" + |} + | + |dependencies { + | dokkatooPluginHtml( + | dokkatoo.versions.jetbrainsDokka.map { dokkaVersion -> + | "org.jetbrains.dokka:all-modules-page-plugin:${'$'}dokkaVersion" + | } + | ) + |} + | + |tasks.withType().configureEach { + | serverUri.set(providers.gradleProperty("testServerUri")) + |} + """.trimMargin() + + createKotlinFile( + "src/main/kotlin/Hello.kt", + """ + |package com.project.hello + | + |/** The Hello class */ + |class Hello { + | /** prints `Hello` to the console */ + | fun sayHello() = println("Hello") + |} + | + """.trimMargin() + ) + + // remove the flag that disables the logging task, since this test wants the logger to run + defaultRunnerArgs.removeIf { ENABLE_TASK_PROPERTY_NAME in it } + + config() + } +}