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(
     """