Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
169bf6a
feat(gradle): split plugin into api/core modules and add config.json …
monikon22 Aug 15, 2026
7694c60
refactor(runner): replace module singletons with Session, add Environ…
monikon22 Aug 15, 2026
1371293
feat(gradle): add mode registry, move LocalMode to plugwright-local
monikon22 Aug 15, 2026
525d921
feat(matrix): add PlugwrightMatrixTask, JSON+JUnit reports, requires/…
monikon22 Aug 15, 2026
9141210
feat(runner): add plugin host with hooks, fixtures, matchers, inherit…
monikon22 Aug 15, 2026
cdac6b5
feat(gradle): thread plugin configs and cleanup journal through confi…
monikon22 Aug 15, 2026
a574308
feat(external): add ExternalMode with account pool, console channels,…
monikon22 Aug 15, 2026
4af068f
feat(bundle): split published plugin id into its own module registeri…
monikon22 Aug 15, 2026
b8675bf
feat(runner): add account pool, external environment, admin-bot conso…
monikon22 Aug 15, 2026
2aaf4c1
fix(gradle): give mode-registered tasks the shared Node setup
monikon22 Aug 15, 2026
410fdfe
fix(runner): keep secrets lazy and report ping/cleanup exit codes
monikon22 Aug 15, 2026
2db8b3c
fix(session): throttle bot error logging instead of logging every pac…
monikon22 Aug 16, 2026
da65879
feat(gradle): keep the IntelliJ sync trigger through the module split
monikon22 Aug 20, 2026
86fd6fb
fix(gradle): wire the IDEA sync trigger when idea applies late
monikon22 Aug 22, 2026
5bdcc6d
build(gradle): load kotlin-dsl once for all subprojects
monikon22 Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 19 additions & 44 deletions gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,56 +1,31 @@
// Loaded once here so every subproject resolves the same Kotlin plugin classes: applying
// `kotlin-dsl` from each subproject's own plugins block loads the Kotlin plugin several
// times over, which Gradle warns about and does not support.
plugins {
`kotlin-dsl`
`maven-publish`
id("com.gradle.plugin-publish") version "1.2.1"
`kotlin-dsl` apply false
}

group = "io.github.drownek"
val projectVersion = file("../version.txt").readText().trim()
version = projectVersion

repositories {
mavenCentral()
gradlePluginPortal()
}

