You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by sn...@apache.org on 2026/06/19 14:06:39 UTC
(polaris) branch main updated: Add polaris-server-test-runner plugin (#4732)
This is an automated email from the ASF dual-hosted git repository.
snazy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git
The following commit(s) were added to refs/heads/main by this push:
new 3f4143af85 Add polaris-server-test-runner plugin (#4732)
3f4143af85 is described below
commit 3f4143af85571c86c9daf2b8a63768e06b87e5fe
Author: Robert Stupp <sn...@snazy.de>
AuthorDate: Fri Jun 19 16:06:35 2026 +0200
Add polaris-server-test-runner plugin (#4732)
This new plugin for test purposes mainly buys us separation and faster feedback:
- Test projects no longer need to build or embed a full Quarkus test app just to run against Polaris.
- Tests can depend on a ready `quarkus-run.jar` via `polarisServer(...)`, so the server lifecycle is externalized from the test JVM.
- Less Quarkus application builds across all checks, improving overall test runtime.
- Quarkus dependencies stop leaking into test classpaths, which reduces dependency conflicts with Spark, Iceberg, Hadoop, etc.
- The plugin makes `enforcedPlatform(libs.quarkus.bom)` safer because Quarkus is mostly constrained to the app/server side instead of the tests.
- Each test task can get its own isolated Polaris server instance and config.
- Projects can use normal `JvmTestSuite`s, which improves Gradle/IntelliJ behavior and keeps test classpaths scoped.
- Custom server startup hooks still allow task-specific setup, like OPA/Testcontainers or RustFS, without baking those concerns into the plugin.
---
.gitignore | 2 +
gradle/server-test-runner/build.gradle.kts | 43 +++
.../server-test-runner/gradle/libs.versions.toml | 22 ++
gradle/server-test-runner/settings.gradle.kts | 24 ++
.../runner/spi/PolarisServerStartupAction.java | 36 +++
.../runner/spi/PolarisServerStartupContext.java | 40 +++
.../kotlin/PolarisServerTestRunnerExtensions.kt | 107 ++++++++
.../runner/PolarisServerTestRunnerExtension.kt | 136 ++++++++++
.../test/runner/PolarisServerTestRunnerPlugin.kt | 62 +++++
.../server/test/runner/PolarisServerTestService.kt | 54 ++++
.../test/runner/PolarisServerTestTaskConfigurer.kt | 194 ++++++++++++++
.../server/test/runner/RunningPolarisServer.kt | 193 ++++++++++++++
.../runner/PolarisServerTestRunnerPluginTest.kt | 295 +++++++++++++++++++++
settings.gradle.kts | 2 +
14 files changed, 1210 insertions(+)
diff --git a/.gitignore b/.gitignore
index 9a5ebaf9db..477e2406a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,6 +69,8 @@ gradle/wrapper/gradle-wrapper-*.sha256
/.gradle
/build-logic/.gradle
/build-logic/.kotlin
+/gradle/server-test-runner/.gradle
+/gradle/server-test-runner/.kotlin
**/build/
!src/**/build/
diff --git a/gradle/server-test-runner/build.gradle.kts b/gradle/server-test-runner/build.gradle.kts
new file mode 100644
index 0000000000..504ebff8fc
--- /dev/null
+++ b/gradle/server-test-runner/build.gradle.kts
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+ `kotlin-dsl`
+ `java-gradle-plugin`
+}
+
+gradlePlugin {
+ plugins {
+ create("polarisServerTestRunner") {
+ id = "polaris-server-test-runner"
+ implementationClass = "org.apache.polaris.server.test.runner.PolarisServerTestRunnerPlugin"
+ }
+ }
+}
+
+testing { suites { named<JvmTestSuite>("test") { useJUnitJupiter() } } }
+
+dependencies {
+ testImplementation(gradleTestKit())
+ testImplementation(platform(libs.junit.bom))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation(libs.assertj.core)
+}
+
+group = "org.apache.polaris.server-test-runner"
diff --git a/gradle/server-test-runner/gradle/libs.versions.toml b/gradle/server-test-runner/gradle/libs.versions.toml
new file mode 100644
index 0000000000..a8e5324ce7
--- /dev/null
+++ b/gradle/server-test-runner/gradle/libs.versions.toml
@@ -0,0 +1,22 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+[libraries]
+assertj-core = { module = "org.assertj:assertj-core", version = "3.27.7" }
+junit-bom = { module = "org.junit:junit-bom", version = "6.1.0" }
diff --git a/gradle/server-test-runner/settings.gradle.kts b/gradle/server-test-runner/settings.gradle.kts
new file mode 100644
index 0000000000..0f3d5c5455
--- /dev/null
+++ b/gradle/server-test-runner/settings.gradle.kts
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+pluginManagement { repositories { gradlePluginPortal() } }
+
+dependencyResolutionManagement { repositories { mavenCentral() } }
+
+rootProject.name = "polaris-server-test-runner"
diff --git a/gradle/server-test-runner/src/main/java/org/apache/polaris/server/test/runner/spi/PolarisServerStartupAction.java b/gradle/server-test-runner/src/main/java/org/apache/polaris/server/test/runner/spi/PolarisServerStartupAction.java
new file mode 100644
index 0000000000..4301579999
--- /dev/null
+++ b/gradle/server-test-runner/src/main/java/org/apache/polaris/server/test/runner/spi/PolarisServerStartupAction.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.server.test.runner.spi;
+
+/**
+ * Isolated startup action that can prepare external services before the Polaris server process
+ * starts.
+ *
+ * <p>Implementations are loaded from a child classloader configured by the build and may mutate the
+ * provided context to pass task-specific system properties or environment variables to the Polaris
+ * server. Implementations are closed after the decorated test task finishes, or when startup fails.
+ */
+public interface PolarisServerStartupAction extends AutoCloseable {
+ /** Starts required services and updates the Polaris server startup context. */
+ void start(PolarisServerStartupContext context) throws Exception;
+
+ /** Stops resources owned by this action. */
+ @Override
+ default void close() throws Exception {}
+}
diff --git a/gradle/server-test-runner/src/main/java/org/apache/polaris/server/test/runner/spi/PolarisServerStartupContext.java b/gradle/server-test-runner/src/main/java/org/apache/polaris/server/test/runner/spi/PolarisServerStartupContext.java
new file mode 100644
index 0000000000..b5076873fb
--- /dev/null
+++ b/gradle/server-test-runner/src/main/java/org/apache/polaris/server/test/runner/spi/PolarisServerStartupContext.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.server.test.runner.spi;
+
+import java.util.Map;
+
+/** Context passed to an isolated {@link PolarisServerStartupAction}. */
+public interface PolarisServerStartupContext {
+ /**
+ * Build-configured startup-action parameters.
+ *
+ * <p>These values come from the Gradle plugin's {@code startupActionParameters} map and are only
+ * intended for communication between the build script and the startup action. They are not JVM
+ * arguments, Quarkus configuration properties, or environment variables unless the startup action
+ * explicitly copies them into {@link #getSystemProperties()} or {@link #getEnvironment()}.
+ */
+ Map<String, String> getParameters();
+
+ /** Mutable system properties that will be passed to the Polaris server JVM. */
+ Map<String, String> getSystemProperties();
+
+ /** Mutable environment variables that will be passed to the Polaris server process. */
+ Map<String, String> getEnvironment();
+}
diff --git a/gradle/server-test-runner/src/main/kotlin/PolarisServerTestRunnerExtensions.kt b/gradle/server-test-runner/src/main/kotlin/PolarisServerTestRunnerExtensions.kt
new file mode 100644
index 0000000000..e994a4c475
--- /dev/null
+++ b/gradle/server-test-runner/src/main/kotlin/PolarisServerTestRunnerExtensions.kt
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import org.apache.polaris.server.test.runner.PolarisServerTestRunnerExtension
+import org.apache.polaris.server.test.runner.PolarisServerTestRunnerPlugin
+import org.apache.polaris.server.test.runner.configurePolarisServer
+import org.apache.polaris.server.test.runner.conventionFrom
+import org.gradle.api.Action
+import org.gradle.api.file.FileCollection
+import org.gradle.api.provider.Provider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.process.CommandLineArgumentProvider
+
+/**
+ * Runs a Polaris server from [server] for the duration of this [Test] task.
+ *
+ * The file collection must resolve to exactly one runnable jar. The server is started only when the
+ * test task executes, not during configuration. The discovered HTTP and management ports are passed
+ * to the test JVM using the default property names from [PolarisServerTestRunnerExtension].
+ */
+fun Test.withPolarisServer(server: FileCollection) {
+ withPolarisServer(server) {}
+}
+
+/**
+ * Runs a Polaris server from the file collection provided by [server] for the duration of this
+ * [Test] task.
+ *
+ * This overload is convenient for lazily named configurations such as
+ * `withPolarisServer(configurations.polarisServer)`.
+ */
+fun Test.withPolarisServer(server: Provider<out FileCollection>) {
+ withPolarisServer(server) {}
+}
+
+/**
+ * Runs a Polaris server from [server] for the duration of this [Test] task and customizes its
+ * process settings using [configure].
+ *
+ * Use this overload when the server artifact is task-specific, for example
+ * `withPolarisServer(configurations.polarisServer) { ... }`. Dynamic port values are passed via a
+ * [CommandLineArgumentProvider] so they do not become test task inputs.
+ */
+fun Test.withPolarisServer(
+ server: FileCollection,
+ configure: Action<PolarisServerTestRunnerExtension>,
+) {
+ val extension = polarisServerTestRunnerExtension()
+ extension.server.setFrom(server)
+ configure.execute(extension)
+ configurePolarisServer(extension)
+}
+
+/**
+ * Runs a Polaris server from the file collection provided by [server] for the duration of this
+ * [Test] task and customizes its process settings using [configure].
+ *
+ * Use this overload when the server artifact is exposed through a lazy Gradle provider, for example
+ * `withPolarisServer(configurations.polarisServer) { ... }`.
+ */
+fun Test.withPolarisServer(
+ server: Provider<out FileCollection>,
+ configure: Action<PolarisServerTestRunnerExtension>,
+) {
+ val extension = polarisServerTestRunnerExtension()
+ extension.server.setFrom(server)
+ configure.execute(extension)
+ configurePolarisServer(extension)
+}
+
+/**
+ * Runs a Polaris server for the duration of this [Test] task using the plugin extension's
+ * configured [PolarisServerTestRunnerExtension.server] artifact.
+ *
+ * Use this overload when the `polarisServerTestRunner` extension is configured globally and the
+ * test task only needs to opt into the server lifecycle.
+ */
+fun Test.withPolarisServer(configure: Action<PolarisServerTestRunnerExtension>) {
+ val extension = polarisServerTestRunnerExtension()
+ configure.execute(extension)
+ configurePolarisServer(extension)
+}
+
+private fun Test.polarisServerTestRunnerExtension(): PolarisServerTestRunnerExtension {
+ val projectExtension =
+ project.extensions.getByName(PolarisServerTestRunnerPlugin.POLARIS_SERVER_EXTENSION)
+ as PolarisServerTestRunnerExtension
+ return project.objects
+ .newInstance(PolarisServerTestRunnerExtension::class.java, project, projectExtension.service)
+ .apply { conventionFrom(projectExtension) }
+}
diff --git a/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestRunnerExtension.kt b/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestRunnerExtension.kt
new file mode 100644
index 0000000000..24452ec92a
--- /dev/null
+++ b/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestRunnerExtension.kt
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.server.test.runner
+
+import java.time.Duration
+import javax.inject.Inject
+import org.gradle.api.Project
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.MapProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.provider.Provider
+
+/**
+ * Configuration for running a Polaris server process for Gradle test tasks.
+ *
+ * The plugin starts the configured server artifact before a decorated
+ * [Test][org.gradle.api.tasks.testing.Test] task executes and stops it when the task's root test
+ * suite completes. The discovered Quarkus HTTP and management ports are passed to the test JVM
+ * using the property names configured here.
+ */
+abstract class PolarisServerTestRunnerExtension
+@Inject
+constructor(project: Project, internal val service: Provider<PolarisServerTestService>) {
+ /** Server artifact to execute. This must resolve to exactly one runnable `java -jar` artifact. */
+ val server: ConfigurableFileCollection = project.objects.fileCollection()
+
+ /**
+ * Classpath used to load the optional startup action in an isolated child classloader.
+ *
+ * The startup action can prepare external services before the Polaris server starts and can add
+ * dynamic server system properties or environment variables through the startup context.
+ */
+ val startupActionClasspath: ConfigurableFileCollection = project.objects.fileCollection()
+
+ /**
+ * Fully qualified implementation class name of the optional startup action.
+ *
+ * The class must implement [org.apache.polaris.server.test.runner.spi.PolarisServerStartupAction]
+ * and have a public no-argument constructor.
+ */
+ val startupActionClass: Property<String> = project.objects.property(String::class.java)
+
+ /** String parameters passed to the optional startup action. */
+ val startupActionParameters: MapProperty<String, String> =
+ project.objects.mapProperty(String::class.java, String::class.java)
+
+ /** Working directory for the server process. Defaults to `build/polaris-server`. */
+ val workingDirectory: DirectoryProperty =
+ project.objects
+ .directoryProperty()
+ .convention(project.layout.buildDirectory.dir("polaris-server"))
+
+ /** Maximum time to wait for the server process to print its Quarkus listen ports. */
+ val startupTimeout: Property<Duration> =
+ project.objects.property(Duration::class.java).convention(Duration.ofSeconds(30))
+
+ /** Maximum time to wait for the server process to stop gracefully before it is killed. */
+ val stopTimeout: Property<Duration> =
+ project.objects.property(Duration::class.java).convention(Duration.ofSeconds(15))
+
+ /** Test JVM system property name that receives the discovered HTTP port. */
+ val httpPortProperty: Property<String> =
+ project.objects.property(String::class.java).convention("quarkus.http.test-port")
+
+ /** Test JVM system property name that receives the discovered management port, when present. */
+ val managementPortProperty: Property<String> =
+ project.objects.property(String::class.java).convention("quarkus.management.test-port")
+
+ /**
+ * System properties passed to the server JVM.
+ *
+ * These properties are also declared as inputs of the decorated test task.
+ */
+ val systemProperties: MapProperty<String, String> =
+ project.objects.mapProperty(String::class.java, String::class.java)
+
+ /**
+ * Environment variables passed to the server process.
+ *
+ * These variables are also declared as inputs of the decorated test task.
+ */
+ val environment: MapProperty<String, String> =
+ project.objects.mapProperty(String::class.java, String::class.java)
+
+ /**
+ * JVM arguments passed before `-jar` when starting the server.
+ *
+ * These arguments are also declared as inputs of the decorated test task.
+ */
+ val jvmArguments: ListProperty<String> =
+ project.objects.listProperty(String::class.java).convention(emptyList())
+
+ /**
+ * Application arguments passed after the server jar path.
+ *
+ * These arguments are also declared as inputs of the decorated test task.
+ */
+ val arguments: ListProperty<String> =
+ project.objects.listProperty(String::class.java).convention(emptyList())
+}
+
+internal fun PolarisServerTestRunnerExtension.conventionFrom(
+ other: PolarisServerTestRunnerExtension
+) {
+ server.setFrom(other.server)
+ startupActionClasspath.setFrom(other.startupActionClasspath)
+ startupActionClass.convention(other.startupActionClass)
+ startupActionParameters.convention(other.startupActionParameters)
+ workingDirectory.convention(other.workingDirectory)
+ startupTimeout.convention(other.startupTimeout)
+ stopTimeout.convention(other.stopTimeout)
+ httpPortProperty.convention(other.httpPortProperty)
+ managementPortProperty.convention(other.managementPortProperty)
+ systemProperties.convention(other.systemProperties)
+ environment.convention(other.environment)
+ jvmArguments.convention(other.jvmArguments)
+ arguments.convention(other.arguments)
+}
diff --git a/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestRunnerPlugin.kt b/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestRunnerPlugin.kt
new file mode 100644
index 0000000000..6dfeba8dbc
--- /dev/null
+++ b/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestRunnerPlugin.kt
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.server.test.runner
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+
+class PolarisServerTestRunnerPlugin : Plugin<Project> {
+ override fun apply(project: Project) {
+ val serverConfiguration =
+ project.configurations.create(POLARIS_SERVER_CONFIGURATION) {
+ isCanBeConsumed = false
+ isCanBeResolved = true
+ isTransitive = false
+ description = "Runnable Polaris server artifact used by integration test tasks."
+ }
+
+ // We need one service per project to prevent classloader isolation issues.
+ // Build services are global to the current Gradle build invocation and shared
+ // across projects. As plugins are applied to projects, those use a "per project"
+ // class loader, which can then cause classic `ClassCastException` with a cause
+ // that `PolarisServerTestService$Inject_` from class loader A cannot be cast to
+ // `PolarisServerTestService` from class loader B.
+ // Using a project-scoped service name prevents this issue.
+ val serviceName = "polarisServerTestRunner-${project.path}"
+ val service =
+ project.gradle.sharedServices.registerIfAbsent(
+ serviceName,
+ PolarisServerTestService::class.java,
+ ) {}
+
+ val extension =
+ project.extensions.create(
+ POLARIS_SERVER_EXTENSION,
+ PolarisServerTestRunnerExtension::class.java,
+ project,
+ service,
+ )
+ extension.server.convention(serverConfiguration)
+ }
+
+ companion object {
+ const val POLARIS_SERVER_CONFIGURATION = "polarisServer"
+ const val POLARIS_SERVER_EXTENSION = "polarisServerTestRunner"
+ }
+}
diff --git a/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestService.kt b/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestService.kt
new file mode 100644
index 0000000000..cae1aae865
--- /dev/null
+++ b/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestService.kt
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.server.test.runner
+
+import org.gradle.api.Task
+import org.gradle.api.logging.Logging
+import org.gradle.api.services.BuildService
+import org.gradle.api.services.BuildServiceParameters
+
+abstract class PolarisServerTestService : BuildService<BuildServiceParameters.None>, AutoCloseable {
+ private val servers = linkedMapOf<String, RunningPolarisServer>()
+ private val logger = Logging.getLogger(PolarisServerTestService::class.java)
+
+ internal fun register(task: Task, server: RunningPolarisServer) {
+ synchronized(servers) {
+ if (servers.containsKey(task.path)) {
+ throw IllegalStateException("Server already registered for task ${task.path}")
+ }
+ servers[task.path] = server
+ }
+ }
+
+ fun finished(task: Task) {
+ val server = synchronized(servers) { servers.remove(task.path) }
+ server?.stop(task.logger)
+ }
+
+ override fun close() {
+ val remaining = synchronized(servers) { servers.values.toList().also { servers.clear() } }
+ if (remaining.isNotEmpty()) {
+ logger.warn(
+ "Stopping {} Polaris server process(es) during build service cleanup",
+ remaining.size,
+ )
+ }
+ remaining.forEach { it.stop(logger) }
+ }
+}
diff --git a/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestTaskConfigurer.kt b/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestTaskConfigurer.kt
new file mode 100644
index 0000000000..2478341c01
--- /dev/null
+++ b/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestTaskConfigurer.kt
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.server.test.runner
+
+import java.io.File
+import java.net.URLClassLoader
+import org.apache.polaris.server.test.runner.spi.PolarisServerStartupAction
+import org.apache.polaris.server.test.runner.spi.PolarisServerStartupContext
+import org.gradle.api.GradleException
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestDescriptor
+import org.gradle.api.tasks.testing.TestListener
+import org.gradle.api.tasks.testing.TestResult
+import org.gradle.process.CommandLineArgumentProvider
+
+internal fun Test.configurePolarisServer(extension: PolarisServerTestRunnerExtension) {
+ val dynamicArguments = PolarisServerArguments()
+ var startupAction: AutoCloseable? = null
+ jvmArgumentProviders.add(dynamicArguments)
+ inputs.files(extension.server).withPathSensitivity(PathSensitivity.RELATIVE)
+ inputs.files(extension.startupActionClasspath).withPathSensitivity(PathSensitivity.RELATIVE)
+ inputs.property("polarisServerStartupActionClass", extension.startupActionClass.orNull ?: "")
+ inputs.properties(extension.startupActionParameters.get())
+ inputs.properties(extension.systemProperties.get())
+ inputs.properties(extension.environment.get())
+ inputs.property("polarisServerJvmArguments", extension.jvmArguments.get())
+ inputs.property("polarisServerArguments", extension.arguments.get())
+ usesService(extension.service)
+ addTestListener(
+ object : TestListener {
+ override fun beforeSuite(suite: TestDescriptor) {}
+
+ override fun beforeTest(testDescriptor: TestDescriptor) {}
+
+ override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {}
+
+ override fun afterSuite(suite: TestDescriptor, result: TestResult) {
+ if (suite.parent == null) {
+ extension.service.get().finished(this@configurePolarisServer)
+ startupAction?.closeQuietly()
+ startupAction = null
+ }
+ }
+ }
+ )
+
+ doFirst {
+ val files = extension.server.files
+ if (files.size != 1) {
+ throw GradleException(
+ "Expected exactly one Polaris server artifact, but found ${files.size}: $files"
+ )
+ }
+ val jar = files.single()
+ val javaExecutable = javaLauncher.get().executablePath.asFile.absolutePath
+ val systemProperties = extension.systemProperties.get().toMutableMap()
+ val environment = extension.environment.get().toMutableMap()
+ try {
+ startupAction =
+ extension.startupActionClass.orNull?.let {
+ IsolatedPolarisServerStartupAction.start(
+ implementationClass = it,
+ classpath = extension.startupActionClasspath.files,
+ parameters = extension.startupActionParameters.get(),
+ systemProperties = systemProperties,
+ environment = environment,
+ )
+ }
+ val started =
+ RunningPolarisServer.start(
+ javaExecutable = javaExecutable,
+ jar = jar,
+ workingDirectory = extension.workingDirectory.get().asFile,
+ startupTimeout = extension.startupTimeout.get(),
+ stopTimeout = extension.stopTimeout.get(),
+ jvmArguments = extension.jvmArguments.get(),
+ systemProperties = systemProperties,
+ environment = environment,
+ arguments = extension.arguments.get(),
+ logger = logger,
+ )
+ extension.service.get().register(this, started.server)
+ dynamicArguments.arguments = buildList {
+ add("-D${extension.httpPortProperty.get()}=${started.ports.httpPort}")
+ started.ports.managementPort?.let { add("-D${extension.managementPortProperty.get()}=$it") }
+ }
+ } catch (e: Throwable) {
+ startupAction?.closeQuietly()
+ startupAction = null
+ throw e
+ }
+ }
+}
+
+private class PolarisServerArguments : CommandLineArgumentProvider {
+ @get:Internal var arguments: List<String> = emptyList()
+
+ override fun asArguments(): Iterable<String> = arguments
+}
+
+private class DefaultPolarisServerStartupContext(
+ private val parameters: Map<String, String>,
+ private val systemProperties: MutableMap<String, String>,
+ private val environment: MutableMap<String, String>,
+) : PolarisServerStartupContext {
+ override fun getParameters(): Map<String, String> = parameters
+
+ override fun getSystemProperties(): MutableMap<String, String> = systemProperties
+
+ override fun getEnvironment(): MutableMap<String, String> = environment
+}
+
+private class IsolatedPolarisServerStartupAction(
+ private val classLoader: URLClassLoader,
+ private val delegate: PolarisServerStartupAction,
+) : AutoCloseable {
+ override fun close() {
+ withContextClassLoader(classLoader) { delegate.close() }
+ classLoader.close()
+ }
+
+ companion object {
+ fun start(
+ implementationClass: String,
+ classpath: Set<File>,
+ parameters: Map<String, String>,
+ systemProperties: MutableMap<String, String>,
+ environment: MutableMap<String, String>,
+ ): IsolatedPolarisServerStartupAction {
+ val urls = classpath.map { it.toURI().toURL() }.toTypedArray()
+ val classLoader = URLClassLoader(urls, PolarisServerStartupAction::class.java.classLoader)
+ val action =
+ try {
+ withContextClassLoader(classLoader) {
+ val type = Class.forName(implementationClass, true, classLoader)
+ type.getDeclaredConstructor().newInstance() as PolarisServerStartupAction
+ }
+ } catch (e: Throwable) {
+ classLoader.close()
+ throw GradleException(
+ "Failed to load Polaris server startup action $implementationClass",
+ e,
+ )
+ }
+ val isolated = IsolatedPolarisServerStartupAction(classLoader, action)
+ try {
+ withContextClassLoader(classLoader) {
+ action.start(
+ DefaultPolarisServerStartupContext(parameters, systemProperties, environment)
+ )
+ }
+ } catch (e: Throwable) {
+ isolated.closeQuietly()
+ throw GradleException("Failed to run Polaris server startup action $implementationClass", e)
+ }
+ return isolated
+ }
+ }
+}
+
+private fun AutoCloseable.closeQuietly() {
+ try {
+ close()
+ } catch (_: Exception) {}
+}
+
+private fun <T> withContextClassLoader(classLoader: ClassLoader, action: () -> T): T {
+ val thread = Thread.currentThread()
+ val previous = thread.contextClassLoader
+ thread.contextClassLoader = classLoader
+ try {
+ return action()
+ } finally {
+ thread.contextClassLoader = previous
+ }
+}
diff --git a/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/RunningPolarisServer.kt b/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/RunningPolarisServer.kt
new file mode 100644
index 0000000000..5dae2f13f4
--- /dev/null
+++ b/gradle/server-test-runner/src/main/kotlin/org/apache/polaris/server/test/runner/RunningPolarisServer.kt
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.server.test.runner
+
+import java.io.BufferedReader
+import java.io.File
+import java.io.InputStreamReader
+import java.net.URI
+import java.time.Duration
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+import org.gradle.api.GradleException
+import org.gradle.api.logging.Logger
+
+internal class RunningPolarisServer(
+ private val process: Process,
+ private val outputThread: Thread,
+ private val stopTimeout: Duration,
+) {
+ fun stop(logger: Logger) {
+ if (!process.isAlive) {
+ joinOutputThread(logger)
+ return
+ }
+
+ process.destroy()
+ if (!process.waitFor(stopTimeout.toMillis(), TimeUnit.MILLISECONDS)) {
+ process.destroyForcibly()
+ process.waitFor(stopTimeout.toMillis(), TimeUnit.MILLISECONDS)
+ }
+ joinOutputThread(logger)
+ }
+
+ private fun joinOutputThread(logger: Logger) {
+ try {
+ outputThread.join(stopTimeout.toMillis())
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ logger.warn("Interrupted while waiting for Polaris server output thread to finish", e)
+ }
+ }
+
+ companion object {
+ private val listenPattern =
+ Regex(
+ "^.*Listening on: (https?://\\S+?)(?:[.] Management interface listening on (https?://\\S+?)[.])?\\s*$"
+ )
+
+ fun start(
+ javaExecutable: String,
+ jar: File,
+ workingDirectory: File,
+ startupTimeout: Duration,
+ stopTimeout: Duration,
+ jvmArguments: List<String>,
+ systemProperties: Map<String, String>,
+ environment: Map<String, String>,
+ arguments: List<String>,
+ logger: Logger,
+ ): StartedServer {
+ workingDirectory.mkdirs()
+ val command = buildList {
+ add(javaExecutable)
+ addAll(jvmArguments)
+ add("-Dquarkus.http.port=0")
+ add("-Dquarkus.management.port=0")
+ systemProperties.forEach { (name, value) -> add("-D$name=$value") }
+ add("-jar")
+ add(jar.absolutePath)
+ addAll(arguments)
+ }
+
+ logger.info("Starting Polaris server process: {}", command)
+ val processBuilder =
+ ProcessBuilder(command).directory(workingDirectory).redirectErrorStream(true)
+ processBuilder.environment().putAll(environment)
+ val process = processBuilder.start()
+ val detected = AtomicReference<ListenUrls>()
+ val failed = AtomicReference<Throwable>()
+ val ready = CountDownLatch(1)
+ val readySignalled = AtomicBoolean()
+ val capturedOutput = mutableListOf<String>()
+ val signalReady = { if (readySignalled.compareAndSet(false, true)) ready.countDown() }
+
+ val outputThread =
+ Thread(
+ {
+ try {
+ BufferedReader(InputStreamReader(process.inputStream)).useLines { lines ->
+ lines.forEach { line ->
+ logger.info("[polaris-server] {}", line)
+ synchronized(capturedOutput) {
+ if (capturedOutput.size < 200) {
+ capturedOutput.add(line)
+ }
+ }
+ if (detected.get() == null) {
+ val match = listenPattern.matchEntire(line)
+ if (match != null) {
+ val listenUrls =
+ ListenUrls(
+ match.groupValues[1],
+ match.groupValues.getOrNull(2).orEmpty().ifEmpty { null },
+ )
+ logger.info("Captured listen-URLs line from Quarkus: {}", listenUrls)
+ detected.set(listenUrls)
+ signalReady()
+ }
+ }
+ }
+ }
+ } catch (e: Throwable) {
+ failed.set(e)
+ } finally {
+ signalReady()
+ }
+ },
+ "polaris-server-output",
+ )
+ outputThread.isDaemon = true
+ outputThread.start()
+
+ if (!ready.await(startupTimeout.toMillis(), TimeUnit.MILLISECONDS)) {
+ process.destroyForcibly()
+ throw GradleException(
+ "Polaris server did not emit a listen URL within $startupTimeout. Captured output:\n${capturedOutput(capturedOutput)}"
+ )
+ }
+
+ failed.get()?.let { throw GradleException("Failed while reading Polaris server output", it) }
+ val urls = detected.get()
+ if (urls == null) {
+ val exit =
+ if (process.isAlive) "still running" else "exited with code ${process.exitValue()}"
+ process.destroyForcibly()
+ throw GradleException(
+ "Polaris server $exit before emitting a listen URL. Captured output:\n${capturedOutput(capturedOutput)}"
+ )
+ }
+
+ val server = RunningPolarisServer(process, outputThread, stopTimeout)
+ val ports =
+ try {
+ urls.toPorts()
+ } catch (e: Throwable) {
+ server.stop(logger)
+ throw e
+ }
+ return StartedServer(server, ports)
+ }
+
+ private fun capturedOutput(lines: MutableList<String>): String =
+ synchronized(lines) { lines.takeLast(200).joinToString("\n") }.ifBlank { "<no output>" }
+ }
+}
+
+internal data class StartedServer(val server: RunningPolarisServer, val ports: ServerPorts)
+
+private data class ListenUrls(val httpUrl: String, val managementUrl: String?) {
+ fun toPorts(): ServerPorts =
+ ServerPorts(
+ httpPort = port(httpUrl, "HTTP"),
+ managementPort = managementUrl?.let { port(it, "management") },
+ )
+
+ private fun port(url: String, name: String): Int {
+ val port = URI.create(url).port
+ if (port < 0) {
+ throw GradleException("Polaris server $name listen URL '$url' does not include a port")
+ }
+ return port
+ }
+}
+
+internal data class ServerPorts(val httpPort: Int, val managementPort: Int?)
diff --git a/gradle/server-test-runner/src/test/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestRunnerPluginTest.kt b/gradle/server-test-runner/src/test/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestRunnerPluginTest.kt
new file mode 100644
index 0000000000..627a02e3d7
--- /dev/null
+++ b/gradle/server-test-runner/src/test/kotlin/org/apache/polaris/server/test/runner/PolarisServerTestRunnerPluginTest.kt
@@ -0,0 +1,295 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.server.test.runner
+
+import java.nio.file.Path
+import kotlin.io.path.createDirectories
+import kotlin.io.path.writeText
+import org.assertj.core.api.Assertions.assertThat
+import org.gradle.testkit.runner.GradleRunner
+import org.gradle.testkit.runner.TaskOutcome
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+class PolarisServerTestRunnerPluginTest {
+ @TempDir lateinit var projectDir: Path
+
+ @Test
+ fun `starts server for test task and passes discovered ports`() {
+ writeSettings()
+ writeBuild(
+ """
+ import org.gradle.api.tasks.compile.JavaCompile
+ import org.gradle.jvm.tasks.Jar
+
+ plugins {
+ java
+ id("polaris-server-test-runner")
+ }
+
+ repositories { mavenCentral() }
+
+ dependencies {
+ testImplementation(platform("org.junit:junit-bom:6.1.0"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ }
+
+ val serverRuntime by configurations.creating
+
+ dependencies {
+ serverRuntime(files(tasks.named("jar")))
+ }
+
+ val startupActionSourceDir = layout.buildDirectory.dir("startup-action-src")
+ val startupActionClassesDir = layout.buildDirectory.dir("startup-action-classes")
+
+ val writeStartupAction by tasks.registering {
+ val sourceFile = startupActionSourceDir.map { it.file("test/StartupAction.java") }
+ outputs.file(sourceFile)
+ doLast {
+ sourceFile.get().asFile.apply {
+ parentFile.mkdirs()
+ writeText(
+ ${"\"\"\""}
+ package test;
+
+ import org.apache.polaris.server.test.runner.spi.PolarisServerStartupAction;
+ import org.apache.polaris.server.test.runner.spi.PolarisServerStartupContext;
+
+ public class StartupAction implements PolarisServerStartupAction {
+ @Override
+ public void start(PolarisServerStartupContext context) {
+ context.getSystemProperties().put(
+ "startup.value", context.getParameters().get("value"));
+ }
+ }
+ ${"\"\"\""}.trimIndent()
+ )
+ }
+ }
+ }
+
+ val compileStartupAction by tasks.registering(JavaCompile::class) {
+ dependsOn(writeStartupAction)
+ source(startupActionSourceDir)
+ destinationDirectory.set(startupActionClassesDir)
+ classpath =
+ files(
+ org.apache.polaris.server.test.runner.spi.PolarisServerStartupAction::class.java
+ .protectionDomain
+ .codeSource
+ .location
+ )
+ }
+
+ val startupActionJar by tasks.registering(Jar::class) {
+ dependsOn(compileStartupAction)
+ from(startupActionClassesDir)
+ archiveBaseName.set("startup-action")
+ }
+
+ tasks.named<Jar>("jar") {
+ manifest { attributes("Main-Class" to "test.FakeServer") }
+ }
+
+ tasks.test {
+ useJUnitPlatform()
+ withPolarisServer(configurations.named("serverRuntime")) {
+ arguments.add(layout.buildDirectory.file("server-stopped.txt").get().asFile.absolutePath)
+ arguments.add(layout.buildDirectory.file("server-started.txt").get().asFile.absolutePath)
+ startupActionClasspath.from(startupActionJar)
+ startupActionClass.set("test.StartupAction")
+ startupActionParameters.put("value", "started")
+ }
+ }
+ """
+ .trimIndent()
+ )
+ writeFakeServer()
+ writePropertyTest()
+
+ val result = gradleRunner("test").build()
+
+ assertThat(result.task(":test")?.outcome).isEqualTo(TaskOutcome.SUCCESS)
+ assertThat(projectDir.resolve("build/server-stopped.txt")).exists()
+ assertThat(projectDir.resolve("build/server-started.txt")).hasContent("started")
+ }
+
+ @Test
+ fun `fails when server artifact is missing`() {
+ writeSettings()
+ writeBuild(
+ """
+ plugins {
+ java
+ id("polaris-server-test-runner")
+ }
+
+ repositories { mavenCentral() }
+
+ dependencies {
+ testImplementation(platform("org.junit:junit-bom:6.1.0"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ }
+
+ tasks.test {
+ useJUnitPlatform()
+ withPolarisServer(files())
+ }
+ """
+ .trimIndent()
+ )
+ writePropertyTest()
+
+ val result = gradleRunner("test").buildAndFail()
+
+ assertThat(result.output).contains("Expected exactly one Polaris server artifact")
+ }
+
+ @Test
+ fun `fails when server listen URL does not include port`() {
+ writeSettings()
+ writeBuild(
+ """
+ import org.gradle.jvm.tasks.Jar
+
+ plugins {
+ java
+ id("polaris-server-test-runner")
+ }
+
+ repositories { mavenCentral() }
+
+ dependencies {
+ testImplementation(platform("org.junit:junit-bom:6.1.0"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ }
+
+ val serverRuntime by configurations.creating
+
+ dependencies {
+ serverRuntime(files(tasks.named("jar")))
+ }
+
+ tasks.named<Jar>("jar") {
+ manifest { attributes("Main-Class" to "test.FakeServer") }
+ }
+
+ tasks.test {
+ useJUnitPlatform()
+ withPolarisServer(configurations.named("serverRuntime")) {
+ arguments.add(layout.buildDirectory.file("server-stopped.txt").get().asFile.absolutePath)
+ }
+ }
+ """
+ .trimIndent()
+ )
+ writeFakeServer("Listening on: http://0.0.0.0")
+ writePropertyTest()
+
+ val result = gradleRunner("test").buildAndFail()
+
+ assertThat(result.output)
+ .contains("Polaris server HTTP listen URL 'http://0.0.0.0' does not include a port")
+ assertThat(projectDir.resolve("build/server-stopped.txt")).hasContent("stopped")
+ }
+
+ private fun writeSettings() {
+ projectDir.resolve("settings.gradle.kts").writeText("rootProject.name = \"fixture\"\n")
+ }
+
+ private fun writeBuild(content: String) {
+ projectDir.resolve("build.gradle.kts").writeText(content)
+ }
+
+ private fun writeFakeServer(
+ listenLine: String =
+ "Listening on: http://0.0.0.0:12345. Management interface listening on http://0.0.0.0:12346."
+ ) {
+ val sourceDir = projectDir.resolve("src/main/java/test").createDirectories()
+ sourceDir
+ .resolve("FakeServer.java")
+ .writeText(
+ """
+ package test;
+
+ import java.nio.file.Files;
+ import java.nio.file.Path;
+ import java.util.concurrent.CountDownLatch;
+
+ public final class FakeServer {
+ public static void main(String[] args) throws Exception {
+ Path stopped = Path.of(args[0]);
+ if (args.length > 1) {
+ String startupValue = System.getProperty("startup.value");
+ if (!"started".equals(startupValue)) {
+ throw new IllegalStateException("Unexpected startup value: " + startupValue);
+ }
+ Files.writeString(Path.of(args[1]), startupValue);
+ }
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ try {
+ Files.writeString(stopped, "stopped");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }));
+ System.out.println("${listenLine}");
+ new CountDownLatch(1).await();
+ }
+ }
+ """
+ .trimIndent()
+ )
+ }
+
+ private fun writePropertyTest() {
+ val testDir = projectDir.resolve("src/test/java/test").createDirectories()
+ testDir
+ .resolve("ServerPropertiesTest.java")
+ .writeText(
+ """
+ package test;
+
+ import static org.junit.jupiter.api.Assertions.assertEquals;
+
+ import org.junit.jupiter.api.Test;
+
+ public class ServerPropertiesTest {
+ @Test
+ void receivesServerProperties() {
+ assertEquals("12345", System.getProperty("quarkus.http.test-port"));
+ assertEquals("12346", System.getProperty("quarkus.management.test-port"));
+ }
+ }
+ """
+ .trimIndent()
+ )
+ }
+
+ private fun gradleRunner(vararg arguments: String): GradleRunner =
+ GradleRunner.create()
+ .withProjectDir(projectDir.toFile())
+ .withPluginClasspath()
+ .withArguments(*arguments, "--stacktrace")
+ .forwardOutput()
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 62d4aa17f3..36f45a0bbc 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -40,6 +40,8 @@ if (isCI) {
includeBuild("build-logic") { name = "polaris-build-logic" }
+includeBuild("gradle/server-test-runner") { name = "polaris-server-test-runner" }
+
if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_21)) {
throw GradleException(
"""