dependencies {
implementation(gradleApi())
implementation("com.google.code.gson:gson:2.10.1")
implementation("org.yaml:snakeyaml:2.0")
implementation("org.jetbrains.gradle.plugin.idea-ext:org.jetbrains.gradle.plugin.idea-ext.gradle.plugin:1.4.1")
}
allprojects {
group = "io.github.drownek"
version = projectVersion

gradlePlugin {
website.set("https://github.com/drownek/plugwright")
vcsUrl.set("https://github.com/drownek/plugwright.git")
plugins {
create("plugwright") {
id = "io.github.drownek.plugwright"
displayName = "Plugwright Testing Plugin"
description = "End-to-end testing framework for Paper/Spigot Minecraft plugins"
tags.set(listOf("minecraft", "paper", "spigot", "testing", "e2e"))
implementationClass = "me.drownek.plugwright.PlugwrightPlugin"
}
repositories {
mavenCentral()
// The idea-ext plugin marker plugwright-core compiles against lives here, not in Central.
gradlePluginPortal()
}
}

java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
subprojects {
apply(plugin = "org.gradle.kotlin.kotlin-dsl")

val generateVersionResource = tasks.register("generateVersionResource") {
val outFile = layout.buildDirectory.file("generated/version-resource/plugwright-version.properties")
inputs.property("version", projectVersion)
outputs.file(outFile)
doLast {
val f = outFile.get().asFile
f.parentFile.mkdirs()
f.writeText("version=$projectVersion\n")
plugins.withId("java") {
extensions.configure<JavaPluginExtension> {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
}
}

sourceSets.named("main") {
resources.srcDir(generateVersionResource.map { it.outputs.files.singleFile.parentFile })
}
3 changes: 3 additions & 0 deletions gradle-plugin/plugwright-api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dependencies {
implementation(gradleApi())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package me.drownek.plugwright.api

import java.io.Serializable

/**
* A JSON-shaped value in the runner configuration.
*
* Modes build these instead of writing JSON directly: it keeps the api module free of a
* JSON library, and it lets core render [Secret] entries as references rather than values.
*/
sealed class ConfigValue : Serializable {
data class Str(val value: String) : ConfigValue()
data class Num(val value: Number) : ConfigValue()
data class Bool(val value: Boolean) : ConfigValue()
data class Secret(val ref: SecretRef) : ConfigValue()
data class Arr(val values: List<ConfigValue>) : ConfigValue()
data class Obj(val entries: Map<String, ConfigValue>) : ConfigValue()
object Null : ConfigValue() {
private fun readResolve(): Any = Null
}

companion object {
private const val serialVersionUID: Long = 1L
}
}

/** The object a mode serializes its spec into. */
typealias ConfigNode = ConfigValue.Obj

/**
* Builder handed to [PlugwrightMode.serialize].
*
* Keys are written in insertion order so a regenerated config file stays diff-friendly.
*/
class ConfigNodeBuilder {
private val entries = LinkedHashMap<String, ConfigValue>()

fun put(key: String, value: String) = apply { entries[key] = ConfigValue.Str(value) }
fun put(key: String, value: Number) = apply { entries[key] = ConfigValue.Num(value) }
fun put(key: String, value: Boolean) = apply { entries[key] = ConfigValue.Bool(value) }
fun put(key: String, value: SecretRef) = apply { entries[key] = ConfigValue.Secret(value) }
fun put(key: String, value: ConfigValue) = apply { entries[key] = value }
fun putNull(key: String) = apply { entries[key] = ConfigValue.Null }

/** Omits the key entirely when [value] is null — absent and null mean different things downstream. */
fun putIfPresent(key: String, value: String?) = apply { if (value != null) put(key, value) }

fun putStrings(key: String, values: Iterable<String>) = apply {
entries[key] = ConfigValue.Arr(values.map { ConfigValue.Str(it) })
}

fun obj(key: String, action: ConfigNodeBuilder.() -> Unit) = apply {
entries[key] = ConfigNodeBuilder().apply(action).build()
}

fun array(key: String, action: ConfigArrayBuilder.() -> Unit) = apply {
entries[key] = ConfigValue.Arr(ConfigArrayBuilder().apply(action).build())
}

fun build(): ConfigNode = ConfigValue.Obj(LinkedHashMap(entries))
}

class ConfigArrayBuilder {
private val values = mutableListOf<ConfigValue>()

fun add(value: String) = apply { values.add(ConfigValue.Str(value)) }
fun add(value: Number) = apply { values.add(ConfigValue.Num(value)) }
fun add(value: Boolean) = apply { values.add(ConfigValue.Bool(value)) }
fun add(value: ConfigValue) = apply { values.add(value) }

fun obj(action: ConfigNodeBuilder.() -> Unit) = apply {
values.add(ConfigNodeBuilder().apply(action).build())
}

fun build(): List<ConfigValue> = values.toList()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package me.drownek.plugwright.api

import org.gradle.api.Named
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property

/**
* Build-script description of one environment tests can run against.
*
* A mode subtypes this with its own fields (`host`, `runDir`, …); everything declared
* here is owned by plugwright itself and behaves the same for every mode.
*/
interface EnvironmentSpec : Named {

/** Name used in task names and report files: `local` becomes `plugwrightTestLocal`. */
override fun getName(): String

/**
* Whether `plugwrightTest` includes this environment. Ignored when the per-environment
* task is invoked directly — an explicit request always runs.
*/
val includeInMatrix: Property<Boolean>

/**
* Whether failures here fail the build when running the matrix. Failures are still
* reported as failures. Ignored when the per-environment task is invoked directly.
*/
val allowFailure: Property<Boolean>

/** Test name substrings to skip in this environment. */
val excludeTests: ListProperty<String>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package me.drownek.plugwright.api

import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property

/**
* The pre-3.0 flat properties on the `plugwright { }` extension, kept so a build with no
* `environments { }` block keeps working.
*
* A mode reads these in [PlugwrightMode.applyLegacyDefaults] to seed the environment it is
* asked to create implicitly. Modes with no legacy shape simply ignore this.
*/
interface LegacyEnvironmentProperties {
val minecraftVersion: Property<String>
val jvmArgs: ListProperty<String>
val acceptEula: Property<Boolean>
val runDir: DirectoryProperty
val pluginUrls: ListProperty<String>
val runDirFiles: ListProperty<RunDirFile>
val cleanExcludePatterns: ListProperty<String>
val useExternalPluginsOnly: Property<Boolean>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package me.drownek.plugwright.api

import java.io.Serializable

/**
* One runner plugin to load: an npm package name or a resolvable local file path, plus its
* options and whether its declared `tests` are inherited into the run.
*
* Lands in the top-level `plugins` array of the runner config — a sibling of `environment`,
* not part of `environment.config` — via [TaskRegistrationContext.pluginConfigs].
*/
data class PluginRef @JvmOverloads constructor(
val specifier: String,
val options: Map<String, String> = emptyMap(),
val inheritTests: Boolean = true
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package me.drownek.plugwright.api

/**
* Version of the contract in this module.
*
* A mode declares the version it was compiled against via [PlugwrightMode.apiVersion].
* Plugwright refuses to load a mode whose version it does not understand instead of
* failing later with a [NoSuchMethodError] from a mismatched classpath.
*/
object PlugwrightApi {
const val VERSION: Int = 1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package me.drownek.plugwright.api

import org.gradle.api.model.ObjectFactory

/**
* How one kind of environment is declared in the build script and prepared for a test run.
*
* Implementations are stateless singletons: everything configurable lives in the spec, and
* everything executed lives in the tasks registered by [registerTasks].
*/
interface PlugwrightMode<S : EnvironmentSpec> {

/** Stable id, written into the runner config: `local`, `external`, `velocity`. */
val id: String

/** Spec type this mode creates; also the key the environment container registers a factory under. */
val specType: Class<S>

/** Contract version this mode was compiled against. See [PlugwrightApi.VERSION]. */
val apiVersion: Int get() = PlugwrightApi.VERSION

/** Creates an empty spec. Use [ObjectFactory.newInstance] so Gradle manages the properties. */
fun createSpec(name: String, objects: ObjectFactory): S

/** npm packages the runner needs for this configuration. */
fun runnerPackages(spec: S): List<RunnerPackageRef> = emptyList()

/** Configuration-time checks. Report problems through [ValidationContext], do not throw. */
fun validate(spec: S, ctx: ValidationContext) {}

/**
* Seeds [spec] from the deprecated flat extension properties, for a build with no
* `environments { }` block. No-op for modes with no legacy shape to migrate from.
*/
fun applyLegacyDefaults(spec: S, legacy: LegacyEnvironmentProperties) {}

/**
* Writes the mode-specific part of the runner config, landing under
* `environment.config`. Runs at configuration time, so secrets stay [SecretRef]s.
*/
fun serialize(spec: S, node: ConfigNodeBuilder)

/** Registers the tasks for this environment: provisioning, cleanup, mode-specific extras. */
fun registerTasks(spec: S, ctx: TaskRegistrationContext) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package me.drownek.plugwright.api

import java.io.File
import java.io.Serializable

/**
* One file to write into an environment's run directory before the server starts.
* Exactly one of [content] or [sourceFile] is non-null.
*/
data class RunDirFile(
val path: String,
val content: String?,
val sourceFile: File?
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package me.drownek.plugwright.api

import java.io.Serializable

/**
* An npm package the runner needs for a given environment, plus the export that
* provides its [Environment factory][PlugwrightMode].
*
* The set of packages depends on the configuration, not only on the mode: an external
* environment pulls the RCON console package only when the build script declares one.
*
* @param name npm package name, e.g. `@drownek/plugwright`
* @param version npm version range; null means "whatever the test project already has"
* @param export named export of the package holding the factory; null means the default export
*/
data class RunnerPackageRef @JvmOverloads constructor(
val name: String,
val version: String? = null,
val export: String? = null
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package me.drownek.plugwright.api

import org.gradle.api.Project
import java.io.File
import java.io.Serializable

/**
* A pointer to a secret value, never the value itself.
*
* Secrets are resolved by the runner at execution time. Resolving them during the
* configuration phase would put passwords into the configuration cache and into
* build artifacts.
*/
sealed class SecretRef : Serializable {

/** Read the secret from the environment variable [name]. */
data class FromEnv(val name: String) : SecretRef()

/** Read the secret from the first line of [path]. */
data class FromFile(val path: String) : SecretRef() {
constructor(file: File) : this(file.absolutePath)
}

/** Read the secret from the system property [name]. */
data class FromSystemProperty(val name: String) : SecretRef()

companion object {
private const val serialVersionUID: Long = 1L
}
}

/**
* Factory for [SecretRef] values, exposed to build scripts as `secret`.
*/
object Secrets {
fun env(name: String): SecretRef = SecretRef.FromEnv(name)
fun file(path: String): SecretRef = SecretRef.FromFile(path)
fun file(file: File): SecretRef = SecretRef.FromFile(file)
fun systemProperty(name: String): SecretRef = SecretRef.FromSystemProperty(name)
}

/** `secret.env("X")` / `secret.file(path)` in a build script, anywhere the implicit `Project`
* receiver is reachable — including nested `environments { create(...) { ... } }` blocks. */
val Project.secret: Secrets get() = Secrets
Loading