From 169bf6a5866b34df35e3702daf50ed120c37f8f2 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 21:06:11 +0300 Subject: [PATCH 01/15] feat(gradle): split plugin into api/core modules and add config.json transport The build script and the runner talked through five flat env vars, which leaves no room for a second environment. Phase 1 of the multi-mode work rearranges the plumbing without changing behaviour: - gradle-plugin becomes a multi-project build: plugwright-api holds the contract third-party modes compile against (PlugwrightMode, EnvironmentSpec, SecretRef, ConfigNode, RunnerPackageRef, TaskRegistrationContext), plugwright-core holds the plugin. api has no coordinates of its own yet, so its classes are merged into the core jar; the published artifactId changes to plugwright-core, the plugin id and its marker do not. - plugwrightTest writes build/tmp/plugwright/local.json and passes it as --config. The runner resolves config in the order --config file, plugwright.config.json, then the old env vars, so an older plugin still drives a newer runner. Host, port, jvm args and the tests dir come from the file instead of being hardcoded in runner.ts. - npm install and tsc move out of the test task into plugwrightCompileTests, so several environments can share one install. - Process and Node.js plumbing moves to AbstractNodeTask, shared by the test, run-server and compile-tests tasks. Secrets travel as references ({"from":"env","name":...}) and are read by the runner, never resolved at configuration time. --- gradle-plugin/build.gradle.kts | 60 +---- gradle-plugin/plugwright-api/build.gradle.kts | 7 + .../me/drownek/plugwright/api/ConfigNode.kt | 76 +++++++ .../drownek/plugwright/api/EnvironmentSpec.kt | 32 +++ .../drownek/plugwright/api/PlugwrightApi.kt | 12 + .../drownek/plugwright/api/PlugwrightMode.kt | 39 ++++ .../plugwright/api/RunnerPackageRef.kt | 24 ++ .../me/drownek/plugwright/api/SecretRef.kt | 39 ++++ .../plugwright/api/TaskRegistrationContext.kt | 50 ++++ .../plugwright/api/ValidationContext.kt | 19 ++ .../plugwright-core/build.gradle.kts | 55 +++++ .../me/drownek/plugwright/AbstractNodeTask.kt | 162 +++++++++++++ .../plugwright/AbstractPlugwrightTask.kt | 127 +---------- .../kotlin/me/drownek/plugwright/Banner.kt | 0 .../me/drownek/plugwright/NodeManager.kt | 0 .../plugwright/PlugwrightCompileTestsTask.kt | 61 +++++ .../drownek/plugwright/PlugwrightExtension.kt | 0 .../me/drownek/plugwright/PlugwrightPlugin.kt | 213 +++++++++-------- .../drownek/plugwright/PlugwrightRunTask.kt | 0 .../drownek/plugwright/PlugwrightTestTask.kt | 102 +++++++-- .../drownek/plugwright/RunnerConfigWriter.kt | 63 ++++++ gradle-plugin/settings.gradle.kts | 7 +- runner-package/lib/config.ts | 214 ++++++++++++++++++ runner-package/runner.ts | 60 +++-- scripts/bump-version.js | 4 +- 25 files changed, 1093 insertions(+), 333 deletions(-) create mode 100644 gradle-plugin/plugwright-api/build.gradle.kts create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ConfigNode.kt create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/EnvironmentSpec.kt create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightApi.kt create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunnerPackageRef.kt create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ValidationContext.kt create mode 100644 gradle-plugin/plugwright-core/build.gradle.kts create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt rename gradle-plugin/{ => plugwright-core}/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt (76%) rename gradle-plugin/{ => plugwright-core}/src/main/kotlin/me/drownek/plugwright/Banner.kt (100%) rename gradle-plugin/{ => plugwright-core}/src/main/kotlin/me/drownek/plugwright/NodeManager.kt (100%) create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt rename gradle-plugin/{ => plugwright-core}/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt (100%) rename gradle-plugin/{ => plugwright-core}/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt (67%) rename gradle-plugin/{ => plugwright-core}/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt (100%) rename gradle-plugin/{ => plugwright-core}/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt (52%) create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerConfigWriter.kt create mode 100644 runner-package/lib/config.ts diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 31c0ff3..ac35b44 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -1,56 +1,20 @@ -plugins { - `kotlin-dsl` - `maven-publish` - id("com.gradle.plugin-publish") version "1.2.1" -} - -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") -} -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" - } - } -} +allprojects { + group = "io.github.drownek" + version = projectVersion -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + repositories { + mavenCentral() } } -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") +subprojects { + plugins.withId("java") { + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } } } - -sourceSets.named("main") { - resources.srcDir(generateVersionResource.map { it.outputs.files.singleFile.parentFile }) -} diff --git a/gradle-plugin/plugwright-api/build.gradle.kts b/gradle-plugin/plugwright-api/build.gradle.kts new file mode 100644 index 0000000..1dd0b3b --- /dev/null +++ b/gradle-plugin/plugwright-api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +dependencies { + implementation(gradleApi()) +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ConfigNode.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ConfigNode.kt new file mode 100644 index 0000000..7dba16d --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ConfigNode.kt @@ -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() + data class Obj(val entries: Map) : 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() + + 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) = 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() + + 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 = values.toList() +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/EnvironmentSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/EnvironmentSpec.kt new file mode 100644 index 0000000..ee63eb3 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/EnvironmentSpec.kt @@ -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 + + /** + * 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 + + /** Test name substrings to skip in this environment. */ + val excludeTests: ListProperty +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightApi.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightApi.kt new file mode 100644 index 0000000..7acdb9c --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightApi.kt @@ -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 +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt new file mode 100644 index 0000000..52bc915 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt @@ -0,0 +1,39 @@ +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 { + + /** 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 + + /** 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 = emptyList() + + /** Configuration-time checks. Report problems through [ValidationContext], do not throw. */ + fun validate(spec: S, ctx: ValidationContext) {} + + /** + * 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) {} +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunnerPackageRef.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunnerPackageRef.kt new file mode 100644 index 0000000..a3688cb --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunnerPackageRef.kt @@ -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 + } +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt new file mode 100644 index 0000000..5f4e378 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt @@ -0,0 +1,39 @@ +package me.drownek.plugwright.api + +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) +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt new file mode 100644 index 0000000..4f0347d --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt @@ -0,0 +1,50 @@ +package me.drownek.plugwright.api + +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import java.io.File +import kotlin.reflect.KClass + +/** + * Handed to [PlugwrightMode.registerTasks] so a mode can add its own tasks for one environment. + * + * Preparation work belongs in a task, not in a callback executed inside someone else's + * `@TaskAction`: a task keeps the configuration cache intact, gets up-to-date checks, and + * can be invoked by hand. + */ +interface TaskRegistrationContext { + + val project: Project + + /** Name of the environment these tasks belong to. */ + val environmentName: String + + /** + * The jar of the plugin under test, from `shadowJar` / `reobfJar` / `jar`. + * + * Absent when the build asked for external plugins only, or when no jar-producing + * task exists. Modes that do not install the plugin themselves ignore it. + */ + val projectPluginJar: Provider + + /** + * Registers a task named `plugwright`, e.g. `plugwrightProvisionLocal` + * for `register("Provision", …)` in the `local` environment. + */ + fun register(suffix: String, type: Class, action: T.() -> Unit): TaskProvider + + /** + * Marks a task as the environment's preparation step. `plugwrightTest` and + * the matrix run it before the tests. + */ + fun prepareTask(task: TaskProvider) +} + +/** Kotlin-friendly overload of [TaskRegistrationContext.register]. */ +fun TaskRegistrationContext.register( + suffix: String, + type: KClass, + action: T.() -> Unit +): TaskProvider = register(suffix, type.java, action) diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ValidationContext.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ValidationContext.kt new file mode 100644 index 0000000..b60f62c --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ValidationContext.kt @@ -0,0 +1,19 @@ +package me.drownek.plugwright.api + +/** + * Collects configuration-time problems found by [PlugwrightMode.validate]. + * + * Modes report through this instead of throwing so one build failure can list every + * problem in every environment at once. + */ +interface ValidationContext { + + /** Environment being validated. */ + val environmentName: String + + /** Records a problem that must fail the build. */ + fun error(message: String) + + /** Records a problem worth printing that does not fail the build. */ + fun warn(message: String) +} diff --git a/gradle-plugin/plugwright-core/build.gradle.kts b/gradle-plugin/plugwright-core/build.gradle.kts new file mode 100644 index 0000000..c7c03cc --- /dev/null +++ b/gradle-plugin/plugwright-core/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + `kotlin-dsl` + `maven-publish` + id("com.gradle.plugin-publish") version "1.2.1" +} + +val projectVersion = version.toString() + +dependencies { + implementation(gradleApi()) + implementation("com.google.code.gson:gson:2.10.1") + implementation("org.yaml:snakeyaml:2.0") + + // The api module has no separate published coordinates yet, so its classes are + // merged into this jar below. compileOnly keeps it out of the published POM. + compileOnly(project(":plugwright-api")) +} + +// Until plugwright-api is published on its own, ship it inside the plugin jar so +// both this plugin and third-party mode jars resolve the same contract classes. +val apiJar = project(":plugwright-api").tasks.named("jar", Jar::class) + +tasks.named("jar") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(apiJar.map { zipTree(it.archiveFile) }) +} + +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" + } + } +} + +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") + } +} + +sourceSets.named("main") { + resources.srcDir(generateVersionResource.map { it.outputs.files.singleFile.parentFile }) +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt new file mode 100644 index 0000000..aefa167 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt @@ -0,0 +1,162 @@ +package me.drownek.plugwright + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import java.io.File + +/** + * Base for tasks that shell out to Node.js or to the test server. + * + * Holds the Node.js resolution inputs and the process plumbing; knows nothing about + * server provisioning. + */ +abstract class AbstractNodeTask : DefaultTask() { + + @get:Input + abstract val nodeVersion: Property + + @get:Input + abstract val downloadNode: Property + + @get:Internal + abstract val nodeInstallDir: DirectoryProperty + + protected fun resolveNode(): NodeManager.NodePaths = + NodeManager.getOrDownloadNode(nodeInstallDir.get().asFile, nodeVersion.get(), downloadNode.get()) + + /** Environment that puts the resolved Node.js on PATH for child processes. */ + protected fun nodePathEnv(nodePaths: NodeManager.NodePaths): Map { + val nodeDir = File(nodePaths.node).parent ?: return emptyMap() + val pathKey = System.getenv().keys.firstOrNull { it.equals("PATH", ignoreCase = true) } ?: "PATH" + return mapOf(pathKey to nodeDir + File.pathSeparator + (System.getenv(pathKey) ?: "")) + } + + protected fun runCommand( + dir: File, + vararg command: String, + env: Map = emptyMap(), + interactive: Boolean = false, + onStdoutLine: ((String) -> Unit)? = null + ) { + val isWindows = System.getProperty("os.name").lowercase().contains("win") + val cmdName = File(command[0]).nameWithoutExtension.lowercase() + val cmd = if (isWindows && (cmdName == "npm" || cmdName == "node")) { + listOf("cmd", "/c") + command + } else { + command.toList() + } + + val processBuilder = ProcessBuilder(cmd) + processBuilder.directory(dir) + processBuilder.environment().putAll(env) + + val process = processBuilder.start() + + val shutdownHook = Thread { + if (process.isAlive) killProcessTree(process) + } + Runtime.getRuntime().addShutdownHook(shutdownHook) + try { + runProcess(process, command, interactive, onStdoutLine) + } finally { + try { + Runtime.getRuntime().removeShutdownHook(shutdownHook) + } catch (_: IllegalStateException) {} + } + } + + protected fun runProcess( + process: Process, + command: Array, + interactive: Boolean = false, + onStdoutLine: ((String) -> Unit)? = null + ) { + val stdoutThread = Thread { + process.inputStream.bufferedReader(Charsets.UTF_8).useLines { lines -> + lines.forEach { line -> + logger.lifecycle(line) + onStdoutLine?.invoke(line) + } + } + } + stdoutThread.isDaemon = true + + val stderrThread = Thread { + process.errorStream.bufferedReader(Charsets.UTF_8).useLines { lines -> + lines.forEach { logger.error(it) } + } + } + stderrThread.isDaemon = true + + var stdinThread: Thread? = null + if (interactive) { + stdinThread = Thread { + try { + val reader = System.`in`.bufferedReader(Charsets.UTF_8) + val out = process.outputStream + while (true) { + val line = reader.readLine() ?: break + out.write((line + "\n").toByteArray(Charsets.UTF_8)) + out.flush() + } + } catch (_: Exception) {} + } + stdinThread.isDaemon = true + stdinThread.start() + } + + stdoutThread.start() + stderrThread.start() + + val exitCode = try { + process.waitFor() + } catch (e: InterruptedException) { + logger.lifecycle("[E2E] Build cancelled, gracefully terminating server process tree...") + + killProcessTree(process) + + // Re-interrupt the thread after doing the cleanup + Thread.currentThread().interrupt() + throw RuntimeException("E2E build cancelled; spawned server was terminated.", e) + } + + try { stdoutThread.join(2000) } catch (_: InterruptedException) {} + try { stderrThread.join(2000) } catch (_: InterruptedException) {} + + if (exitCode != 0) { + throw RuntimeException("Command '${command.joinToString(" ")}' failed with exit code: $exitCode") + } + } + + protected fun killProcessTree(process: Process) { + try { + val isJava = process.info().command().orElse("")?.contains("java") ?: false + if (isJava) { + try { + val out = process.outputStream + out.write("stop\n".toByteArray()) + out.flush() + } catch (_: Exception) {} + process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS) + } + + val handle = process.toHandle() + val descendants = handle.descendants().toList() + + // Kill parent first to prevent respawning + handle.destroyForcibly() + process.waitFor(2, java.util.concurrent.TimeUnit.SECONDS) + + // Then kill descendants + descendants.forEach { + try { it.destroyForcibly() } catch (_: Throwable) {} + } + + } catch (_: Throwable) { + // best effort + } + } +} diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt similarity index 76% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt rename to gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt index 4c9ab9a..2a65169 100644 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt @@ -1,8 +1,6 @@ package me.drownek.plugwright import com.google.gson.JsonParser -import org.gradle.api.DefaultTask -import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* @@ -19,7 +17,7 @@ import java.time.Duration import org.yaml.snakeyaml.Yaml import org.yaml.snakeyaml.DumperOptions -abstract class AbstractPlugwrightTask : DefaultTask() { +abstract class AbstractPlugwrightTask : AbstractNodeTask() { @get:Input abstract val serverJarPath: Property @@ -51,15 +49,6 @@ abstract class AbstractPlugwrightTask : DefaultTask() { @get:Optional abstract val runDirFiles: ListProperty - @get:Input - abstract val nodeVersion: Property - - @get:Input - abstract val downloadNode: Property - - @get:Internal - abstract val nodeInstallDir: DirectoryProperty - protected fun prepareServerEnvironment(): File { val serverJar = serverJarPath.get() val serverDirectory = serverDir.get() @@ -367,118 +356,4 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } } - protected fun runCommand(dir: File, vararg command: String, env: Map = emptyMap(), interactive: Boolean = false, onStdoutLine: ((String) -> Unit)? = null) { - val isWindows = System.getProperty("os.name").lowercase().contains("win") - val cmdName = File(command[0]).nameWithoutExtension.lowercase() - val cmd = if (isWindows && (cmdName == "npm" || cmdName == "node")) { - listOf("cmd", "/c") + command - } else { - command.toList() - } - - val processBuilder = ProcessBuilder(cmd) - processBuilder.directory(dir) - processBuilder.environment().putAll(env) - - val process = processBuilder.start() - - val shutdownHook = Thread { - if (process.isAlive) killProcessTree(process) - } - Runtime.getRuntime().addShutdownHook(shutdownHook) - try { - runProcess(process, command, interactive, onStdoutLine) - } finally { - try { - Runtime.getRuntime().removeShutdownHook(shutdownHook) - } catch (_: IllegalStateException) {} - } - } - - protected fun runProcess(process: Process, command: Array, interactive: Boolean = false, onStdoutLine: ((String) -> Unit)? = null) { - val stdoutThread = Thread { - process.inputStream.bufferedReader(Charsets.UTF_8).useLines { lines -> - lines.forEach { line -> - logger.lifecycle(line) - onStdoutLine?.invoke(line) - } - } - } - stdoutThread.isDaemon = true - - val stderrThread = Thread { - process.errorStream.bufferedReader(Charsets.UTF_8).useLines { lines -> - lines.forEach { logger.error(it) } - } - } - stderrThread.isDaemon = true - - var stdinThread: Thread? = null - if (interactive) { - stdinThread = Thread { - try { - val reader = System.`in`.bufferedReader(Charsets.UTF_8) - val out = process.outputStream - while (true) { - val line = reader.readLine() ?: break - out.write((line + "\n").toByteArray(Charsets.UTF_8)) - out.flush() - } - } catch (_: Exception) {} - } - stdinThread.isDaemon = true - stdinThread.start() - } - - stdoutThread.start() - stderrThread.start() - - val exitCode = try { - process.waitFor() - } catch (e: InterruptedException) { - logger.lifecycle("[E2E] Build cancelled, gracefully terminating server process tree...") - - killProcessTree(process) - - // Re-interrupt the thread after doing the cleanup - Thread.currentThread().interrupt() - throw RuntimeException("E2E build cancelled; spawned server was terminated.", e) - } - - try { stdoutThread.join(2000) } catch (_: InterruptedException) {} - try { stderrThread.join(2000) } catch (_: InterruptedException) {} - - if (exitCode != 0) { - throw RuntimeException("Command '${command.joinToString(" ")}' failed with exit code: $exitCode") - } - } - - protected fun killProcessTree(process: Process) { - try { - val isJava = process.info().command().orElse("")?.contains("java") ?: false - if (isJava) { - try { - val out = process.outputStream - out.write("stop\n".toByteArray()) - out.flush() - } catch (_: Exception) {} - process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS) - } - - val handle = process.toHandle() - val descendants = handle.descendants().toList() - - // Kill parent first to prevent respawning - handle.destroyForcibly() - process.waitFor(2, java.util.concurrent.TimeUnit.SECONDS) - - // Then kill descendants - descendants.forEach { - try { it.destroyForcibly() } catch (_: Throwable) {} - } - - } catch (_: Throwable) { - // best effort - } - } } diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/Banner.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/Banner.kt similarity index 100% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/Banner.kt rename to gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/Banner.kt diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/NodeManager.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NodeManager.kt similarity index 100% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/NodeManager.kt rename to gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NodeManager.kt diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt new file mode 100644 index 0000000..c0de6af --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt @@ -0,0 +1,61 @@ +package me.drownek.plugwright + +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction +import java.io.File + +/** + * Installs the test project's npm dependencies and compiles its TypeScript. + * + * Split out of the test task so several environments share one install and one `tsc` + * run instead of paying for them per environment. + */ +abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { + + @get:InputDirectory + @get:Optional + abstract val testsDir: DirectoryProperty + + init { + group = "verification" + description = "Install npm dependencies and compile the E2E tests" + // The compiled output depends on node_modules and on the installed runner package, + // neither of which is a declared input, so never report this as up to date. + outputs.upToDateWhen { false } + } + + @TaskAction + fun compile() { + val userTestsDirectory = if (testsDir.isPresent) { + testsDir.get().asFile + } else { + logger.warn("Tests directory not configured") + return + } + + if (!userTestsDirectory.exists()) { + logger.warn("Tests directory does not exist: ${userTestsDirectory.absolutePath}") + return + } + + val nodePaths = resolveNode() + val npmEnv = nodePathEnv(nodePaths) + + // Install dependencies if needed + if (!File(userTestsDirectory, "node_modules").exists()) { + logger.lifecycle("Installing Node.js dependencies...") + runCommand(userTestsDirectory, nodePaths.npm, "install", env = npmEnv) + } + + // Build TypeScript tests if tsconfig.json exists + val tsconfigFile = File(userTestsDirectory, "tsconfig.json") + if (tsconfigFile.exists()) { + logger.lifecycle("TypeScript config found, compiling tests...") + runCommand(userTestsDirectory, nodePaths.npm, "run", "build", env = npmEnv) + } else { + logger.lifecycle("No TypeScript config found, running JavaScript tests directly") + } + } +} diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt similarity index 100% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt rename to gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt similarity index 67% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt rename to gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt index 6b45a9b..ea43f97 100644 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt @@ -3,12 +3,8 @@ package me.drownek.plugwright import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.plugins.ExtensionAware import org.gradle.api.plugins.JavaPluginExtension import org.gradle.jvm.toolchain.JavaToolchainService -import org.gradle.plugins.ide.idea.model.IdeaModel -import org.jetbrains.gradle.ext.ProjectSettings -import org.jetbrains.gradle.ext.TaskTriggersConfig import java.io.File import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -23,65 +19,11 @@ object BannerState { val printed = AtomicBoolean(false) } -private fun runNpmInstall(project: Project, targetDir: File, nodePaths: NodeManager.NodePaths) { - val isWin = System.getProperty("os.name").lowercase().contains("windows") - val cmd = if (isWin) listOf("cmd", "/c", nodePaths.npm, "install") else listOf(nodePaths.npm, "install") - val nodeDir = File(nodePaths.node).parent - - val execOps = project.objects.newInstance(InjectedExecOps::class.java) - val execResult = execOps.execOperations.exec { - workingDir = targetDir - commandLine = cmd - if (nodeDir != null) { - val pathKey = environment.keys.firstOrNull { it.equals("PATH", ignoreCase = true) } ?: "PATH" - environment[pathKey] = nodeDir + File.pathSeparator + (environment[pathKey] ?: "") - } - isIgnoreExitValue = true - } - - if (execResult.exitValue != 0) { - throw GradleException("EXEC ERROR: 'npm install' failed with exit code ${execResult.exitValue}.") - } - project.logger.lifecycle("Dependencies installed successfully.") -} - -private fun AbstractPlugwrightTask.configureCommon(project: Project, extension: PlugwrightExtension, defaultNodeInstallDir: File) { - doFirst { - if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) - } - - minecraftVersion.set(extension.minecraftVersion) - jvmArgs.set(extension.jvmArgs) - acceptEula.set(extension.acceptEula) - pluginUrls.set(extension.pluginUrls) - runDirFiles.set(extension.runDirFiles) - nodeVersion.set(extension.nodeVersion) - downloadNode.set(extension.downloadNode) - nodeInstallDir.set(defaultNodeInstallDir) - - serverJarPath.set( - extension.runDir.map { runDir -> - val serverJar = runDir.asFile.resolve("server.jar") - serverJar.absolutePath - } - ) - - serverDir.set( - extension.runDir.map { runDir -> - runDir.asFile.absolutePath - } - ) - - // Configure Java Toolchain if Java plugin is present - project.plugins.withId("java") { - val javaExtension = project.extensions.findByType(JavaPluginExtension::class.java) - val javaToolchains = project.extensions.findByType(JavaToolchainService::class.java) - - if (javaExtension != null && javaToolchains != null) { - javaLauncher.set(javaToolchains.launcherFor(javaExtension.toolchain)) - } - } -} +/** + * Name of the implicit environment used while the build script has no `environments { }` + * block: the flat extension properties describe one local server. + */ +const val DEFAULT_ENVIRONMENT_NAME = "local" class PlugwrightPlugin : Plugin { override fun apply(project: Project) { @@ -142,46 +84,38 @@ class PlugwrightPlugin : Plugin { } } - val plugwrightNpmInstall = project.tasks.register("plugwrightNpmInstall") { - group = "verification" - description = "Installs Node.js dependencies for Plugwright tests." - + val plugwrightCompileTests = project.tasks.register("plugwrightCompileTests", PlugwrightCompileTestsTask::class.java) { doFirst { if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) } - - // Define inputs and outputs for up-to-date checks - inputs.file(extension.testsDir.map { it.file("package.json") }).optional() - inputs.file(extension.testsDir.map { it.file("package-lock.json") }).optional() - outputs.dir(extension.testsDir.map { it.dir("node_modules") }) - outputs.upToDateWhen { File(extension.testsDir.get().asFile, "package.json").exists() } - - doLast { - val testsDir = extension.testsDir.get().asFile - if (!testsDir.exists() || !File(testsDir, "package.json").exists()) { - throw GradleException("Cannot run plugwrightNpmInstall: 'package.json' not found in ${testsDir.absolutePath}. Please run 'plugwrightInit' first.") - } - - val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get()) - project.logger.lifecycle("Installing Node.js dependencies in ${testsDir.absolutePath}...") - try { - runNpmInstall(project, testsDir, nodePaths) - } catch (e: Exception) { - if (e is GradleException) throw e - throw GradleException("EXEC FATAL: Failed to launch npm process. Original error: ${e.message}", e) - } - } + testsDir.set(extension.testsDir) + nodeVersion.set(extension.nodeVersion) + downloadNode.set(extension.downloadNode) + nodeInstallDir.set(defaultNodeInstallDir) } project.tasks.register("plugwrightTest", PlugwrightTestTask::class.java) { - // Ensure clean and setup runs before test + // Ensure clean runs before test dependsOn(plugwrightClean) - dependsOn(plugwrightNpmInstall) + // npm install + tsc are shared across environments, so they live in their own task + dependsOn(plugwrightCompileTests) - configureCommon(project, extension, defaultNodeInstallDir) + doFirst { + if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) + } testsDir.set(extension.testsDir) + environmentName.set(DEFAULT_ENVIRONMENT_NAME) + configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$DEFAULT_ENVIRONMENT_NAME.json")) + minecraftVersion.set(extension.minecraftVersion) + jvmArgs.set(extension.jvmArgs) + acceptEula.set(extension.acceptEula) + pluginUrls.set(extension.pluginUrls) + runDirFiles.set(extension.runDirFiles) + nodeVersion.set(extension.nodeVersion) + downloadNode.set(extension.downloadNode) + nodeInstallDir.set(defaultNodeInstallDir) // Support command line properties for filtering if (project.hasProperty("testFiles")) { @@ -191,19 +125,76 @@ class PlugwrightPlugin : Plugin { if (project.hasProperty("testNames")) { testNames.set(project.property("testNames") as String) } + + serverJarPath.set( + extension.runDir.map { runDir -> + val serverJar = runDir.asFile.resolve("server.jar") + serverJar.absolutePath + } + ) + + serverDir.set( + extension.runDir.map { runDir -> + runDir.asFile.absolutePath + } + ) + + // Configure Java Toolchain if Java plugin is present + project.plugins.withId("java") { + val javaExtension = project.extensions.findByType(JavaPluginExtension::class.java) + val javaToolchains = project.extensions.findByType(JavaToolchainService::class.java) + + if (javaExtension != null && javaToolchains != null) { + javaLauncher.set(javaToolchains.launcherFor(javaExtension.toolchain)) + } + } } project.tasks.register("plugwrightRunServer", PlugwrightRunTask::class.java) { // Ensure clean runs before starting the server dependsOn(plugwrightClean) - configureCommon(project, extension, defaultNodeInstallDir) + doFirst { + if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) + } + + minecraftVersion.set(extension.minecraftVersion) + jvmArgs.set(extension.jvmArgs) + acceptEula.set(extension.acceptEula) + pluginUrls.set(extension.pluginUrls) + runDirFiles.set(extension.runDirFiles) + nodeVersion.set(extension.nodeVersion) + downloadNode.set(extension.downloadNode) + nodeInstallDir.set(defaultNodeInstallDir) + + serverJarPath.set( + extension.runDir.map { runDir -> + val serverJar = runDir.asFile.resolve("server.jar") + serverJar.absolutePath + } + ) + + serverDir.set( + extension.runDir.map { runDir -> + runDir.asFile.absolutePath + } + ) + + // Configure Java Toolchain if Java plugin is present + project.plugins.withId("java") { + val javaExtension = project.extensions.findByType(JavaPluginExtension::class.java) + val javaToolchains = project.extensions.findByType(JavaToolchainService::class.java) + + if (javaExtension != null && javaToolchains != null) { + javaLauncher.set(javaToolchains.launcherFor(javaExtension.toolchain)) + } + } } project.tasks.register("plugwrightInit") { group = "verification" description = "Interactively initializes a plugwright-test environment with required configs and an initial test file." - + doFirst { if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) } @@ -311,7 +302,25 @@ class PlugwrightPlugin : Plugin { val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get()) try { - runNpmInstall(project, targetDir, nodePaths) + val isWin = System.getProperty("os.name").lowercase().contains("windows") + val cmd = if (isWin) listOf("cmd", "/c", nodePaths.npm, "install") else listOf(nodePaths.npm, "install") + val nodeDir = File(nodePaths.node).parent + + val execOps = project.objects.newInstance(InjectedExecOps::class.java) + val execResult = execOps.execOperations.exec { + workingDir = targetDir + commandLine = cmd + if (nodeDir != null) { + val pathKey = environment.keys.firstOrNull { it.equals("PATH", ignoreCase = true) } ?: "PATH" + environment[pathKey] = nodeDir + File.pathSeparator + (environment[pathKey] ?: "") + } + isIgnoreExitValue = true + } + + if (execResult.exitValue != 0) { + throw GradleException("EXEC ERROR: 'npm install' failed with exit code ${execResult.exitValue}.") + } + project.logger.lifecycle("Dependencies installed successfully.") project.logger.lifecycle("\nYou're all set! Run tests with: ./gradlew plugwrightTest") } catch (e: Exception) { if (e is GradleException) throw e @@ -340,19 +349,5 @@ class PlugwrightPlugin : Plugin { } } } - - // Auto-trigger npm install on IntelliJ IDEA sync if IDEA plugin is applied - project.plugins.withId("idea") { - project.pluginManager.apply("org.jetbrains.gradle.plugin.idea-ext") - project.afterEvaluate { - val ideaModel = project.extensions.findByType(IdeaModel::class.java) - if (ideaModel != null) { - val ideaProject = ideaModel.project as? ExtensionAware - val settings = ideaProject?.extensions?.findByType(ProjectSettings::class.java) as? ExtensionAware - val triggers = settings?.extensions?.findByType(TaskTriggersConfig::class.java) - triggers?.afterSync(plugwrightNpmInstall) - } - } - } } } diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt similarity index 100% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt rename to gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt similarity index 52% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt rename to gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt index b3d755c..dcadf5e 100644 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt @@ -1,7 +1,9 @@ package me.drownek.plugwright +import me.drownek.plugwright.api.ConfigNodeBuilder import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* import java.io.File @@ -20,14 +22,25 @@ abstract class PlugwrightTestTask : AbstractPlugwrightTask() { @get:Optional abstract val testNames: Property + /** Name of the environment under test. Written into the runner config and into report names. */ + @get:Input + abstract val environmentName: Property + + /** Where the generated runner config is written before the CLI is invoked. */ + @get:OutputFile + abstract val configFile: RegularFileProperty + init { group = "verification" description = "Run E2E tests for Paper plugin" + // Declaring the config file as an output must not make the run itself skippable: + // the test result depends on the plugin, the server and the spec files alike. + outputs.upToDateWhen { false } } @TaskAction fun runTests() { - val nodePaths = NodeManager.getOrDownloadNode(nodeInstallDir.get().asFile, nodeVersion.get(), downloadNode.get()) + val nodePaths = resolveNode() prepareServerEnvironment() val serverJar = serverJarPath.get() @@ -43,35 +56,20 @@ abstract class PlugwrightTestTask : AbstractPlugwrightTask() { logger.warn("Tests directory not configured") return } - + if (!userTestsDirectory.exists()) { logger.warn("Tests directory does not exist: ${userTestsDirectory.absolutePath}") return } - val nodeDir = File(nodePaths.node).parent - val npmEnv = if (nodeDir != null) { - val pathKey = System.getenv().keys.firstOrNull { it.equals("PATH", ignoreCase = true) } ?: "PATH" - mapOf(pathKey to nodeDir + File.pathSeparator + (System.getenv(pathKey) ?: "")) - } else emptyMap() - - // Build TypeScript tests if tsconfig.json exists - val tsconfigFile = File(userTestsDirectory, "tsconfig.json") - if (tsconfigFile.exists()) { - logger.lifecycle("TypeScript config found, compiling tests...") - runCommand(userTestsDirectory, nodePaths.npm, "run", "build", env = npmEnv) - } else { - logger.lifecycle("No TypeScript config found, running JavaScript tests directly") - } - // Build JVM arguments string for the runner val finalJvmArgs = serverArgs.toMutableList() - + // Ensure EULA argument is present if acceptEula is true if (shouldAcceptEula && !finalJvmArgs.any { it.contains("eula.agree") }) { finalJvmArgs.add("-Dcom.mojang.eula.agree=true") } - + val jvmArgsString = finalJvmArgs.joinToString(" ") // Run Tests using the npm package @@ -85,6 +83,20 @@ abstract class PlugwrightTestTask : AbstractPlugwrightTask() { logger.lifecycle("Server JAR: $serverJar") logger.lifecycle("JVM Args: $jvmArgsString") + val configDestination = configFile.get().asFile + writeRunnerConfig( + destination = configDestination, + serverJar = serverJar.trim(), + serverDirectory = serverDirectory.trim(), + javaPath = javaPath, + jvmArgs = finalJvmArgs, + minecraftVersion = mcVersion, + testsDirectory = userTestsDirectory + ) + logger.lifecycle("Runner config: ${configDestination.absolutePath}") + + // The environment variables are the pre-3.0 transport. The runner prefers --config + // and falls back to these, so an older runner still works with a newer plugin. val envMap = mutableMapOf( "SERVER_JAR" to serverJar.trim(), "SERVER_DIR" to serverDirectory.trim(), @@ -119,11 +131,57 @@ abstract class PlugwrightTestTask : AbstractPlugwrightTask() { ) runCommand( - userTestsDirectory, - nodePaths.node, cliJsFile.absolutePath, + userTestsDirectory, + nodePaths.node, cliJsFile.absolutePath, "--config", configDestination.absolutePath, env = envMap ) - + logger.lifecycle("E2E tests completed successfully") } + + private fun writeRunnerConfig( + destination: File, + serverJar: String, + serverDirectory: String, + javaPath: String, + jvmArgs: List, + minecraftVersion: String, + testsDirectory: File + ) { + val envName = environmentName.get() + val fileFilters = testFiles.orNull.splitFilter() + val nameFilters = testNames.orNull.splitFilter() + + val root = ConfigNodeBuilder().apply { + put("version", RunnerConfigWriter.CONFIG_VERSION) + obj("environment") { + put("name", envName) + put("mode", "local") + obj("config") { + put("serverJar", serverJar) + put("serverDir", serverDirectory) + put("javaPath", javaPath) + putStrings("jvmArgs", jvmArgs) + put("minecraftVersion", minecraftVersion) + // The bots connect to the server this task starts; the port still comes + // from server.properties defaults until environments can pick their own. + put("host", "localhost") + put("port", 25565) + } + } + obj("tests") { + put("dir", testsDirectory.absolutePath) + if (fileFilters != null) putStrings("include", fileFilters) else putNull("include") + if (nameFilters != null) putStrings("names", nameFilters) else putNull("names") + putNull("exclude") + // null means "runner default", which TEST_TIMEOUT can still override. + putNull("timeoutMs") + } + }.build() + + RunnerConfigWriter.write(destination, root) + } + + private fun String?.splitFilter(): List? = + this?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.takeIf { it.isNotEmpty() } } diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerConfigWriter.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerConfigWriter.kt new file mode 100644 index 0000000..f172ee3 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerConfigWriter.kt @@ -0,0 +1,63 @@ +package me.drownek.plugwright + +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.ConfigValue +import me.drownek.plugwright.api.SecretRef +import java.io.File + +/** + * Renders the runner configuration file passed to the CLI as `--config`. + * + * Secrets are written as references, never as values: the file lands in `build/` and + * would otherwise leak passwords into build artifacts. + */ +object RunnerConfigWriter { + + /** Bumped when the file layout changes in a way the runner must notice. */ + const val CONFIG_VERSION: Int = 1 + + private val gson = GsonBuilder() + .setPrettyPrinting() + .disableHtmlEscaping() + .serializeNulls() + .create() + + fun write(destination: File, root: ConfigNode): File { + destination.parentFile?.mkdirs() + destination.writeText(gson.toJson(toJson(root)), Charsets.UTF_8) + return destination + } + + fun toJson(value: ConfigValue): JsonElement = when (value) { + is ConfigValue.Str -> JsonPrimitive(value.value) + is ConfigValue.Num -> JsonPrimitive(value.value) + is ConfigValue.Bool -> JsonPrimitive(value.value) + is ConfigValue.Secret -> toJson(value.ref) + is ConfigValue.Arr -> JsonArray().apply { value.values.forEach { add(toJson(it)) } } + is ConfigValue.Obj -> JsonObject().apply { value.entries.forEach { (k, v) -> add(k, toJson(v)) } } + ConfigValue.Null -> JsonNull.INSTANCE + } + + private fun toJson(ref: SecretRef): JsonObject = JsonObject().apply { + when (ref) { + is SecretRef.FromEnv -> { + addProperty("from", "env") + addProperty("name", ref.name) + } + is SecretRef.FromFile -> { + addProperty("from", "file") + addProperty("path", ref.path) + } + is SecretRef.FromSystemProperty -> { + addProperty("from", "systemProperty") + addProperty("name", ref.name) + } + } + } +} diff --git a/gradle-plugin/settings.gradle.kts b/gradle-plugin/settings.gradle.kts index 065db97..901056b 100644 --- a/gradle-plugin/settings.gradle.kts +++ b/gradle-plugin/settings.gradle.kts @@ -1 +1,6 @@ -rootProject.name = "plugwright-gradle-plugin" +rootProject.name = "plugwright" + +// plugwright-api — stable contract third-party modes compile against +// plugwright-core — the Gradle plugin itself +include(":plugwright-api") +include(":plugwright-core") diff --git a/runner-package/lib/config.ts b/runner-package/lib/config.ts new file mode 100644 index 0000000..88f2cd7 --- /dev/null +++ b/runner-package/lib/config.ts @@ -0,0 +1,214 @@ +import { readFileSync } from 'fs'; +import { isAbsolute, resolve } from 'path'; + +/** Config layouts this runner understands. */ +export const SUPPORTED_CONFIG_VERSION = 1; + +/** Default file consulted when no --config flag is given. */ +export const DEFAULT_CONFIG_FILENAME = 'plugwright.config.json'; + +/** A secret is transported as a pointer; the value is read here, at run time. */ +export type SecretRef = + | { from: 'env'; name: string } + | { from: 'file'; path: string } + | { from: 'systemProperty'; name: string }; + +export interface RuntimeRef { + /** npm package exporting the environment factory. */ + package: string; + /** Named export holding the factory; the default export when omitted. */ + export?: string; +} + +export interface EnvironmentConfig { + /** Environment name, used in logs and report file names. */ + name: string; + /** Mode id: `local`, `external`, or one contributed by a third-party module. */ + mode: string; + /** Where to load a non-built-in environment implementation from. */ + runtime?: RuntimeRef | null; + /** Mode-specific settings; interpreted by the environment implementation. */ + config: Record; +} + +export interface TestsConfig { + /** Directory scanned for compiled spec files. Defaults to the working directory. */ + dir?: string | null; + /** Only run spec files matching these substrings. */ + include?: string[] | null; + /** Skip spec files matching these substrings. */ + exclude?: string[] | null; + /** Only run tests whose name contains one of these substrings. */ + names?: string[] | null; + /** Per-test timeout; falls back to TEST_TIMEOUT and then to 30s. */ + timeoutMs?: number | null; +} + +export interface RunnerConfig { + version: number; + environment: EnvironmentConfig; + tests: TestsConfig; +} + +/** Settings of the built-in `local` mode, which spawns its own Paper server. */ +export interface LocalEnvironmentConfig { + serverJar: string; + serverDir: string; + javaPath: string; + jvmArgs: string[]; + minecraftVersion?: string | null; + host?: string | null; + port?: number | null; +} + +/** + * Reads `--config ` / `--config=` from the given arguments. + */ +function readConfigFlag(argv: string[]): string | null { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--config') { + const value = argv[i + 1]; + if (!value || value.startsWith('-')) { + throw new Error('--config requires a path to a configuration file'); + } + return value; + } + if (arg.startsWith('--config=')) { + return arg.slice('--config='.length); + } + } + return null; +} + +function readConfigFile(path: string): RunnerConfig { + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch (error) { + throw new Error(`Cannot read plugwright config at ${path}: ${(error as Error).message}`); + } + + let parsed: RunnerConfig; + try { + parsed = JSON.parse(raw) as RunnerConfig; + } catch (error) { + throw new Error(`Invalid JSON in plugwright config at ${path}: ${(error as Error).message}`); + } + + if (typeof parsed.version !== 'number') { + throw new Error(`Plugwright config at ${path} has no "version" field`); + } + if (parsed.version > SUPPORTED_CONFIG_VERSION) { + throw new Error( + `Plugwright config at ${path} is version ${parsed.version}, this runner supports up to ` + + `${SUPPORTED_CONFIG_VERSION}. Update @drownek/plugwright in your test project.` + ); + } + if (!parsed.environment || typeof parsed.environment.mode !== 'string') { + throw new Error(`Plugwright config at ${path} has no "environment.mode"`); + } + + parsed.tests = parsed.tests ?? {}; + return parsed; +} + +function splitFilter(value: string | undefined): string[] | null { + if (!value) return null; + const parts = value.split(',').map(part => part.trim()).filter(part => part !== ''); + return parts.length > 0 ? parts : null; +} + +/** + * Pre-3.0 transport: five flat environment variables set by the Gradle plugin. + * Kept so an older plugin keeps working with a newer runner. + */ +function configFromEnvironment(): RunnerConfig { + const { SERVER_JAR, SERVER_DIR, JAVA_PATH, JVM_ARGS, MC_VERSION } = process.env; + + if (!SERVER_JAR || !SERVER_DIR || !JAVA_PATH) { + throw new Error( + 'No configuration found. Pass --config , or set SERVER_JAR, SERVER_DIR and JAVA_PATH.' + ); + } + + return { + version: SUPPORTED_CONFIG_VERSION, + environment: { + name: 'local', + mode: 'local', + config: { + serverJar: SERVER_JAR, + serverDir: SERVER_DIR, + javaPath: JAVA_PATH, + jvmArgs: (JVM_ARGS ?? '').split(' ').filter(arg => arg.trim() !== ''), + minecraftVersion: MC_VERSION ?? null, + host: 'localhost', + port: 25565, + }, + }, + tests: { + dir: null, + include: splitFilter(process.env.TEST_FILES), + names: splitFilter(process.env.TEST_NAMES), + exclude: null, + timeoutMs: null, + }, + }; +} + +/** + * Resolves the configuration for this run. + * + * Order: `--config `, then `plugwright.config.json` in the working directory, + * then the legacy environment variables. + */ +export function loadRunnerConfig(argv: string[] = process.argv.slice(2)): RunnerConfig { + const flagPath = readConfigFlag(argv); + if (flagPath) { + return readConfigFile(isAbsolute(flagPath) ? flagPath : resolve(process.cwd(), flagPath)); + } + + const defaultPath = resolve(process.cwd(), DEFAULT_CONFIG_FILENAME); + try { + readFileSync(defaultPath); + return readConfigFile(defaultPath); + } catch { + return configFromEnvironment(); + } +} + +/** True when [value] is a secret pointer rather than a plain value. */ +export function isSecretRef(value: unknown): value is SecretRef { + return typeof value === 'object' && value !== null && typeof (value as SecretRef).from === 'string'; +} + +/** + * Reads the value a [SecretRef] points at. Config files carry references, so a password + * never ends up in the Gradle configuration cache or in a build artifact. + */ +export function resolveSecret(ref: SecretRef): string { + switch (ref.from) { + case 'env': { + const value = process.env[ref.name]; + if (value === undefined) { + throw new Error(`Secret unavailable: environment variable ${ref.name} is not set`); + } + return value; + } + case 'file': { + try { + return readFileSync(ref.path, 'utf8').split(/\r?\n/)[0]; + } catch (error) { + throw new Error(`Secret unavailable: cannot read ${ref.path}: ${(error as Error).message}`); + } + } + case 'systemProperty': + throw new Error( + `Secret unavailable: "${ref.name}" is a JVM system property, which the runner cannot read. ` + + 'Use an environment variable or a file instead.' + ); + default: + throw new Error(`Unknown secret source: ${JSON.stringify(ref)}`); + } +} diff --git a/runner-package/runner.ts b/runner-package/runner.ts index b03b91f..d4adf13 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -11,6 +11,8 @@ import { ServerWrapper } from './lib/server.js'; import { testRegistry, scopeStack } from './lib/test-registry.js'; import { serverConsoleBuffer, createBot, disconnectAllBots, writeMcOutput } from './lib/bot-utils.js'; import { formatDuration, printTestSummary } from './lib/reporter.js'; +import { loadRunnerConfig } from './lib/config.js'; +import type { LocalEnvironmentConfig, RunnerConfig } from './lib/config.js'; import type { TestResult } from './lib/types.js'; // Enable source map support for accurate TypeScript stack traces @@ -22,6 +24,8 @@ export { PlayerWrapper } from './lib/player.js'; export { ServerWrapper } from './lib/server.js'; export { test, opTest, describe, beforeEach, afterEach } from './lib/test-registry.js'; export { expect } from './lib/matchers.js'; +export { loadRunnerConfig, resolveSecret, isSecretRef } from './lib/config.js'; +export type { RunnerConfig, EnvironmentConfig, TestsConfig, LocalEnvironmentConfig, SecretRef } from './lib/config.js'; export type { TestContext } from './lib/types.js'; async function waitForServerStart(serverProcess: ChildProcessWithoutNullStreams): Promise { @@ -75,28 +79,34 @@ async function findSpecFiles(dir: string): Promise { return results; } -export async function runTestSession(): Promise { - const serverJar = process.env.SERVER_JAR; - const serverDir = process.env.SERVER_DIR; - const javaPath = process.env.JAVA_PATH; - const testFileFilter = process.env.TEST_FILES; - const testNameFilter = process.env.TEST_NAMES; +export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): Promise { + const { mode, name: environmentName } = config.environment; + if (mode !== 'local') { + throw new Error(`Environment "${environmentName}" uses mode "${mode}", which this runner cannot run yet.`); + } + + const env = config.environment.config as unknown as LocalEnvironmentConfig; + const { serverJar, serverDir, javaPath } = env; + const mcVersion = env.minecraftVersion ?? undefined; + const host = env.host ?? 'localhost'; + const port = env.port ?? 25565; + const testFileFilters = config.tests.include ?? null; + const testNameFilters = config.tests.names ?? null; const testResults: TestResult[] = []; if (!serverJar || !serverDir || !javaPath) { - throw new Error('SERVER_JAR, JAVA_PATH and SERVER_DIR environment variables must be set'); + throw new Error('Environment config must provide serverJar, serverDir and javaPath'); } let exitCode = 0; console.log(`${pc.bold('Starting Paper server...')}`); - const jvmArgsString = process.env.JVM_ARGS || ''; - const jvmArgs = jvmArgsString.split(' ').filter(arg => arg.trim() !== ''); + const jvmArgs = env.jvmArgs ?? []; console.log(pc.dim(`JVM Arguments: ${jvmArgs.join(' ')}`)); - const serverProcess = spawn(javaPath!, [...jvmArgs, '-jar', serverJar, '--nogui'], { + const serverProcess = spawn(javaPath, [...jvmArgs, '-jar', serverJar, '--nogui'], { cwd: serverDir, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -156,9 +166,9 @@ export async function runTestSession(): Promise { serverProcess.stdout.on('data', writeMcOutput); serverProcess.stderr.on('data', writeMcOutput); - let testFiles = await findSpecFiles(process.cwd()); - if (testFileFilter) { - const patterns = testFileFilter.split(',').map(p => p.trim()); + let testFiles = await findSpecFiles(config.tests.dir || process.cwd()); + if (testFileFilters) { + const patterns = testFileFilters; console.log(`${pc.dim(`Filtering test files with patterns: ${JSON.stringify(patterns)}`)}\n`); testFiles = testFiles.filter(file => patterns.some(pattern => { @@ -170,7 +180,7 @@ export async function runTestSession(): Promise { ); } - console.log(`${pc.bold(`Found ${testFiles.length} test file(s)${testFileFilter ? ` matching filter: ${testFileFilter}` : ''}`)}\n`); + console.log(`${pc.bold(`Found ${testFiles.length} test file(s)${testFileFilters ? ` matching filter: ${testFileFilters.join(',')}` : ''}`)}\n`); for (const file of testFiles) { console.log(`\n${pc.blue(pc.bold(`Running tests from: ${file}`))}`); @@ -181,11 +191,10 @@ export async function runTestSession(): Promise { await import(pathToFileURL(file).href); for (const testCase of testRegistry) { - if (testNameFilter) { - const patterns = testNameFilter.split(',').map(p => p.trim()); - const matches = patterns.some(pattern => testCase.name.includes(pattern)); + if (testNameFilters) { + const matches = testNameFilters.some(pattern => testCase.name.includes(pattern)); if (!matches) { - console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (filter: ${testNameFilter})`)); + console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (filter: ${testNameFilters.join(',')})`)); continue; } } @@ -207,10 +216,10 @@ export async function runTestSession(): Promise { console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(botUsername)}`); const bot = createBot({ - host: 'localhost', - port: 25565, + host, + port, username: botUsername, - version: process.env.MC_VERSION, + version: mcVersion, auth: 'offline', }); @@ -218,9 +227,9 @@ export async function runTestSession(): Promise { player._captureSpawnPromise(); player.setServerWrapper(server); player._setBotOptions({ - host: 'localhost', - port: 25565, - version: process.env.MC_VERSION, + host, + port, + version: mcVersion, auth: 'offline', }); @@ -234,7 +243,8 @@ export async function runTestSession(): Promise { try { const abortController = new AbortController(); - const timeoutMs = process.env.TEST_TIMEOUT ? parseInt(process.env.TEST_TIMEOUT, 10) : 30000; + const timeoutMs = config.tests.timeoutMs + ?? (process.env.TEST_TIMEOUT ? parseInt(process.env.TEST_TIMEOUT, 10) : 30000); let timeoutHandle: ReturnType; const timeoutPromise = new Promise((_, reject) => { timeoutHandle = setTimeout(() => { diff --git a/scripts/bump-version.js b/scripts/bump-version.js index ebec250..c373c8f 100644 --- a/scripts/bump-version.js +++ b/scripts/bump-version.js @@ -44,7 +44,7 @@ function bumpVersionFiles(newVersion, isPrerelease) { // Matches any version after the package name, e.g., "@drownek/plugwright": "^1.x.x" replaceRegexInFile( - "gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt", + "gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt", /"@drownek\/plugwright": "\^[^"]+"/g, `"@drownek/plugwright": "^${newVersion}"` ); @@ -106,7 +106,7 @@ async function main() { changedSourceFiles.push( "README.md", "docs/quickstart.mdx", - "gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt", + "gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt", ); } From 7694c606fa73b68d2f86906eb8a396413c4c0d67 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 21:17:57 +0300 Subject: [PATCH 02/15] refactor(runner): replace module singletons with Session, add Environment/ServerConsole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the multi-mode runner redesign. local mode keeps its exact behavior (spawn Paper, wait for "Done (", stdio console, process-tree kill guards) but now lives behind the Environment/ServerConsole contracts instead of being runner.ts's only code path. - lib/session.ts: Session + MessageBuffer replace the module-level activeBots/messageBuffer/serverConsoleBuffer singletons that made it impossible to run two environments in one process. - lib/environment.ts, lib/console.ts: Environment and ServerConsole interfaces. - lib/environments/local.ts: LocalEnvironment + StdioConsole, carrying over spawn/waitForServerStart/killServerTree/teardown unchanged. - PlayerWrapper and ServerWrapper now hold a session reference instead of importing module state; matchers.ts reads buffers off that reference instead of module imports. - testRegistry/scopeStack stay module-level (documented why in session.ts) — still correct for one environment per process. Public API (test/opTest/describe/expect/PlayerWrapper/ServerWrapper/ wrappers) is unchanged. Verified: tsc --noEmit clean, full build clean, all 46 example_plugin e2e tests pass under local mode. --- runner-package/lib/bot-utils.ts | 104 ---------- runner-package/lib/console.ts | 14 ++ runner-package/lib/environment.ts | 37 ++++ runner-package/lib/environments/local.ts | 240 +++++++++++++++++++++++ runner-package/lib/matchers.ts | 10 +- runner-package/lib/player.ts | 35 ++-- runner-package/lib/server.ts | 17 +- runner-package/lib/session.ts | 156 +++++++++++++++ runner-package/runner.ts | 197 +++---------------- 9 files changed, 512 insertions(+), 298 deletions(-) delete mode 100644 runner-package/lib/bot-utils.ts create mode 100644 runner-package/lib/console.ts create mode 100644 runner-package/lib/environment.ts create mode 100644 runner-package/lib/environments/local.ts create mode 100644 runner-package/lib/session.ts diff --git a/runner-package/lib/bot-utils.ts b/runner-package/lib/bot-utils.ts deleted file mode 100644 index 26b60e5..0000000 --- a/runner-package/lib/bot-utils.ts +++ /dev/null @@ -1,104 +0,0 @@ -import mineflayer, { Bot } from 'mineflayer'; -import pc from 'picocolors'; - -/** Shared mutable state for active bots and buffers. */ -export const activeBots: Bot[] = []; -export const serverConsoleBuffer: string[] = []; - -/** - * Disconnects a bot, waiting for the `end` event or a timeout. - * Cleans up all listeners BEFORE registering end handler so it isn't stripped. - * Skips the wait entirely if the client is already ended. - */ -export function disconnectBot(bot: Bot, label: string, timeoutMs: number = 3000): Promise { - const cleanupListeners = () => { - try { - bot.removeAllListeners(); - } catch (err) { - console.log(pc.dim(`[Bot] ${label} warning: failed to remove listeners: ${(err as Error).message}`)); - } - }; - - const isAlreadyEnded = !!(bot as any)._client?.ended; - if (isAlreadyEnded) { - cleanupListeners(); - return Promise.resolve(); - } - - return new Promise((resolve) => { - const timeout = setTimeout(() => { - console.log(pc.dim(`[Bot] ${label} disconnect timeout, continuing`)); - cleanupListeners(); - resolve(); - }, timeoutMs); - - try { - bot.once('end', () => { - clearTimeout(timeout); - cleanupListeners(); - resolve(); - }); - bot.quit(); - } catch (err) { - console.log(pc.dim(`[Bot] ${label} error during disconnect: ${(err as Error).message}`)); - clearTimeout(timeout); - cleanupListeners(); - resolve(); - } - }); -} - -/** - * Creates a new mineflayer bot and registers it in the activeBots list. - */ -export function createBot(options: { - host: string; - port: number; - username: string; - version: string | undefined; - auth: 'mojang' | 'microsoft' | 'offline'; -}): Bot { - const bot = mineflayer.createBot({ - host: options.host, - port: options.port, - username: options.username, - version: options.version, - auth: options.auth, - }); - - activeBots.push(bot); - - bot.once('end', (reason: string) => { - console.log(pc.dim(`[Bot] ${options.username} connection ended: ${reason}`)); - }); - - return bot; -} - -/** - * Disconnects all active bots and clears the list. - */ -export async function disconnectAllBots(): Promise { - await Promise.all( - activeBots.map((b, i) => disconnectBot(b, b.username ?? `bot-${i}`, 2000)) - ); - - activeBots.length = 0; -} - -/** - * Writes Minecraft server output to the console and appends to the server console buffer. - */ -export function writeMcOutput(data: Buffer): void { - const text = data.toString().replace(/\r\n/g, '\n'); - const lines = text.split('\n'); - for (const line of lines) { - if (line.length > 0) { - serverConsoleBuffer.push(line); - } - } - const prefixed = lines - .map(line => line.length > 0 ? `${pc.gray('[MC]')} ${line}` : '') - .join('\n'); - process.stdout.write(prefixed); -} \ No newline at end of file diff --git a/runner-package/lib/console.ts b/runner-package/lib/console.ts new file mode 100644 index 0000000..ba0d585 --- /dev/null +++ b/runner-package/lib/console.ts @@ -0,0 +1,14 @@ +/** + * A channel for sending admin commands to the server and reading its output. + * `local` speaks to the Paper process over stdio; other channels (RCON, an + * admin bot) are added by later modes. + */ +export interface ServerConsole { + readonly kind: 'stdio' | 'rcon' | 'admin-bot'; + /** How much of the server's output this channel can see. Matchers must check this, + * not just whether a console exists, or tests silently stop working on `'responses'`/`'none'`. */ + readonly output: 'full' | 'responses' | 'none'; + probe(): Promise; + execute(cmd: string): void; + executeAndWait(cmd: string, timeoutMs?: number): Promise; +} diff --git a/runner-package/lib/environment.ts b/runner-package/lib/environment.ts new file mode 100644 index 0000000..b37cfe7 --- /dev/null +++ b/runner-package/lib/environment.ts @@ -0,0 +1,37 @@ +import type { ServerConsole } from './console.js'; +import type { Session } from './session.js'; + +/** What an environment actually supports. Declared expectations in the DSL are checked + * against this after `setup()`; a mismatch is printed once in the run header. */ +export interface EnvironmentCapabilities { + console: boolean; + consoleOutput: 'full' | 'responses' | 'none'; + op: boolean; + freshState: boolean; + arbitraryUsernames: boolean; + lifecycle: boolean; + cleanupStrategy: 'wipe' | 'compensating' | 'none'; +} + +export interface BotConnectionOptions { + host: string; + port: number; + version?: string; + auth: 'offline' | 'microsoft' | 'mojang'; +} + +/** + * A Minecraft server the runner can point bots at, plus however it needs to be + * prepared and torn down. `local` spawns and kills its own Paper process; + * `external` (a later phase) attaches to an already-running server instead. + */ +export interface Environment { + readonly id: string; + readonly capabilities: EnvironmentCapabilities; + /** Prepares the server. Receives the session so output/bot bookkeeping lands there + * instead of in module state. */ + setup(session: Session): Promise; + connection(): BotConnectionOptions; + console(): ServerConsole | null; + teardown(): Promise; +} diff --git a/runner-package/lib/environments/local.ts b/runner-package/lib/environments/local.ts new file mode 100644 index 0000000..42baf98 --- /dev/null +++ b/runner-package/lib/environments/local.ts @@ -0,0 +1,240 @@ +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import { randomUUID } from 'node:crypto'; +import pc from 'picocolors'; +import type { Environment, EnvironmentCapabilities, BotConnectionOptions } from '../environment.js'; +import type { ServerConsole } from '../console.js'; +import type { LocalEnvironmentConfig } from '../config.js'; +import type { Session } from '../session.js'; + +const CAPABILITIES: EnvironmentCapabilities = { + console: true, + consoleOutput: 'full', + op: true, + freshState: true, + arbitraryUsernames: true, + lifecycle: true, + cleanupStrategy: 'wipe', +}; + +/** Talks to the Paper process over its stdin/stdout, same as the runner always has. */ +class StdioConsole implements ServerConsole { + readonly kind = 'stdio' as const; + readonly output = 'full' as const; + + constructor( + private readonly serverProcess: ChildProcessWithoutNullStreams, + private readonly session: Session, + ) {} + + async probe(): Promise { + return this.serverProcess.exitCode === null && !this.serverProcess.killed; + } + + execute(cmd: string): void { + console.log(`${pc.yellow('[Server]')} ${pc.dim(`Executing: ${cmd}`)}`); + this.serverProcess.stdin.write(cmd + '\n', (err) => { + if (err) console.error(`[Server] Write error: ${err}`); + }); + } + + /** stdio has no synchronous response channel, so we round-trip through a `/say` marker + * and poll the console log for it, the same trick `PlayerWrapper.executeAndSync` uses. */ + async executeAndWait(cmd: string, timeoutMs: number = 5000): Promise { + const syncId = `sync_${randomUUID().split('-')[0]}`; + const since = this.session.consoleLog.length; + this.execute(cmd); + this.execute(`say ${syncId}`); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const line = this.session.consoleLog.slice(since).find(l => l.includes(syncId)); + if (line) return line; + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error(`Console command sync timed out for: ${cmd}`); + } +} + +/** + * The mode that's been here all along: download Paper, patch configs (Gradle side), + * spawn it, tear it down. Behavior is unchanged from the pre-Session runner.ts — + * this class just gives it a home that isn't the top-level function body. + */ +export class LocalEnvironment implements Environment { + readonly id = 'local'; + readonly capabilities = CAPABILITIES; + + private readonly config: LocalEnvironmentConfig; + private serverProcess: ChildProcessWithoutNullStreams | null = null; + private session: Session | null = null; + private cleanupStarted = false; + + constructor(config: LocalEnvironmentConfig) { + this.config = config; + } + + async setup(session: Session): Promise { + this.session = session; + + const { serverJar, serverDir, javaPath } = this.config; + if (!serverJar || !serverDir || !javaPath) { + throw new Error('Environment config must provide serverJar, serverDir and javaPath'); + } + + console.log(`${pc.bold('Starting Paper server...')}`); + const jvmArgs = this.config.jvmArgs ?? []; + console.log(pc.dim(`JVM Arguments: ${jvmArgs.join(' ')}`)); + + const serverProcess = spawn(javaPath, [...jvmArgs, '-jar', serverJar, '--nogui'], { + cwd: serverDir, + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.serverProcess = serverProcess; + this._installProcessGuards(serverProcess); + + await this._waitForServerStart(serverProcess); + console.log(`${pc.green(pc.bold('Server started successfully'))}\n`); + + serverProcess.stdout.on('data', (data: Buffer) => session.writeConsoleOutput(data)); + serverProcess.stderr.on('data', (data: Buffer) => session.writeConsoleOutput(data)); + } + + connection(): BotConnectionOptions { + return { + host: this.config.host ?? 'localhost', + port: this.config.port ?? 25565, + version: this.config.minecraftVersion ?? undefined, + auth: 'offline', + }; + } + + console(): ServerConsole | null { + if (!this.serverProcess || !this.session) return null; + return new StdioConsole(this.serverProcess, this.session); + } + + async teardown(): Promise { + const serverProcess = this.serverProcess; + if (!serverProcess) return; + + if (serverProcess.exitCode === null && !serverProcess.killed) { + try { + serverProcess.stdin.write('stop\n'); + } catch (err) { + console.log(pc.yellow(`[WARNING] Failed to send stop command to server: ${(err as Error).message}`)); + } + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + console.log(pc.yellow('[WARNING] Server did not stop gracefully, forcing shutdown...')); + serverProcess.kill(); + resolve(); + }, 30000); + + serverProcess.once('exit', (code) => { + clearTimeout(timeout); + if (code !== 0) { + console.log(pc.yellow(`[WARNING] Server exited with code: ${code}`)); + } + resolve(); + }); + }); + + serverProcess.removeAllListeners(); + serverProcess.stdin.end(); + serverProcess.stdout.destroy(); + serverProcess.stderr.destroy(); + } + + private _waitForServerStart(serverProcess: ChildProcessWithoutNullStreams): Promise { + const session = this.session!; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Server failed to start within 120 seconds')); + }, 120000); + + const dataHandler = (data: Buffer): void => { + const output = data.toString(); + session.writeConsoleOutput(data); + + if (output.includes('Done (')) { + clearTimeout(timeout); + serverProcess.stdout.removeListener('data', dataHandler); + serverProcess.stderr.removeListener('data', stderrHandler); + setTimeout(resolve, 3000); + } + }; + + const stderrHandler = (data: Buffer): void => { + session.writeConsoleOutput(data); + }; + + serverProcess.stdout.on('data', dataHandler); + serverProcess.stderr.on('data', stderrHandler); + + serverProcess.on('error', (err: Error) => { + clearTimeout(timeout); + reject(new Error(`Failed to start server: ${err.message}`)); + }); + + serverProcess.on('exit', (code: number | null) => { + if (code !== null && code !== 0) { + clearTimeout(timeout); + reject(new Error(`Server exited with code ${code} before becoming ready`)); + } + }); + }); + } + + /** + * Kills the Paper process tree if our own process dies unexpectedly — Gradle task + * cancelled from the IDE, SIGKILL from upstream, etc. Otherwise java.exe keeps + * running and holds run/logs/latest.log open, breaking the next clean on Windows. + */ + private _installProcessGuards(serverProcess: ChildProcessWithoutNullStreams): void { + const killServerTree = (): void => { + if (!serverProcess.pid || serverProcess.killed || serverProcess.exitCode !== null) return; + try { + if (process.platform === 'win32') { + // taskkill recursively kills the whole java process tree. + spawn('taskkill', ['/F', '/T', '/PID', String(serverProcess.pid)], { + stdio: 'ignore', + windowsHide: true, + }).on('error', () => { /* best effort */ }); + } else { + serverProcess.kill('SIGKILL'); + } + } catch { + /* best effort */ + } + }; + + const emergencyShutdown = (signal: string): void => { + if (this.cleanupStarted) return; + this.cleanupStarted = true; + console.log(pc.yellow(`\n[runner] Received ${signal}, killing Paper server...`)); + killServerTree(); + // Give taskkill a moment, then exit. + setTimeout(() => process.exit(1), 500).unref(); + }; + + process.on('SIGINT', () => emergencyShutdown('SIGINT')); + process.on('SIGTERM', () => emergencyShutdown('SIGTERM')); + process.on('SIGHUP', () => emergencyShutdown('SIGHUP')); + if (process.platform === 'win32') { + process.on('SIGBREAK', () => emergencyShutdown('SIGBREAK')); + } + // Last-resort safety net: if this node process exits for any reason while + // the server is still alive, try to take it down with us. + process.on('exit', () => killServerTree()); + // On Windows, when the parent (Gradle) is killed abruptly, signals are not + // delivered but our stdin pipe closes. Use that as a death signal. + if (process.stdin && typeof process.stdin.on === 'function') { + process.stdin.on('close', () => emergencyShutdown('stdin-close')); + process.stdin.on('end', () => emergencyShutdown('stdin-end')); + // stdin must be resumed for 'end'/'close' to fire on a piped stdin. + try { process.stdin.resume(); } catch { /* ignore */ } + } + } +} diff --git a/runner-package/lib/matchers.ts b/runner-package/lib/matchers.ts index cfbc3f3..8be4276 100644 --- a/runner-package/lib/matchers.ts +++ b/runner-package/lib/matchers.ts @@ -2,7 +2,6 @@ import { Matchers } from './expect.js'; import { PlayerWrapper } from './player.js'; import { ServerWrapper } from './server.js'; import { GuiItemLocator } from './wrappers.js'; -import { serverConsoleBuffer } from './bot-utils.js'; import { sleep } from './utils.js'; export class RunnerMatchers extends Matchers { @@ -73,8 +72,13 @@ export class RunnerMatchers extends Matchers { return strict ? msg === expectedMessage : msg.includes(expectedMessage); }; - const buffer = this.actual instanceof PlayerWrapper ? this.actual.messageBuffer : serverConsoleBuffer; - const view = (): string[] => since !== undefined ? buffer.slice(since) : buffer; + // A player's messages are its own (see `PlayerWrapper.messageBuffer`) so one bot's chat + // never satisfies an assertion made against another; the server log has no such split, + // it's one console shared by the whole session. + const buffer = this.actual instanceof PlayerWrapper + ? this.actual.messageBuffer + : (this.actual as ServerWrapper).session.consoleLog; + const view = (): string[] => buffer.slice(since); await this.pollAssertion( () => view().some(isMatch), diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 9d4a7c5..994d939 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -1,14 +1,20 @@ import { Bot } from 'mineflayer'; import { ItemWrapper, GuiWrapper, createPlayerExtensions, Window, LiveGuiHandle } from './wrappers.js'; import { ServerWrapper } from './server.js'; -import { activeBots, disconnectBot, createBot } from './bot-utils.js'; +import type { Session } from './session.js'; +import { MessageBuffer } from './session.js'; +import type { BotConnectionOptions } from './environment.js'; import { poll } from './utils.js'; import { randomUUID } from 'node:crypto'; import pc from 'picocolors'; export class PlayerWrapper { bot: Bot; - public readonly messageBuffer: string[] = []; + readonly session: Session; + /** This player's own received-chat log. Kept per player, not per session, so one bot's + * chat can't satisfy — or pollute — an assertion made against another bot in the same + * test run. */ + readonly messageBuffer = new MessageBuffer(); get inventory() { return this.bot.inventory; @@ -35,12 +41,13 @@ export class PlayerWrapper { gui!: (options: { title: string | RegExp; timeout?: number }) => Promise; private serverWrapper?: ServerWrapper; - private _botOptions?: { host: string; port: number; version: string | undefined; auth: 'mojang' | 'microsoft' | 'offline' }; + private _botOptions?: BotConnectionOptions; private _spawnPromise: Promise | null = null; private _listenersBot: Bot | null = null; - constructor(bot: Bot) { + constructor(bot: Bot, session: Session) { this.bot = bot; + this.session = session; this._bindExtensions(bot); } @@ -160,7 +167,7 @@ export class PlayerWrapper { * Clears the received message history for this player. */ clearMessages(): void { - this.messageBuffer.length = 0; + this.messageBuffer.clear(); } getMessageBufferIndex(): number { @@ -228,7 +235,7 @@ export class PlayerWrapper { } /** @internal */ - _setBotOptions(opts: { host: string; port: number; version: string | undefined; auth: 'mojang' | 'microsoft' | 'offline' }): void { + _setBotOptions(opts: BotConnectionOptions): void { this._botOptions = opts; } @@ -245,17 +252,12 @@ export class PlayerWrapper { const botUsername = this.username; const oldBot = this.bot; - await disconnectBot(oldBot, botUsername); + await this.session.disconnectBot(oldBot, botUsername); + this.session.removeBot(oldBot); - const idx = activeBots.indexOf(oldBot); - if (idx !== -1) activeBots.splice(idx, 1); - - const newBot = createBot({ - host: this._botOptions.host, - port: this._botOptions.port, + const newBot = this.session.createBot({ + ...this._botOptions, username: botUsername, - version: this._botOptions.version, - auth: this._botOptions.auth, }); this.bot = newBot; @@ -267,8 +269,7 @@ export class PlayerWrapper { try { await this.join(options); } catch (err) { - const idx = activeBots.indexOf(this.bot); - if (idx !== -1) activeBots.splice(idx, 1); + this.session.removeBot(this.bot); throw err; } } diff --git a/runner-package/lib/server.ts b/runner-package/lib/server.ts index 2a327a3..1954f4a 100644 --- a/runner-package/lib/server.ts +++ b/runner-package/lib/server.ts @@ -1,7 +1,16 @@ +import type { Session } from './session.js'; + export class ServerWrapper { - execute: (cmd: string) => void; + readonly session: Session; + + constructor(session: Session) { + this.session = session; + } - constructor(executeFn: (cmd: string) => void) { - this.execute = executeFn; + execute(cmd: string): void { + if (!this.session.console) { + throw new Error('No server console available for this environment'); + } + this.session.console.execute(cmd); } -} \ No newline at end of file +} diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts new file mode 100644 index 0000000..a6c1c38 --- /dev/null +++ b/runner-package/lib/session.ts @@ -0,0 +1,156 @@ +import mineflayer, { Bot } from 'mineflayer'; +import pc from 'picocolors'; +import type { Environment, BotConnectionOptions } from './environment.js'; +import type { ServerConsole } from './console.js'; + +/** + * Append-only line buffer. Replaces the old module-level `string[]` singletons + * (`messageBuffer`, `serverConsoleBuffer`) that a session's buffers used to be. + */ +export class MessageBuffer { + private lines: string[] = []; + + push(line: string): void { + this.lines.push(line); + } + + get length(): number { + return this.lines.length; + } + + clear(): void { + this.lines.length = 0; + } + + slice(start?: number): string[] { + return start !== undefined ? this.lines.slice(start) : [...this.lines]; + } + + find(predicate: (line: string) => boolean): string | undefined { + return this.lines.find(predicate); + } + + some(predicate: (line: string) => boolean): boolean { + return this.lines.some(predicate); + } +} + +/** + * Everything scoped to one test run against one environment: active bots, the + * message/console-log buffers matchers poll, and the console channel. Replaces + * the module-level singletons that made it impossible to run two environments + * in one process. + * + * `testRegistry`/`scopeStack` (test-registry.ts) stay module-level with a + * per-file reset — correct only as long as one process runs one environment + * and files run sequentially. Don't reach for this class to parallelize spec + * files without revisiting that too. + */ +export class Session { + readonly env: Environment; + console: ServerConsole | null = null; + readonly bots: Bot[] = []; + readonly consoleLog = new MessageBuffer(); + + constructor(env: Environment) { + this.env = env; + } + + /** Pulls the console channel from the environment. Called once `env.setup()` has produced one. */ + refreshConsole(): void { + this.console = this.env.console(); + } + + createBot(options: BotConnectionOptions & { username: string }): Bot { + const bot = mineflayer.createBot({ + host: options.host, + port: options.port, + username: options.username, + version: options.version, + auth: options.auth, + }); + + this.bots.push(bot); + + bot.once('end', (reason: string) => { + console.log(pc.dim(`[Bot] ${options.username} connection ended: ${reason}`)); + }); + + return bot; + } + + removeBot(bot: Bot): void { + const idx = this.bots.indexOf(bot); + if (idx !== -1) this.bots.splice(idx, 1); + } + + /** + * Disconnects a bot, waiting for the `end` event or a timeout. + * Skips the wait entirely if the client is already ended. + * + * Every exit path removes the bot's listeners: a disconnected client isn't reused, so + * nothing should still be reacting to its events (mineflayer keeps the client object + * alive briefly after `end`, and a stale listener firing during that window is how a + * message meant for a torn-down player used to reach the wrong place). + */ + disconnectBot(bot: Bot, label: string, timeoutMs: number = 3000): Promise { + const cleanupListeners = () => { + try { + bot.removeAllListeners(); + } catch (err) { + console.log(pc.dim(`[Bot] ${label} warning: failed to remove listeners: ${(err as Error).message}`)); + } + }; + + const isAlreadyEnded = !!(bot as any)._client?.ended; + if (isAlreadyEnded) { + cleanupListeners(); + return Promise.resolve(); + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + console.log(pc.dim(`[Bot] ${label} disconnect timeout, continuing`)); + cleanupListeners(); + resolve(); + }, timeoutMs); + + try { + bot.once('end', () => { + clearTimeout(timeout); + cleanupListeners(); + resolve(); + }); + bot.quit(); + } catch (err) { + console.log(pc.dim(`[Bot] ${label} error during disconnect: ${(err as Error).message}`)); + clearTimeout(timeout); + cleanupListeners(); + resolve(); + } + }); + } + + async disconnectAllBots(): Promise { + await Promise.all( + this.bots.map((b, i) => this.disconnectBot(b, b.username ?? `bot-${i}`, 2000)) + ); + + this.bots.length = 0; + } + + /** Feeds raw environment output (e.g. Minecraft server stdout/stderr) into the console log buffer. */ + writeConsoleOutput(data: Buffer): void { + const text = data.toString().replace(/\r\n/g, '\n'); + const lines = text.split('\n'); + for (const line of lines) { + if (line.length > 0) { + this.consoleLog.push(line); + } + } + const prefixed = lines + .map(line => line.length > 0 ? `${pc.gray('[MC]')} ${line}` : '') + .join('\n'); + process.stdout.write(prefixed); + } +} diff --git a/runner-package/runner.ts b/runner-package/runner.ts index d4adf13..c066d05 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -1,4 +1,3 @@ -import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; import { readdir } from 'fs/promises'; import { join, basename } from 'path'; import { pathToFileURL } from 'url'; @@ -9,10 +8,12 @@ import { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator } from './lib/wr import { PlayerWrapper } from './lib/player.js'; import { ServerWrapper } from './lib/server.js'; import { testRegistry, scopeStack } from './lib/test-registry.js'; -import { serverConsoleBuffer, createBot, disconnectAllBots, writeMcOutput } from './lib/bot-utils.js'; +import { Session } from './lib/session.js'; +import { LocalEnvironment } from './lib/environments/local.js'; import { formatDuration, printTestSummary } from './lib/reporter.js'; import { loadRunnerConfig } from './lib/config.js'; -import type { LocalEnvironmentConfig, RunnerConfig } from './lib/config.js'; +import type { Environment } from './lib/environment.js'; +import type { EnvironmentConfig, LocalEnvironmentConfig, RunnerConfig } from './lib/config.js'; import type { TestResult } from './lib/types.js'; // Enable source map support for accurate TypeScript stack traces @@ -27,44 +28,16 @@ export { expect } from './lib/matchers.js'; export { loadRunnerConfig, resolveSecret, isSecretRef } from './lib/config.js'; export type { RunnerConfig, EnvironmentConfig, TestsConfig, LocalEnvironmentConfig, SecretRef } from './lib/config.js'; export type { TestContext } from './lib/types.js'; - -async function waitForServerStart(serverProcess: ChildProcessWithoutNullStreams): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Server failed to start within 120 seconds')); - }, 120000); - - const dataHandler = (data: Buffer): void => { - const output = data.toString(); - writeMcOutput(data); - - if (output.includes('Done (')) { - clearTimeout(timeout); - serverProcess.stdout.removeListener('data', dataHandler); - serverProcess.stderr.removeListener('data', stderrHandler); - setTimeout(resolve, 3000); - } - }; - - const stderrHandler = (data: Buffer): void => { - writeMcOutput(data); - }; - - serverProcess.stdout.on('data', dataHandler); - serverProcess.stderr.on('data', stderrHandler); - - serverProcess.on('error', (err: Error) => { - clearTimeout(timeout); - reject(new Error(`Failed to start server: ${err.message}`)); - }); - - serverProcess.on('exit', (code: number | null) => { - if (code !== null && code !== 0) { - clearTimeout(timeout); - reject(new Error(`Server exited with code ${code} before becoming ready`)); - } - }); - }); +export type { Environment, EnvironmentCapabilities, BotConnectionOptions } from './lib/environment.js'; +export type { ServerConsole } from './lib/console.js'; +export { Session } from './lib/session.js'; + +/** Only `local` is wired up yet; third-party modes arrive with the mode registry (phase 3). */ +function resolveEnvironment(cfg: EnvironmentConfig): Environment { + if (cfg.mode !== 'local') { + throw new Error(`Environment "${cfg.name}" uses mode "${cfg.mode}", which this runner cannot run yet.`); + } + return new LocalEnvironment(cfg.config as unknown as LocalEnvironmentConfig); } async function findSpecFiles(dir: string): Promise { @@ -80,91 +53,20 @@ async function findSpecFiles(dir: string): Promise { } export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): Promise { - const { mode, name: environmentName } = config.environment; - if (mode !== 'local') { - throw new Error(`Environment "${environmentName}" uses mode "${mode}", which this runner cannot run yet.`); - } - - const env = config.environment.config as unknown as LocalEnvironmentConfig; - const { serverJar, serverDir, javaPath } = env; - const mcVersion = env.minecraftVersion ?? undefined; - const host = env.host ?? 'localhost'; - const port = env.port ?? 25565; const testFileFilters = config.tests.include ?? null; const testNameFilters = config.tests.names ?? null; const testResults: TestResult[] = []; - if (!serverJar || !serverDir || !javaPath) { - throw new Error('Environment config must provide serverJar, serverDir and javaPath'); - } + const env = resolveEnvironment(config.environment); + const session = new Session(env); let exitCode = 0; - console.log(`${pc.bold('Starting Paper server...')}`); - - const jvmArgs = env.jvmArgs ?? []; - - console.log(pc.dim(`JVM Arguments: ${jvmArgs.join(' ')}`)); - - const serverProcess = spawn(javaPath, [...jvmArgs, '-jar', serverJar, '--nogui'], { - cwd: serverDir, - stdio: ['pipe', 'pipe', 'pipe'] - }); - - // Ensure the Paper server dies if our runner is killed (e.g. Gradle task - // cancelled from the IDE). Otherwise the java.exe keeps running and holds - // run/logs/latest.log open, breaking the next plugwrightClean on Windows. - const killServerTree = (): void => { - if (!serverProcess.pid || serverProcess.killed || serverProcess.exitCode !== null) return; - try { - if (process.platform === 'win32') { - // taskkill recursively kills the whole java process tree. - spawn('taskkill', ['/F', '/T', '/PID', String(serverProcess.pid)], { - stdio: 'ignore', - windowsHide: true, - }).on('error', () => { /* best effort */ }); - } else { - serverProcess.kill('SIGKILL'); - } - } catch { - /* best effort */ - } - }; - - let cleanupStarted = false; - const emergencyShutdown = (signal: string): void => { - if (cleanupStarted) return; - cleanupStarted = true; - console.log(pc.yellow(`\n[runner] Received ${signal}, killing Paper server...`)); - killServerTree(); - // Give taskkill a moment, then exit. - setTimeout(() => process.exit(1), 500).unref(); - }; - - process.on('SIGINT', () => emergencyShutdown('SIGINT')); - process.on('SIGTERM', () => emergencyShutdown('SIGTERM')); - process.on('SIGHUP', () => emergencyShutdown('SIGHUP')); - if (process.platform === 'win32') { - process.on('SIGBREAK', () => emergencyShutdown('SIGBREAK')); - } - // Last-resort safety net: if this node process exits for any reason while - // the server is still alive, try to take it down with us. - process.on('exit', () => killServerTree()); - // On Windows, when the parent (Gradle) is killed abruptly, signals are not - // delivered but our stdin pipe closes. Use that as a death signal. - if (process.stdin && typeof process.stdin.on === 'function') { - process.stdin.on('close', () => emergencyShutdown('stdin-close')); - process.stdin.on('end', () => emergencyShutdown('stdin-end')); - // stdin must be resumed for 'end'/'close' to fire on a piped stdin. - try { process.stdin.resume(); } catch { /* ignore */ } - } + await env.setup(session); + session.refreshConsole(); try { - await waitForServerStart(serverProcess); - console.log(`${pc.green(pc.bold('Server started successfully'))}\n`); - - serverProcess.stdout.on('data', writeMcOutput); - serverProcess.stderr.on('data', writeMcOutput); + const connOpts = env.connection(); let testFiles = await findSpecFiles(config.tests.dir || process.cwd()); if (testFileFilters) { @@ -201,37 +103,21 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); - serverConsoleBuffer.length = 0; + session.consoleLog.clear(); - const server = new ServerWrapper((cmd: string) => { - console.log(`${pc.yellow('[Server]')} ${pc.dim(`Executing: ${cmd}`)}`); - serverProcess.stdin.write(cmd + '\n', (err) => { - if (err) console.error(`[Server] Write error: ${err}`); - }); - }); + const server = new ServerWrapper(session); const createPlayer = async (options?: { username?: string }): Promise => { const uniqueId = randomUUID().split('-')[0]; const botUsername = options?.username || `Test_${uniqueId}`; console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(botUsername)}`); - const bot = createBot({ - host, - port, - username: botUsername, - version: mcVersion, - auth: 'offline', - }); + const bot = session.createBot({ ...connOpts, username: botUsername }); - const player = new PlayerWrapper(bot); + const player = new PlayerWrapper(bot, session); player._captureSpawnPromise(); player.setServerWrapper(server); - player._setBotOptions({ - host, - port, - version: mcVersion, - auth: 'offline', - }); + player._setBotOptions(connOpts); await player.join(); return player; @@ -275,43 +161,14 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): error: error as Error }); } finally { - await disconnectAllBots(); + await session.disconnectAllBots(); } } } } finally { - await disconnectAllBots(); - - // Stop the server - if (serverProcess.exitCode === null && !serverProcess.killed) { - try { - serverProcess.stdin.write('stop\n'); - } catch (err) { - console.log(pc.yellow(`[WARNING] Failed to send stop command to server: ${(err as Error).message}`)); - } - } - - await new Promise((resolve) => { - const timeout = setTimeout(() => { - console.log(pc.yellow('[WARNING] Server did not stop gracefully, forcing shutdown...')); - serverProcess.kill(); - resolve(); - }, 30000); - - serverProcess.once('exit', (code) => { - clearTimeout(timeout); - if (code !== 0) { - console.log(pc.yellow(`[WARNING] Server exited with code: ${code}`)); - } - resolve(); - }); - }); - - serverProcess.removeAllListeners(); - serverProcess.stdin.end(); - serverProcess.stdout.destroy(); - serverProcess.stderr.destroy(); + await session.disconnectAllBots(); + await env.teardown(); exitCode = printTestSummary(testResults); From 1371293a56bda2786fd4bb937c40e016424c871b Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 21:39:59 +0300 Subject: [PATCH 03/15] feat(gradle): add mode registry, move LocalMode to plugwright-local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the multi-mode architecture: - plugwright-api: PlugwrightMode gains applyLegacyDefaults() for seeding an implicit environment from deprecated flat properties; TaskRegistrationContext gains environmentConfig() so a mode can hand over its runner-config node computed lazily at task execution time. New RunDirFile and LegacyEnvironmentProperties types. - plugwright-core: PlugwrightExtension gains registerMode()/environments{} DSL and primaryEnvironment; new EnvironmentContainer (mode + spec registry), TaskRegistrationContextImpl and ValidationContextImpl. PlugwrightPlugin is renamed PlugwrightCorePlugin and made fully mode-agnostic: it creates the implicit 'local' environment when no environments{} block is present, then asks each environment's mode to validate and register its own tasks. PlugwrightTestTask no longer hardcodes local-server specifics — it just writes whatever ConfigNode its mode produced. - plugwright-local (new module): LocalMode + LocalEnvironmentSpec, and the local-only tasks split out of the old monolithic task base (PaperProvisionTask, PlugwrightCleanTask, PlugwrightRunServerTask). Also hosts the published io.github.drownek.plugwright plugin id for now — a dedicated bundle module can take that over once a second built-in mode exists to combine with it. Task names are now generated per environment (plugwrightTestLocal, plugwrightCleanLocal, ...), with bare aliases (plugwrightTest, ...) pointing at whatever environment is primaryEnvironment (defaults to 'local'). Builds with no environments{} block behave exactly as before, verified by the full example_plugin e2e suite (46/46 passing). --- .../api/LegacyEnvironmentProperties.kt | 23 ++ .../drownek/plugwright/api/PlugwrightMode.kt | 6 + .../me/drownek/plugwright/api/RunDirFile.kt | 18 ++ .../plugwright/api/TaskRegistrationContext.kt | 9 + .../plugwright-core/build.gradle.kts | 22 +- .../plugwright/EnvironmentContainer.kt | 64 +++++ ...rightPlugin.kt => PlugwrightCorePlugin.kt} | 244 +++++++----------- .../drownek/plugwright/PlugwrightExtension.kt | 126 ++++----- .../drownek/plugwright/PlugwrightTestTask.kt | 127 +++------ .../plugwright/TaskRegistrationContextImpl.kt | 58 +++++ .../plugwright/ValidationContextImpl.kt | 22 ++ .../plugwright-local/build.gradle.kts | 41 +++ .../me/drownek/plugwright/PlugwrightPlugin.kt | 20 ++ .../plugwright/local/LocalEnvironmentSpec.kt | 94 +++++++ .../me/drownek/plugwright/local/LocalMode.kt | 124 +++++++++ .../plugwright/local/PaperProvisionTask.kt} | 136 +++++----- .../plugwright/local/PlugwrightCleanTask.kt | 57 ++++ .../local/PlugwrightRunServerTask.kt} | 49 ++-- gradle-plugin/settings.gradle.kts | 7 +- 19 files changed, 811 insertions(+), 436 deletions(-) create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/LegacyEnvironmentProperties.kt create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunDirFile.kt create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/EnvironmentContainer.kt rename gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/{PlugwrightPlugin.kt => PlugwrightCorePlugin.kt} (53%) create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ValidationContextImpl.kt create mode 100644 gradle-plugin/plugwright-local/build.gradle.kts create mode 100644 gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt create mode 100644 gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt create mode 100644 gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt rename gradle-plugin/{plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt => plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt} (84%) create mode 100644 gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightCleanTask.kt rename gradle-plugin/{plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt => plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightRunServerTask.kt} (55%) diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/LegacyEnvironmentProperties.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/LegacyEnvironmentProperties.kt new file mode 100644 index 0000000..be54cda --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/LegacyEnvironmentProperties.kt @@ -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 + val jvmArgs: ListProperty + val acceptEula: Property + val runDir: DirectoryProperty + val pluginUrls: ListProperty + val runDirFiles: ListProperty + val cleanExcludePatterns: ListProperty + val useExternalPluginsOnly: Property +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt index 52bc915..9763180 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt @@ -28,6 +28,12 @@ interface PlugwrightMode { /** 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. diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunDirFile.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunDirFile.kt new file mode 100644 index 0000000..3137ad0 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunDirFile.kt @@ -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 + } +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt index 4f0347d..9d1707d 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt @@ -40,6 +40,15 @@ interface TaskRegistrationContext { * the matrix run it before the tests. */ fun prepareTask(task: TaskProvider) + + /** + * Overrides the mode-specific part of this environment's runner config ([ConfigNode], + * landing under `environment.config`), computed lazily at task execution time. + * + * Use this instead of [PlugwrightMode.serialize] when the value needs something only a + * task can reach — a Gradle service such as the Java toolchain, for instance. + */ + fun environmentConfig(node: Provider) } /** Kotlin-friendly overload of [TaskRegistrationContext.register]. */ diff --git a/gradle-plugin/plugwright-core/build.gradle.kts b/gradle-plugin/plugwright-core/build.gradle.kts index c7c03cc..d4185f9 100644 --- a/gradle-plugin/plugwright-core/build.gradle.kts +++ b/gradle-plugin/plugwright-core/build.gradle.kts @@ -1,7 +1,5 @@ plugins { `kotlin-dsl` - `maven-publish` - id("com.gradle.plugin-publish") version "1.2.1" } val projectVersion = version.toString() @@ -9,15 +7,15 @@ val projectVersion = version.toString() dependencies { implementation(gradleApi()) implementation("com.google.code.gson:gson:2.10.1") - implementation("org.yaml:snakeyaml:2.0") // The api module has no separate published coordinates yet, so its classes are // merged into this jar below. compileOnly keeps it out of the published POM. compileOnly(project(":plugwright-api")) } -// Until plugwright-api is published on its own, ship it inside the plugin jar so -// both this plugin and third-party mode jars resolve the same contract classes. +// Until plugwright-api is published on its own, ship it inside this jar so both this +// module and whatever entry-point module publishes it (currently plugwright-local) +// resolve the same contract classes. val apiJar = project(":plugwright-api").tasks.named("jar", Jar::class) tasks.named("jar") { @@ -25,20 +23,6 @@ tasks.named("jar") { from(apiJar.map { zipTree(it.archiveFile) }) } -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" - } - } -} - val generateVersionResource = tasks.register("generateVersionResource") { val outFile = layout.buildDirectory.file("generated/version-resource/plugwright-version.properties") inputs.property("version", projectVersion) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/EnvironmentContainer.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/EnvironmentContainer.kt new file mode 100644 index 0000000..da3a04e --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/EnvironmentContainer.kt @@ -0,0 +1,64 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.EnvironmentSpec +import me.drownek.plugwright.api.PlugwrightMode +import org.gradle.api.GradleException +import org.gradle.api.model.ObjectFactory + +/** + * Registry of [PlugwrightMode]s and the [EnvironmentSpec]s declared against them. + * + * A hand-rolled container rather than Gradle's `ExtensiblePolymorphicDomainObjectContainer`: + * environments are created once while the build script is evaluated and read back once in + * `afterEvaluate`, so the extra machinery of a live domain object container buys nothing here. + */ +class EnvironmentContainer(private val objects: ObjectFactory) { + + class Entry(val spec: EnvironmentSpec, val mode: PlugwrightMode<*>) + + private val modesById = mutableMapOf>() + private val entries = linkedMapOf() + + fun registerMode(mode: PlugwrightMode<*>) { + modesById[mode.id] = mode + } + + fun modeById(id: String): PlugwrightMode<*> = + modesById[id] ?: throw GradleException( + "No plugwright mode is registered under id '$id'. Call registerMode(...) first " + + "(the built-in local mode registers itself when the plugin is applied)." + ) + + /** Declares environment [name], backed by [mode]'s spec type. */ + fun create(name: String, mode: PlugwrightMode, action: S.() -> Unit = {}): S { + if (entries.containsKey(name)) { + throw GradleException("Environment '$name' is already declared.") + } + val spec = mode.createSpec(name, objects) + spec.action() + entries[name] = Entry(spec, mode) + return spec + } + + /** + * Creates environment [name] from whatever mode is registered under that same id, with no + * build-script configuration. Used for the implicit "local" environment. + */ + fun createImplicit(name: String): Entry { + create(name, modeById(name).erased()) {} + return entries.getValue(name) + } + + val isEmpty: Boolean get() = entries.isEmpty() + val names: Set get() = entries.keys + val all: Collection get() = entries.values + operator fun get(name: String): Entry? = entries[name] +} + +/** + * Recovers usable static typing after a [PlugwrightMode] has been erased to `PlugwrightMode<*>`. + * Safe because the [EnvironmentSpec] passed alongside it always came from that same mode's + * [PlugwrightMode.createSpec]. + */ +@Suppress("UNCHECKED_CAST") +internal fun PlugwrightMode<*>.erased(): PlugwrightMode = this as PlugwrightMode diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt similarity index 53% rename from gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt rename to gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt index ea43f97..52eb2f6 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -1,10 +1,10 @@ package me.drownek.plugwright +import me.drownek.plugwright.api.ConfigNodeBuilder import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.plugins.JavaPluginExtension -import org.gradle.jvm.toolchain.JavaToolchainService +import org.gradle.api.provider.Provider import java.io.File import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -21,11 +21,18 @@ object BannerState { /** * Name of the implicit environment used while the build script has no `environments { }` - * block: the flat extension properties describe one local server. + * block: the flat extension properties describe one environment under this name. */ const val DEFAULT_ENVIRONMENT_NAME = "local" -class PlugwrightPlugin : Plugin { +/** + * Mode-agnostic engine: the extension, the shared compile step, and per-environment task + * generation. Knows nothing about `local`/`external`/any other mode — those register + * themselves through [PlugwrightExtension.registerMode] before this plugin's + * `afterEvaluate` runs. See [PlugwrightPlugin] (in the module that publishes the plugin id) + * for where the built-in modes actually get registered. + */ +class PlugwrightCorePlugin : Plugin { override fun apply(project: Project) { val extension = project.extensions.create("plugwright", PlugwrightExtension::class.java, project) @@ -34,56 +41,6 @@ class PlugwrightPlugin : Plugin { // file lock in NodeManager. val defaultNodeInstallDir = File(project.gradle.gradleUserHomeDir, "caches/plugwright/node") - // Register plugwrightClean task - val plugwrightClean = project.tasks.register("plugwrightClean") { - group = "verification" - description = "Wipes the test server data for a clean slate." - - doFirst { - if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) - } - - doLast { - val runDir = extension.runDir.get().asFile - val excludePatterns = extension.cleanExcludePatterns.get() - - if (!runDir.exists()) { - project.logger.lifecycle(" Run directory doesn't exist yet, nothing to clean") - return@doLast - } - - project.logger.lifecycle(" Cleaning run directory (excluding: ${excludePatterns.joinToString(", ")})") - - // Get all files and directories in the run folder - val allEntries = runDir.listFiles() ?: emptyArray() - - // Separate entries into deleted and kept - val deletedFiles = mutableListOf() - val keptFiles = mutableListOf() - - // Delete everything except the excluded patterns - allEntries.forEach { entry -> - val shouldExclude = excludePatterns.any { pattern -> - entry.name == pattern - } - - if (!shouldExclude) { - deletedFiles.add(entry.name) - project.delete(entry) - } else { - keptFiles.add(entry.name) - } - } - - if (deletedFiles.isNotEmpty()) { - project.logger.lifecycle(" deleted: ${deletedFiles.joinToString(", ")}") - } - if (keptFiles.isNotEmpty()) { - project.logger.lifecycle(" preserved: ${keptFiles.joinToString(", ")}") - } - } - } - val plugwrightCompileTests = project.tasks.register("plugwrightCompileTests", PlugwrightCompileTestsTask::class.java) { doFirst { if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) @@ -95,106 +52,100 @@ class PlugwrightPlugin : Plugin { nodeInstallDir.set(defaultNodeInstallDir) } - project.tasks.register("plugwrightTest", PlugwrightTestTask::class.java) { - // Ensure clean runs before test - dependsOn(plugwrightClean) - // npm install + tsc are shared across environments, so they live in their own task - dependsOn(plugwrightCompileTests) + registerInitTask(project, extension, defaultNodeInstallDir) - doFirst { - if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) - } - - testsDir.set(extension.testsDir) - environmentName.set(DEFAULT_ENVIRONMENT_NAME) - configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$DEFAULT_ENVIRONMENT_NAME.json")) - minecraftVersion.set(extension.minecraftVersion) - jvmArgs.set(extension.jvmArgs) - acceptEula.set(extension.acceptEula) - pluginUrls.set(extension.pluginUrls) - runDirFiles.set(extension.runDirFiles) - nodeVersion.set(extension.nodeVersion) - downloadNode.set(extension.downloadNode) - nodeInstallDir.set(defaultNodeInstallDir) - - // Support command line properties for filtering - if (project.hasProperty("testFiles")) { - testFiles.set(project.property("testFiles") as String) - } + project.afterEvaluate { + wireEnvironments(project, extension, plugwrightCompileTests, defaultNodeInstallDir) + } + } - if (project.hasProperty("testNames")) { - testNames.set(project.property("testNames") as String) - } + private fun wireEnvironments( + project: Project, + extension: PlugwrightExtension, + plugwrightCompileTests: org.gradle.api.tasks.TaskProvider, + defaultNodeInstallDir: File + ) { + // No environments { } block: fold the deprecated flat properties into one implicit + // environment, using whatever mode was registered under the default name. + if (extension.environments.isEmpty) { + val entry = extension.environments.createImplicit(DEFAULT_ENVIRONMENT_NAME) + entry.mode.erased().applyLegacyDefaults(entry.spec, extension) + } - serverJarPath.set( - extension.runDir.map { runDir -> - val serverJar = runDir.asFile.resolve("server.jar") - serverJar.absolutePath - } + val primaryName = extension.primaryEnvironment.get() + if (extension.environments[primaryName] == null) { + throw GradleException( + "plugwright.primaryEnvironment is set to '$primaryName', but no such environment is " + + "declared. Declared environments: ${extension.environments.names.joinToString()}" ) + } - serverDir.set( - extension.runDir.map { runDir -> - runDir.asFile.absolutePath - } - ) + val projectPluginJarProvider = resolveProjectPluginJar(project, extension) + val validationProblems = mutableListOf() - // Configure Java Toolchain if Java plugin is present - project.plugins.withId("java") { - val javaExtension = project.extensions.findByType(JavaPluginExtension::class.java) - val javaToolchains = project.extensions.findByType(JavaToolchainService::class.java) + extension.environments.all.forEach { entry -> + val envName = entry.spec.name + val mode = entry.mode.erased() + val ctx = TaskRegistrationContextImpl(project, envName, envName == primaryName, projectPluginJarProvider) - if (javaExtension != null && javaToolchains != null) { - javaLauncher.set(javaToolchains.launcherFor(javaExtension.toolchain)) + val testTask = ctx.register("Test", PlugwrightTestTask::class.java) { + doFirst { + if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) } + dependsOn(plugwrightCompileTests) + testsDir.set(extension.testsDir) + environmentName.set(envName) + modeId.set(mode.id) + excludeTests.set(entry.spec.excludeTests) + configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$envName.json")) + nodeVersion.set(extension.nodeVersion) + downloadNode.set(extension.downloadNode) + nodeInstallDir.set(defaultNodeInstallDir) + + if (project.hasProperty("testFiles")) testFiles.set(project.property("testFiles") as String) + if (project.hasProperty("testNames")) testNames.set(project.property("testNames") as String) } - } - project.tasks.register("plugwrightRunServer", PlugwrightRunTask::class.java) { - // Ensure clean runs before starting the server - dependsOn(plugwrightClean) + val validation = ValidationContextImpl(envName, project.logger) + mode.validate(entry.spec, validation) + validationProblems += validation.errors.map { "[$envName] $it" } - doFirst { - if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) - } - - minecraftVersion.set(extension.minecraftVersion) - jvmArgs.set(extension.jvmArgs) - acceptEula.set(extension.acceptEula) - pluginUrls.set(extension.pluginUrls) - runDirFiles.set(extension.runDirFiles) - nodeVersion.set(extension.nodeVersion) - downloadNode.set(extension.downloadNode) - nodeInstallDir.set(defaultNodeInstallDir) + mode.registerTasks(entry.spec, ctx) - serverJarPath.set( - extension.runDir.map { runDir -> - val serverJar = runDir.asFile.resolve("server.jar") - serverJar.absolutePath - } - ) - - serverDir.set( - extension.runDir.map { runDir -> - runDir.asFile.absolutePath - } - ) + testTask.configure { + ctx.prepareTaskRef?.let { dependsOn(it) } + environmentConfig.set( + ctx.environmentConfigProvider + ?: project.provider { ConfigNodeBuilder().apply { mode.serialize(entry.spec, this) }.build() } + ) + } + } - // Configure Java Toolchain if Java plugin is present - project.plugins.withId("java") { - val javaExtension = project.extensions.findByType(JavaPluginExtension::class.java) - val javaToolchains = project.extensions.findByType(JavaToolchainService::class.java) + if (validationProblems.isNotEmpty()) { + throw GradleException("plugwright configuration problems:\n" + validationProblems.joinToString("\n") { " $it" }) + } + } - if (javaExtension != null && javaToolchains != null) { - javaLauncher.set(javaToolchains.launcherFor(javaExtension.toolchain)) - } - } + /** The jar of the plugin under test, from `shadowJar` / `reobfJar` / `jar`. Absent when + * the build asked for external plugins only, or when no jar-producing task exists. */ + private fun resolveProjectPluginJar(project: Project, extension: PlugwrightExtension): Provider { + if (extension.useExternalPluginsOnly.get()) { + return project.objects.property(File::class.java) } + val jarTask = when { + project.tasks.findByName("shadowJar") != null -> project.tasks.named("shadowJar") + project.tasks.findByName("reobfJar") != null -> project.tasks.named("reobfJar") + project.tasks.findByName("jar") != null -> project.tasks.named("jar") + else -> null + } ?: return project.objects.property(File::class.java) + return jarTask.map { it.outputs.files.singleFile } + } + private fun registerInitTask(project: Project, extension: PlugwrightExtension, defaultNodeInstallDir: File) { project.tasks.register("plugwrightInit") { group = "verification" description = "Interactively initializes a plugwright-test environment with required configs and an initial test file." - + doFirst { if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) } @@ -288,7 +239,7 @@ class PlugwrightPlugin : Plugin { testFile.writeText( """ import {expect, test} from '@drownek/plugwright'; - + test('help displays message', async ({ player, server }) => { player.chat('/help'); await expect(player).toHaveReceivedMessage('Help'); @@ -328,26 +279,5 @@ class PlugwrightPlugin : Plugin { } } } - - project.afterEvaluate { - // Only set up plugin jar dependency if not using external plugins only - if (!extension.useExternalPluginsOnly.get()) { - // Try to find the task that produces the plugin jar - val jarTask = when { - project.tasks.findByName("shadowJar") != null -> project.tasks.named("shadowJar") - project.tasks.findByName("reobfJar") != null -> project.tasks.named("reobfJar") - else -> project.tasks.named("jar") - } - - if (jarTask.isPresent) { - project.tasks.named("plugwrightTest", PlugwrightTestTask::class.java).configure { - pluginJar.set(jarTask.map { it.outputs.files.singleFile }) - } - project.tasks.named("plugwrightRunServer", PlugwrightRunTask::class.java).configure { - pluginJar.set(jarTask.map { it.outputs.files.singleFile }) - } - } - } - } } } diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt index 584fa11..3037f52 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt @@ -1,14 +1,17 @@ package me.drownek.plugwright +import me.drownek.plugwright.api.LegacyEnvironmentProperties +import me.drownek.plugwright.api.PlugwrightMode +import me.drownek.plugwright.api.RunDirFile import org.gradle.api.Project import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import java.io.File -abstract class PlugwrightExtension(project: Project) { +abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperties { /** - * Directory containing test files (.spec.js) + * Directory containing test files (.spec.js / .spec.ts) */ val testsDir: DirectoryProperty = project.objects.directoryProperty().convention( project.layout.projectDirectory.dir("src/test/e2e") @@ -27,83 +30,66 @@ abstract class PlugwrightExtension(project: Project) { val downloadNode: Property = project.objects.property(Boolean::class.java).convention(false) /** - * Directory where the server will be run from. - * Will be created automatically if it doesn't exist. + * Environment the unsuffixed task aliases (`plugwrightTest`, `plugwrightClean`, …) point + * at. Only meaningful once more than one environment is declared. */ - val runDir: DirectoryProperty = project.objects.directoryProperty().convention( - project.layout.projectDirectory.dir("run") - ) + val primaryEnvironment: Property = project.objects.property(String::class.java).convention(DEFAULT_ENVIRONMENT_NAME) /** - * Minecraft version for the Paper server (e.g., "1.19.4", "1.20.4") + * Mode registry and declared environments. See [registerMode] and [environments]. */ - val minecraftVersion: Property = project.objects.property(String::class.java).convention("1.19.4") + val environments: EnvironmentContainer = EnvironmentContainer(project.objects) - /** - * JVM arguments to pass when starting the server. - */ - val jvmArgs: ListProperty = project.objects.listProperty(String::class.java).convention( - listOf( - "-Xmx2G" - ) + /** Registers a [PlugwrightMode] so [environments] can create environments of its spec type. */ + fun registerMode(mode: PlugwrightMode<*>) { + environments.registerMode(mode) + } + + /** Declares the environments tests can run against. */ + fun environments(action: EnvironmentContainer.() -> Unit) { + environments.action() + } + + // ---- Deprecated flat properties -------------------------------------------------- + // Pre-3.0 shape: describes a single implicit "local" environment. Still read whenever + // the build script has no environments { } block — see PlugwrightMode.applyLegacyDefaults. + + @Deprecated("Use environments { create(\"local\", LocalMode) { minecraftVersion.set(...) } }") + override val minecraftVersion: Property = project.objects.property(String::class.java).convention("1.19.4") + + @Deprecated("Use environments { create(\"local\", LocalMode) { jvmArgs.set(...) } }") + override val jvmArgs: ListProperty = project.objects.listProperty(String::class.java).convention( + listOf("-Xmx2G") ) - /** - * Whether to accept the Minecraft EULA automatically. - * When true, adds -Dcom.mojang.eula.agree=true to JVM args. - */ - val acceptEula: Property = project.objects.property(Boolean::class.java).convention(true) + @Deprecated("Use environments { create(\"local\", LocalMode) { acceptEula.set(...) } }") + override val acceptEula: Property = project.objects.property(Boolean::class.java).convention(true) - /** - * List of files/folders to exclude from deletion during plugwrightClean. - * By default, excludes server.jar, cache, and libraries folders. - * These paths are relative to the run directory. - */ - val cleanExcludePatterns: ListProperty = project.objects.listProperty(String::class.java).convention( - listOf( - "server.jar", - "cache", - "libraries" - ) + @Deprecated("Use environments { create(\"local\", LocalMode) { runDir.set(...) } }") + override val runDir: DirectoryProperty = project.objects.directoryProperty().convention( + project.layout.projectDirectory.dir("run") ) - /** - * URLs of plugins to download before running tests. - * These plugins will be placed in the server's plugins directory. - */ - val pluginUrls: ListProperty = project.objects.listProperty(String::class.java).convention(emptyList()) + @Deprecated("Use environments { create(\"local\", LocalMode) { cleanExcludePatterns.set(...) } }") + override val cleanExcludePatterns: ListProperty = project.objects.listProperty(String::class.java).convention( + listOf("server.jar", "cache", "libraries") + ) - /** - * Whether to use only externally downloaded plugins instead of building the project plugin. - * When true, the plugwrightTest task will not depend on jar/shadowJar/reobfJar tasks. - * Useful when running E2E tests with plugins downloaded from external sources only. - */ - val useExternalPluginsOnly: Property = project.objects.property(Boolean::class.java).convention(false) + @Deprecated("Use environments { create(\"local\", LocalMode) { downloadPlugins { ... } } }") + override val pluginUrls: ListProperty = project.objects.listProperty(String::class.java).convention(emptyList()) - /** - * List of files to write into the run directory before the server starts. - * Internal storage — use the writeFiles { } DSL block to populate. - */ - val runDirFiles: ListProperty = project.objects.listProperty(RunDirFile::class.java).convention(emptyList()) + @Deprecated("Use environments { create(\"local\", LocalMode) { useExternalPluginsOnly.set(...) } }") + override val useExternalPluginsOnly: Property = project.objects.property(Boolean::class.java).convention(false) + + @Deprecated("Use environments { create(\"local\", LocalMode) { writeFiles { ... } } }") + override val runDirFiles: ListProperty = project.objects.listProperty(RunDirFile::class.java).convention(emptyList()) /** * DSL method for staging files into the run directory before server start. * * Paths are relative to the run directory. - * - * Example: - * ``` - * writeFiles { - * // inline text content - * file("plugins/SomePlugin/config.yml", """ - * key: "value" - * """.trimIndent()) - * - * // copy from a local source file - * file("plugins/MyPlugin/data.json", projectDir.resolve("test-fixtures/data.json")) - * } - * ``` */ + @Deprecated("Use environments { create(\"local\", LocalMode) { writeFiles { ... } } }") fun writeFiles(action: RunDirFileSpec.() -> Unit) { val spec = RunDirFileSpec() action(spec) @@ -127,26 +113,10 @@ abstract class PlugwrightExtension(project: Project) { } } - /** - * Represents a single file to be written into the run directory. - * Exactly one of [content] or [sourceFile] will be non-null. - */ - data class RunDirFile( - val path: String, - val content: String?, - val sourceFile: File? - ) - /** * DSL method for configuring plugin downloads. - * Example: - * ``` - * downloadPlugins { - * url("https://example.com/plugin1.jar") - * url("https://example.com/plugin2.jar") - * } - * ``` */ + @Deprecated("Use environments { create(\"local\", LocalMode) { downloadPlugins { ... } } }") fun downloadPlugins(action: PluginDownloadSpec.() -> Unit) { val spec = PluginDownloadSpec() action(spec) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt index dcadf5e..8ab44ae 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt @@ -1,14 +1,23 @@ package me.drownek.plugwright +import me.drownek.plugwright.api.ConfigNode import me.drownek.plugwright.api.ConfigNodeBuilder import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* import java.io.File -abstract class PlugwrightTestTask : AbstractPlugwrightTask() { +/** + * Runs the compiled test suite against one environment. + * + * Mode-agnostic: whichever mode owns this environment prepares whatever it needs through + * its own tasks (wired in via [me.drownek.plugwright.api.TaskRegistrationContext.prepareTask]) + * and hands over the mode-specific part of the runner config through [environmentConfig]. + */ +abstract class PlugwrightTestTask : AbstractNodeTask() { @get:InputDirectory @get:Optional @@ -22,10 +31,27 @@ abstract class PlugwrightTestTask : AbstractPlugwrightTask() { @get:Optional abstract val testNames: Property - /** Name of the environment under test. Written into the runner config and into report names. */ + /** Name of the environment under test. Written into the runner config and report names. */ @get:Input abstract val environmentName: Property + /** Mode id this environment runs under (`local`, `external`, …). */ + @get:Input + abstract val modeId: Property + + /** Test name substrings to skip in this environment. */ + @get:Input + @get:Optional + abstract val excludeTests: ListProperty + + /** + * The mode-specific part of the runner config (`environment.config`). Set by the plugin + * from either [me.drownek.plugwright.api.PlugwrightMode.serialize] or the mode's own + * [me.drownek.plugwright.api.TaskRegistrationContext.environmentConfig] override. + */ + @get:Internal + abstract val environmentConfig: Property + /** Where the generated runner config is written before the CLI is invoked. */ @get:OutputFile abstract val configFile: RegularFileProperty @@ -41,15 +67,7 @@ abstract class PlugwrightTestTask : AbstractPlugwrightTask() { @TaskAction fun runTests() { val nodePaths = resolveNode() - prepareServerEnvironment() - - val serverJar = serverJarPath.get() - val serverDirectory = serverDir.get() - val mcVersion = minecraftVersion.get() - val serverArgs = jvmArgs.get() - val shouldAcceptEula = acceptEula.get() - // Check tests directory val userTestsDirectory = if (testsDir.isPresent) { testsDir.get().asFile } else { @@ -62,61 +80,12 @@ abstract class PlugwrightTestTask : AbstractPlugwrightTask() { return } - // Build JVM arguments string for the runner - val finalJvmArgs = serverArgs.toMutableList() - - // Ensure EULA argument is present if acceptEula is true - if (shouldAcceptEula && !finalJvmArgs.any { it.contains("eula.agree") }) { - finalJvmArgs.add("-Dcom.mojang.eula.agree=true") - } - - val jvmArgsString = finalJvmArgs.joinToString(" ") - - // Run Tests using the npm package - val javaPath = if (javaLauncher.isPresent) { - javaLauncher.get().executablePath.asFile.absolutePath - } else { - File(System.getProperty("java.home"), "bin/java" + if (System.getProperty("os.name").lowercase().contains("win")) ".exe" else "").absolutePath - } - - logger.lifecycle("Running E2E tests...") - logger.lifecycle("Server JAR: $serverJar") - logger.lifecycle("JVM Args: $jvmArgsString") + logger.lifecycle("Running E2E tests for environment '${environmentName.get()}'...") val configDestination = configFile.get().asFile - writeRunnerConfig( - destination = configDestination, - serverJar = serverJar.trim(), - serverDirectory = serverDirectory.trim(), - javaPath = javaPath, - jvmArgs = finalJvmArgs, - minecraftVersion = mcVersion, - testsDirectory = userTestsDirectory - ) + writeRunnerConfig(configDestination, userTestsDirectory) logger.lifecycle("Runner config: ${configDestination.absolutePath}") - // The environment variables are the pre-3.0 transport. The runner prefers --config - // and falls back to these, so an older runner still works with a newer plugin. - val envMap = mutableMapOf( - "SERVER_JAR" to serverJar.trim(), - "SERVER_DIR" to serverDirectory.trim(), - "JAVA_PATH" to javaPath, - "JVM_ARGS" to jvmArgsString, - "MC_VERSION" to mcVersion - ) - - if (testFiles.isPresent) { - val fileFilter = testFiles.get() - envMap["TEST_FILES"] = fileFilter - logger.lifecycle("Test files filter: $fileFilter") - } - - if (testNames.isPresent) { - val nameFilter = testNames.get() - envMap["TEST_NAMES"] = nameFilter - logger.lifecycle("Test names filter: $nameFilter") - } - val defaultCliJs = File(userTestsDirectory, "node_modules/@drownek/plugwright/dist/cli.js") val cliJsFile = sequenceOf( // Canonical path resolves npm symlink bugs on CI @@ -130,50 +99,28 @@ abstract class PlugwrightTestTask : AbstractPlugwrightTask() { "Did 'npm install' succeed in ${userTestsDirectory.absolutePath}?" ) - runCommand( - userTestsDirectory, - nodePaths.node, cliJsFile.absolutePath, "--config", configDestination.absolutePath, - env = envMap - ) + runCommand(userTestsDirectory, nodePaths.node, cliJsFile.absolutePath, "--config", configDestination.absolutePath) logger.lifecycle("E2E tests completed successfully") } - private fun writeRunnerConfig( - destination: File, - serverJar: String, - serverDirectory: String, - javaPath: String, - jvmArgs: List, - minecraftVersion: String, - testsDirectory: File - ) { - val envName = environmentName.get() + private fun writeRunnerConfig(destination: File, testsDirectory: File) { val fileFilters = testFiles.orNull.splitFilter() val nameFilters = testNames.orNull.splitFilter() + val excludeList = if (excludeTests.isPresent) excludeTests.get() else emptyList() val root = ConfigNodeBuilder().apply { put("version", RunnerConfigWriter.CONFIG_VERSION) obj("environment") { - put("name", envName) - put("mode", "local") - obj("config") { - put("serverJar", serverJar) - put("serverDir", serverDirectory) - put("javaPath", javaPath) - putStrings("jvmArgs", jvmArgs) - put("minecraftVersion", minecraftVersion) - // The bots connect to the server this task starts; the port still comes - // from server.properties defaults until environments can pick their own. - put("host", "localhost") - put("port", 25565) - } + put("name", environmentName.get()) + put("mode", modeId.get()) + put("config", environmentConfig.get()) } obj("tests") { put("dir", testsDirectory.absolutePath) if (fileFilters != null) putStrings("include", fileFilters) else putNull("include") if (nameFilters != null) putStrings("names", nameFilters) else putNull("names") - putNull("exclude") + if (excludeList.isNotEmpty()) putStrings("exclude", excludeList) else putNull("exclude") // null means "runner default", which TEST_TIMEOUT can still override. putNull("timeoutMs") } diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt new file mode 100644 index 0000000..0622c4a --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt @@ -0,0 +1,58 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.TaskRegistrationContext +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import java.io.File + +/** + * A task registered through [register] gets a name of the form `plugwright`. + * When [environmentName] is the build's primary environment, the first registration of a + * given suffix also gets a bare `plugwright` alias. + */ +internal class TaskRegistrationContextImpl( + override val project: Project, + override val environmentName: String, + private val isPrimary: Boolean, + override val projectPluginJar: Provider +) : TaskRegistrationContext { + + /** Set by [prepareTask]; read by the plugin once every mode has registered its tasks. */ + var prepareTaskRef: TaskProvider? = null + private set + + /** Set by [environmentConfig]; when null, the plugin falls back to [me.drownek.plugwright.api.PlugwrightMode.serialize]. */ + var environmentConfigProvider: Provider? = null + private set + + private val aliasedSuffixes = mutableSetOf() + + override fun register(suffix: String, type: Class, action: T.() -> Unit): TaskProvider { + val envSuffix = environmentName.replaceFirstChar { it.uppercaseChar() } + val taskName = "plugwright$suffix$envSuffix" + val provider = project.tasks.register(taskName, type) { action() } + + if (isPrimary && aliasedSuffixes.add(suffix)) { + val aliasName = "plugwright$suffix" + if (project.tasks.findByName(aliasName) == null) { + project.tasks.register(aliasName) { + group = "verification" + description = "Alias for $taskName (primary environment '$environmentName')" + dependsOn(provider) + } + } + } + return provider + } + + override fun prepareTask(task: TaskProvider) { + prepareTaskRef = task + } + + override fun environmentConfig(node: Provider) { + environmentConfigProvider = node + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ValidationContextImpl.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ValidationContextImpl.kt new file mode 100644 index 0000000..f365d6f --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ValidationContextImpl.kt @@ -0,0 +1,22 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.ValidationContext +import org.gradle.api.logging.Logger + +/** Warnings are logged immediately; errors are collected so the plugin can report every + * environment's problems in one build failure instead of stopping at the first one. */ +internal class ValidationContextImpl( + override val environmentName: String, + private val logger: Logger +) : ValidationContext { + + val errors = mutableListOf() + + override fun error(message: String) { + errors.add(message) + } + + override fun warn(message: String) { + logger.warn("plugwright [$environmentName]: $message") + } +} diff --git a/gradle-plugin/plugwright-local/build.gradle.kts b/gradle-plugin/plugwright-local/build.gradle.kts new file mode 100644 index 0000000..689739f --- /dev/null +++ b/gradle-plugin/plugwright-local/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + `kotlin-dsl` + `maven-publish` + id("com.gradle.plugin-publish") version "1.2.1" +} + +dependencies { + implementation(gradleApi()) + implementation("com.google.code.gson:gson:2.10.1") + implementation("org.yaml:snakeyaml:2.0") + implementation(project(":plugwright-core")) + + // Compile-time only: its classes reach the runtime classpath through plugwright-core's + // jar, which this module re-merges below. + compileOnly(project(":plugwright-api")) +} + +// This is the module published under the plugin id, so its jar must carry the api and +// core classes too — neither is published under its own coordinates. +val apiJar = project(":plugwright-api").tasks.named("jar", Jar::class) +val coreJar = project(":plugwright-core").tasks.named("jar", Jar::class) + +tasks.named("jar") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(apiJar.map { zipTree(it.archiveFile) }) + from(coreJar.map { zipTree(it.archiveFile) }) +} + +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" + } + } +} diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt new file mode 100644 index 0000000..5177a0d --- /dev/null +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt @@ -0,0 +1,20 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.local.LocalMode +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** + * Entry point for the `io.github.drownek.plugwright` id. + * + * Applies the mode-agnostic engine and registers the built-in modes — just `local` for + * now. A dedicated bundle module can take over this role once a second built-in mode + * exists to combine with it. + */ +class PlugwrightPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply(PlugwrightCorePlugin::class.java) + val extension = project.extensions.getByType(PlugwrightExtension::class.java) + extension.registerMode(LocalMode) + } +} diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt new file mode 100644 index 0000000..c011243 --- /dev/null +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt @@ -0,0 +1,94 @@ +package me.drownek.plugwright.local + +import me.drownek.plugwright.api.EnvironmentSpec +import me.drownek.plugwright.api.RunDirFile +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import java.io.File + +/** Build-script description of a Paper server the runner downloads, patches, spawns and + * tears down itself, reachable at `localhost`. */ +class LocalEnvironmentSpec(private val environmentName: String, objects: ObjectFactory) : EnvironmentSpec { + + override fun getName(): String = environmentName + + override val includeInMatrix: Property = objects.property(Boolean::class.java).convention(true) + override val allowFailure: Property = objects.property(Boolean::class.java).convention(false) + override val excludeTests: ListProperty = objects.listProperty(String::class.java).convention(emptyList()) + + /** Minecraft version for the Paper server (e.g., "1.19.4", "1.20.4"). */ + val minecraftVersion: Property = objects.property(String::class.java).convention("1.19.4") + + /** JVM arguments to pass when starting the server. */ + val jvmArgs: ListProperty = objects.listProperty(String::class.java).convention(listOf("-Xmx2G")) + + /** Whether to accept the Minecraft EULA automatically. */ + val acceptEula: Property = objects.property(Boolean::class.java).convention(true) + + /** Directory where the server will be run from. Created automatically if missing. */ + val runDir: DirectoryProperty = objects.directoryProperty() + + /** Port bots connect on. Currently always bound on `localhost`. */ + val port: Property = objects.property(Int::class.java).convention(25565) + + /** URLs of plugins to download before running tests. */ + val pluginUrls: ListProperty = objects.listProperty(String::class.java).convention(emptyList()) + + /** Files to write into the run directory before the server starts. Populated via [writeFiles]. */ + val runDirFiles: ListProperty = objects.listProperty(RunDirFile::class.java).convention(emptyList()) + + /** Files/folders excluded from deletion during the clean task, relative to [runDir]. */ + val cleanExcludePatterns: ListProperty = objects.listProperty(String::class.java).convention( + listOf("server.jar", "cache", "libraries") + ) + + /** When true, the plugin under test is not built or installed automatically. */ + val useExternalPluginsOnly: Property = objects.property(Boolean::class.java).convention(false) + + /** + * DSL method for configuring plugin downloads. + * ``` + * downloadPlugins { + * url("https://example.com/plugin1.jar") + * } + * ``` + */ + fun downloadPlugins(action: PluginDownloadSpec.() -> Unit) { + val spec = PluginDownloadSpec() + action(spec) + pluginUrls.set(spec.urls) + } + + class PluginDownloadSpec { + internal val urls = mutableListOf() + fun url(pluginUrl: String) { + urls.add(pluginUrl) + } + } + + /** + * DSL method for staging files into the run directory before server start. Paths are + * relative to [runDir]. + */ + fun writeFiles(action: RunDirFileSpec.() -> Unit) { + val spec = RunDirFileSpec() + action(spec) + runDirFiles.set(spec.entries) + } + + class RunDirFileSpec { + internal val entries = mutableListOf() + + /** Write [content] (as UTF-8 text) to [path] relative to the run directory. */ + fun file(path: String, content: String) { + entries.add(RunDirFile(path, content, null)) + } + + /** Copy [sourceFile] to [path] relative to the run directory. */ + fun file(path: String, sourceFile: File) { + entries.add(RunDirFile(path, null, sourceFile)) + } + } +} diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt new file mode 100644 index 0000000..4d9f401 --- /dev/null +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt @@ -0,0 +1,124 @@ +package me.drownek.plugwright.local + +import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.ConfigNodeBuilder +import me.drownek.plugwright.api.LegacyEnvironmentProperties +import me.drownek.plugwright.api.PlugwrightMode +import me.drownek.plugwright.api.RunnerPackageRef +import me.drownek.plugwright.api.TaskRegistrationContext +import me.drownek.plugwright.api.ValidationContext +import org.gradle.api.model.ObjectFactory +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.provider.Provider +import org.gradle.jvm.toolchain.JavaLauncher +import org.gradle.jvm.toolchain.JavaToolchainService +import java.io.File + +/** + * Built-in mode: downloads Paper, patches its configs, spawns it, and points bots at + * `localhost`. Registered by default wherever the `io.github.drownek.plugwright` id is + * applied. + */ +object LocalMode : PlugwrightMode { + override val id = "local" + override val specType = LocalEnvironmentSpec::class.java + + override fun createSpec(name: String, objects: ObjectFactory): LocalEnvironmentSpec = + LocalEnvironmentSpec(name, objects) + + override fun runnerPackages(spec: LocalEnvironmentSpec): List = + listOf(RunnerPackageRef("@drownek/plugwright", export = "localEnvironment")) + + override fun validate(spec: LocalEnvironmentSpec, ctx: ValidationContext) { + if (spec.minecraftVersion.get().isBlank()) { + ctx.error("minecraftVersion must not be blank") + } + if (!spec.runDir.isPresent) { + ctx.error("runDir must be set") + } + } + + override fun applyLegacyDefaults(spec: LocalEnvironmentSpec, legacy: LegacyEnvironmentProperties) { + spec.minecraftVersion.set(legacy.minecraftVersion) + spec.jvmArgs.set(legacy.jvmArgs) + spec.acceptEula.set(legacy.acceptEula) + spec.runDir.set(legacy.runDir) + spec.pluginUrls.set(legacy.pluginUrls) + spec.runDirFiles.set(legacy.runDirFiles) + spec.cleanExcludePatterns.set(legacy.cleanExcludePatterns) + spec.useExternalPluginsOnly.set(legacy.useExternalPluginsOnly) + } + + override fun serialize(spec: LocalEnvironmentSpec, node: ConfigNodeBuilder) { + // Never actually reached: registerTasks() below always overrides this through + // ctx.environmentConfig(...), since the real javaPath needs the toolchain service + // that only a task (not this configuration-time call) can reach. Implemented anyway + // so the fallback stays correct if that ever changes. + fillConfig(node, spec, resolveJavaPath(null)) + } + + override fun registerTasks(spec: LocalEnvironmentSpec, ctx: TaskRegistrationContext) { + val project = ctx.project + + val clean = ctx.register("Clean", PlugwrightCleanTask::class.java) { + runDir.set(spec.runDir) + cleanExcludePatterns.set(spec.cleanExcludePatterns) + } + + val provision = ctx.register("Provision", PaperProvisionTask::class.java) { + dependsOn(clean) + runDir.set(spec.runDir) + minecraftVersion.set(spec.minecraftVersion) + pluginJar.set(ctx.projectPluginJar) + pluginUrls.set(spec.pluginUrls) + runDirFiles.set(spec.runDirFiles) + } + + val javaLauncherProvider: Provider? = run { + val javaExtension = project.extensions.findByType(JavaPluginExtension::class.java) + val toolchains = project.extensions.findByType(JavaToolchainService::class.java) + if (javaExtension != null && toolchains != null) toolchains.launcherFor(javaExtension.toolchain) else null + } + + ctx.register("RunServer", PlugwrightRunServerTask::class.java) { + dependsOn(provision) + runDir.set(spec.runDir) + serverJarPath.set(spec.runDir.file("server.jar").map { it.asFile.absolutePath }) + jvmArgs.set(spec.jvmArgs) + acceptEula.set(spec.acceptEula) + javaLauncherProvider?.let { javaLauncher.set(it) } + } + + ctx.prepareTask(provision) + + ctx.environmentConfig(project.provider { + buildConfigNode(spec, resolveJavaPath(javaLauncherProvider)) + }) + } + + private fun buildConfigNode(spec: LocalEnvironmentSpec, javaPath: String): ConfigNode = + ConfigNodeBuilder().also { fillConfig(it, spec, javaPath) }.build() + + private fun fillConfig(builder: ConfigNodeBuilder, spec: LocalEnvironmentSpec, javaPath: String) { + val jvmArgs = spec.jvmArgs.get().toMutableList() + if (spec.acceptEula.get() && jvmArgs.none { it.contains("eula.agree") }) { + jvmArgs.add("-Dcom.mojang.eula.agree=true") + } + + builder.put("serverJar", spec.runDir.get().file("server.jar").asFile.absolutePath) + builder.put("serverDir", spec.runDir.get().asFile.absolutePath) + builder.put("javaPath", javaPath) + builder.putStrings("jvmArgs", jvmArgs) + builder.put("minecraftVersion", spec.minecraftVersion.get()) + builder.put("host", "localhost") + builder.put("port", spec.port.get()) + } + + private fun resolveJavaPath(javaLauncher: Provider?): String { + if (javaLauncher != null && javaLauncher.isPresent) { + return javaLauncher.get().executablePath.asFile.absolutePath + } + val isWindows = System.getProperty("os.name").lowercase().contains("win") + return File(System.getProperty("java.home"), "bin/java" + if (isWindows) ".exe" else "").absolutePath + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt similarity index 84% rename from gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt rename to gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt index 2a65169..b7587d9 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt @@ -1,11 +1,15 @@ -package me.drownek.plugwright +package me.drownek.plugwright.local import com.google.gson.JsonParser +import me.drownek.plugwright.api.RunDirFile +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* -import org.gradle.jvm.toolchain.JavaLauncher -import org.gradle.api.GradleException +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.Yaml import java.io.File import java.net.URI import java.net.http.HttpClient @@ -14,48 +18,38 @@ import java.net.http.HttpResponse import java.nio.file.Files import java.nio.file.StandardCopyOption import java.time.Duration -import org.yaml.snakeyaml.Yaml -import org.yaml.snakeyaml.DumperOptions -abstract class AbstractPlugwrightTask : AbstractNodeTask() { +/** + * Downloads Paper, stages configured files, patches server/bukkit/spigot configs, and + * installs the plugin under test — everything the local server needs before it can start. + */ +abstract class PaperProvisionTask : DefaultTask() { - @get:Input - abstract val serverJarPath: Property - - @get:Input - abstract val serverDir: Property + @get:OutputDirectory + abstract val runDir: DirectoryProperty @get:Input abstract val minecraftVersion: Property - @get:Input - abstract val jvmArgs: ListProperty - - @get:Input - abstract val acceptEula: Property - @get:Input @get:Optional abstract val pluginJar: Property - @get:Nested - @get:Optional - abstract val javaLauncher: Property - @get:Input abstract val pluginUrls: ListProperty @get:Input @get:Optional - abstract val runDirFiles: ListProperty - - protected fun prepareServerEnvironment(): File { - val serverJar = serverJarPath.get() - val serverDirectory = serverDir.get() - val mcVersion = minecraftVersion.get() - - // Create run directory if it doesn't exist - val runDirectory = File(serverDirectory) + abstract val runDirFiles: ListProperty + + init { + group = "verification" + description = "Downloads Paper and prepares the local test server" + } + + @TaskAction + fun provision() { + val runDirectory = runDir.get().asFile if (!runDirectory.exists() && !runDirectory.mkdirs()) { throw GradleException("Failed to create run directory at ${runDirectory.absolutePath}") } @@ -67,17 +61,19 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { filesToWrite.forEach { entry -> val destination = File(runDirectory, entry.path) destination.parentFile?.mkdirs() + val content = entry.content + val sourceFile = entry.sourceFile when { - entry.content != null -> { - destination.writeText(entry.content, Charsets.UTF_8) + content != null -> { + destination.writeText(content, Charsets.UTF_8) logger.lifecycle(" Wrote: ${entry.path}") } - entry.sourceFile != null -> { - if (!entry.sourceFile.exists()) { - throw GradleException("Staged file source does not exist: ${entry.sourceFile.absolutePath}") + sourceFile != null -> { + if (!sourceFile.exists()) { + throw GradleException("Staged file source does not exist: ${sourceFile.absolutePath}") } - Files.copy(entry.sourceFile.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) - logger.lifecycle(" Copied: ${entry.sourceFile.name} -> ${entry.path}") + Files.copy(sourceFile.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + logger.lifecycle(" Copied: ${sourceFile.name} -> ${entry.path}") } } } @@ -87,8 +83,7 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { val serverProperties = File(runDirectory, "server.properties") if (serverProperties.exists()) { var lines = Files.readAllLines(serverProperties.toPath()).toMutableList() - - // Update or add online-mode=false + val hasOnlineMode = lines.any { it.trim().startsWith("online-mode=") } if (hasOnlineMode) { lines = lines.map { line -> @@ -97,8 +92,7 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { } else { lines.add("online-mode=false") } - - // Update or add connection-throttle=0 (required for E2E tests to prevent "Connection throttled" errors) + val hasConnectionThrottle = lines.any { it.trim().startsWith("connection-throttle=") } if (hasConnectionThrottle) { lines = lines.map { line -> @@ -117,17 +111,14 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { } else { lines.add("spawn-protection=0") } - + Files.write(serverProperties.toPath(), lines) } else { logger.lifecycle("Creating server.properties with online-mode=false, connection-throttle=0 and spawn-protection=0") Files.write(serverProperties.toPath(), listOf("online-mode=false", "connection-throttle=0", "spawn-protection=0")) } - // Configure bukkit.yml settings configureBukkitSettings(runDirectory) - - // Configure spigot.yml settings configureSpigotSettings(runDirectory) // Create plugins directory if it doesn't exist @@ -135,7 +126,7 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { if (!pluginsDir.exists() && !pluginsDir.mkdirs()) { throw GradleException("Failed to create plugins directory at ${pluginsDir.absolutePath}") } - + // Copy the project plugin to the server if (pluginJar.isPresent) { val jarFile = pluginJar.get() @@ -161,55 +152,53 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { } // Download Paper server if needed - val serverJarFile = File(serverJar) + val serverJarFile = File(runDirectory, "server.jar") if (!serverJarFile.exists()) { - logger.lifecycle("Server JAR not found. Downloading Paper server for Minecraft $mcVersion...") - downloadPaperServer(mcVersion, serverJarFile) + logger.lifecycle("Server JAR not found. Downloading Paper server for Minecraft ${minecraftVersion.get()}...") + downloadPaperServer(minecraftVersion.get(), serverJarFile) } - - return runDirectory } - protected fun downloadPaperServer(version: String, destination: File) { + private fun downloadPaperServer(version: String, destination: File) { val httpClient = HttpClient.newBuilder().build() - + try { logger.lifecycle("Fetching latest Paper build for Minecraft $version...") - + val versionInfoUrl = "https://fill.papermc.io/v3/projects/paper/versions/$version" val versionRequest = HttpRequest.newBuilder() .uri(URI.create(versionInfoUrl)) .GET() .build() - + val versionResponse = httpClient.send(versionRequest, HttpResponse.BodyHandlers.ofString()) - + if (versionResponse.statusCode() != 200) { throw GradleException("Failed to fetch Paper version info. Status: ${versionResponse.statusCode()}. Make sure Minecraft version '$version' is valid.") } - + val versionJson = JsonParser.parseString(versionResponse.body()).asJsonObject val buildsArray = versionJson.getAsJsonArray("builds") - + if (buildsArray.size() == 0) { throw GradleException("No builds found for Minecraft version $version") } - + val latestBuild = buildsArray.last().asInt logger.lifecycle("Found latest build: $latestBuild") - + val buildInfoUrl = "https://fill.papermc.io/v3/projects/paper/versions/$version/builds/$latestBuild" val buildRequest = HttpRequest.newBuilder() .uri(URI.create(buildInfoUrl)) .GET() .build() - + val buildResponse = httpClient.send(buildRequest, HttpResponse.BodyHandlers.ofString()) - + if (buildResponse.statusCode() != 200) { throw GradleException("Failed to fetch build info. Status: ${buildResponse.statusCode()}") } - + val buildJson = JsonParser.parseString(buildResponse.body()).asJsonObject val downloadsJson = buildJson.getAsJsonObject("downloads") val downloadEntry = when { @@ -221,7 +210,7 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { downloadsJson.getAsJsonObject(firstKey) } } - + val downloadUrl = if (downloadEntry.has("url")) { downloadEntry.get("url").asString } else { @@ -229,29 +218,29 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { "https://fill.papermc.io/v3/projects/paper/versions/$version/builds/$latestBuild/downloads/$downloadName" } logger.lifecycle("Downloading Paper server from: $downloadUrl") - + val downloadRequest = HttpRequest.newBuilder() .uri(URI.create(downloadUrl)) .GET() .build() - + val downloadResponse = httpClient.send(downloadRequest, HttpResponse.BodyHandlers.ofInputStream()) - + if (downloadResponse.statusCode() != 200) { throw GradleException("Failed to download Paper server. Status: ${downloadResponse.statusCode()}") } - + destination.parentFile?.mkdirs() Files.copy(downloadResponse.body(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) - + logger.lifecycle("Paper server downloaded successfully to: ${destination.absolutePath}") - + } catch (e: Exception) { throw GradleException("Failed to download Paper server: ${e.message}", e) } } - protected fun downloadPlugin(httpClient: HttpClient, url: String, pluginsDirectory: File) { + private fun downloadPlugin(httpClient: HttpClient, url: String, pluginsDirectory: File) { try { val uri = try { URI.create(url) @@ -295,7 +284,7 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { } } - protected fun configureBukkitSettings(serverDirectory: File) { + private fun configureBukkitSettings(serverDirectory: File) { val bukkitYmlFile = File(serverDirectory, "bukkit.yml") try { @@ -325,7 +314,7 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { } } - protected fun configureSpigotSettings(serverDirectory: File) { + private fun configureSpigotSettings(serverDirectory: File) { val spigotYmlFile = File(serverDirectory, "spigot.yml") try { @@ -355,5 +344,4 @@ abstract class AbstractPlugwrightTask : AbstractNodeTask() { logger.warn("Warning: Could not configure spigot.yml: ${e.message}") } } - } diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightCleanTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightCleanTask.kt new file mode 100644 index 0000000..876f476 --- /dev/null +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightCleanTask.kt @@ -0,0 +1,57 @@ +package me.drownek.plugwright.local + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction + +/** Wipes the local run directory for a clean slate, keeping whatever [cleanExcludePatterns] names. */ +abstract class PlugwrightCleanTask : DefaultTask() { + + @get:Internal + abstract val runDir: DirectoryProperty + + @get:Input + abstract val cleanExcludePatterns: ListProperty + + init { + group = "verification" + description = "Wipes the test server data for a clean slate." + } + + @TaskAction + fun clean() { + val dir = runDir.get().asFile + val excludePatterns = cleanExcludePatterns.get() + + if (!dir.exists()) { + logger.lifecycle(" Run directory doesn't exist yet, nothing to clean") + return + } + + logger.lifecycle(" Cleaning run directory (excluding: ${excludePatterns.joinToString(", ")})") + + val allEntries = dir.listFiles() ?: emptyArray() + val deletedFiles = mutableListOf() + val keptFiles = mutableListOf() + + allEntries.forEach { entry -> + val shouldExclude = excludePatterns.any { pattern -> entry.name == pattern } + if (!shouldExclude) { + deletedFiles.add(entry.name) + project.delete(entry) + } else { + keptFiles.add(entry.name) + } + } + + if (deletedFiles.isNotEmpty()) { + logger.lifecycle(" deleted: ${deletedFiles.joinToString(", ")}") + } + if (keptFiles.isNotEmpty()) { + logger.lifecycle(" preserved: ${keptFiles.joinToString(", ")}") + } + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightRunServerTask.kt similarity index 55% rename from gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt rename to gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightRunServerTask.kt index 4c06de1..8a6a5e0 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightRunServerTask.kt @@ -1,9 +1,31 @@ -package me.drownek.plugwright +package me.drownek.plugwright.local -import org.gradle.api.tasks.TaskAction +import me.drownek.plugwright.AbstractNodeTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* +import org.gradle.jvm.toolchain.JavaLauncher import java.io.File -abstract class PlugwrightRunTask : AbstractPlugwrightTask() { +/** Starts the local Paper server interactively, for manual poking outside a test run. */ +abstract class PlugwrightRunServerTask : AbstractNodeTask() { + + @get:InputDirectory + abstract val runDir: DirectoryProperty + + @get:Input + abstract val serverJarPath: Property + + @get:Input + abstract val jvmArgs: ListProperty + + @get:Input + abstract val acceptEula: Property + + @get:Nested + @get:Optional + abstract val javaLauncher: Property init { group = "verification" @@ -12,24 +34,19 @@ abstract class PlugwrightRunTask : AbstractPlugwrightTask() { @TaskAction fun runServer() { - val runDirectory = prepareServerEnvironment() - + val runDirectory = runDir.get().asFile val serverJar = serverJarPath.get() - val serverArgs = jvmArgs.get() - val shouldAcceptEula = acceptEula.get() - - // Build JVM arguments string - val finalJvmArgs = serverArgs.toMutableList() - - // Ensure EULA argument is present if acceptEula is true - if (shouldAcceptEula && !finalJvmArgs.any { it.contains("eula.agree") }) { + val finalJvmArgs = jvmArgs.get().toMutableList() + + if (acceptEula.get() && finalJvmArgs.none { it.contains("eula.agree") }) { finalJvmArgs.add("-Dcom.mojang.eula.agree=true") } - + val javaPath = if (javaLauncher.isPresent) { javaLauncher.get().executablePath.asFile.absolutePath } else { - File(System.getProperty("java.home"), "bin/java" + if (System.getProperty("os.name").lowercase().contains("win")) ".exe" else "").absolutePath + val isWindows = System.getProperty("os.name").lowercase().contains("win") + File(System.getProperty("java.home"), "bin/java" + if (isWindows) ".exe" else "").absolutePath } logger.lifecycle("Starting test server for debugging...") @@ -47,7 +64,7 @@ abstract class PlugwrightRunTask : AbstractPlugwrightTask() { logger.lifecycle("========================================================\n") } } - + logger.lifecycle("Test server stopped") } } diff --git a/gradle-plugin/settings.gradle.kts b/gradle-plugin/settings.gradle.kts index 901056b..664d0d4 100644 --- a/gradle-plugin/settings.gradle.kts +++ b/gradle-plugin/settings.gradle.kts @@ -1,6 +1,9 @@ rootProject.name = "plugwright" -// plugwright-api — stable contract third-party modes compile against -// plugwright-core — the Gradle plugin itself +// plugwright-api — stable contract third-party modes compile against +// plugwright-core — mode-agnostic engine: extension, mode registry, generic tasks +// plugwright-local — built-in "local" mode; also hosts the published plugin id for now, +// until a second built-in mode exists for a dedicated bundle module to combine include(":plugwright-api") include(":plugwright-core") +include(":plugwright-local") From 525d921b7b179da3a9eccef616f3749dd496ee48 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 21:53:34 +0300 Subject: [PATCH 04/15] feat(matrix): add PlugwrightMatrixTask, JSON+JUnit reports, requires/environments filters - plugwrightTest is now the matrix task: runs every environment with includeInMatrix=true through the same RunnerLauncher as plugwrightTest, aggregates a summary, fails the build on any non-allowFailure environment. -Pplugwright.env=a,b narrows it. - matrix { parallel; maxParallel } runs environments concurrently (off by default), each with its own build/reports/plugwright/.log. - Extracted RunnerLauncher (config write + cli.js resolution) out of PlugwrightTestTask so both task types share it. - Runner writes build/reports/plugwright/.json and junit/.xml when the config carries report paths. - test()/opTest() accept an optional {requires, environments} filter; skips (plus the pre-existing tests.exclude and tests.names filters, the latter no longer silently continue) land in results/reports with a reason instead of vanishing. --- .../me/drownek/plugwright/MatrixSpec.kt | 25 +++ .../plugwright/PlugwrightCorePlugin.kt | 53 +++++- .../drownek/plugwright/PlugwrightExtension.kt | 8 + .../plugwright/PlugwrightMatrixTask.kt | 176 ++++++++++++++++++ .../drownek/plugwright/PlugwrightTestTask.kt | 65 +++---- .../me/drownek/plugwright/RunnerLauncher.kt | 74 ++++++++ .../plugwright/TaskRegistrationContextImpl.kt | 15 +- runner-package/lib/config.ts | 9 + runner-package/lib/reporter.ts | 102 +++++++++- runner-package/lib/test-registry.ts | 47 ++++- runner-package/lib/types.ts | 4 + runner-package/runner.ts | 55 +++++- 12 files changed, 565 insertions(+), 68 deletions(-) create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/MatrixSpec.kt create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/MatrixSpec.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/MatrixSpec.kt new file mode 100644 index 0000000..ecf81b5 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/MatrixSpec.kt @@ -0,0 +1,25 @@ +package me.drownek.plugwright + +import org.gradle.api.provider.Property + +/** + * Settings for the `plugwrightTest` matrix run: every environment with `includeInMatrix = true`, + * aggregated into one summary. + */ +abstract class MatrixSpec { + + /** + * Runs environments concurrently instead of one after another. Off by default: two local + * Paper servers double the `-Xmx` footprint, and a shared external IP intensifies + * join-throttle contention and ban risk on a public stand. + */ + abstract val parallel: Property + + /** Upper bound on concurrent environment runs when [parallel] is enabled. */ + abstract val maxParallel: Property + + init { + parallel.convention(false) + maxParallel.convention(2) + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt index 52eb2f6..8cda14d 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -4,7 +4,9 @@ import me.drownek.plugwright.api.ConfigNodeBuilder import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.Task import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider import java.io.File import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -82,13 +84,21 @@ class PlugwrightCorePlugin : Plugin { val projectPluginJarProvider = resolveProjectPluginJar(project, extension) val validationProblems = mutableListOf() + val reportsDir = project.layout.buildDirectory.dir("reports/plugwright") + + // -Pplugwright.env=a,b narrows the matrix; ignored by direct plugwrightTest calls. + val matrixEnvFilter = (project.findProperty("plugwright.env") as? String) + ?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() + + val matrixEntries = mutableListOf() + val matrixPrepareTasks = mutableListOf>() extension.environments.all.forEach { entry -> val envName = entry.spec.name val mode = entry.mode.erased() val ctx = TaskRegistrationContextImpl(project, envName, envName == primaryName, projectPluginJarProvider) - val testTask = ctx.register("Test", PlugwrightTestTask::class.java) { + val testTask = ctx.registerWithoutAlias("Test", PlugwrightTestTask::class.java) { doFirst { if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) } @@ -98,6 +108,8 @@ class PlugwrightCorePlugin : Plugin { modeId.set(mode.id) excludeTests.set(entry.spec.excludeTests) configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$envName.json")) + jsonReportFile.set(reportsDir.map { it.file("$envName.json") }) + junitReportFile.set(reportsDir.map { it.dir("junit").file("$envName.xml") }) nodeVersion.set(extension.nodeVersion) downloadNode.set(extension.downloadNode) nodeInstallDir.set(defaultNodeInstallDir) @@ -112,18 +124,51 @@ class PlugwrightCorePlugin : Plugin { mode.registerTasks(entry.spec, ctx) + val environmentConfigProvider = ctx.environmentConfigProvider + ?: project.provider { ConfigNodeBuilder().apply { mode.serialize(entry.spec, this) }.build() } + testTask.configure { ctx.prepareTaskRef?.let { dependsOn(it) } - environmentConfig.set( - ctx.environmentConfigProvider - ?: project.provider { ConfigNodeBuilder().apply { mode.serialize(entry.spec, this) }.build() } + environmentConfig.set(environmentConfigProvider) + } + + if (entry.spec.includeInMatrix.get() && (matrixEnvFilter == null || envName in matrixEnvFilter)) { + val reportsDirFile = reportsDir.get().asFile + matrixEntries += MatrixEnvironmentInput( + name = envName, + modeId = mode.id, + allowFailure = entry.spec.allowFailure.get(), + testsDir = extension.testsDir.get().asFile, + configFile = project.layout.buildDirectory.file("tmp/plugwright/$envName.json").get().asFile, + jsonReportFile = File(reportsDirFile, "$envName.json"), + junitReportFile = File(File(reportsDirFile, "junit"), "$envName.xml"), + logFile = File(reportsDirFile, "$envName.log"), + excludeTests = entry.spec.excludeTests.get(), + environmentConfig = environmentConfigProvider, ) + ctx.prepareTaskRef?.let { matrixPrepareTasks += it } } } if (validationProblems.isNotEmpty()) { throw GradleException("plugwright configuration problems:\n" + validationProblems.joinToString("\n") { " $it" }) } + + project.tasks.register("plugwrightTest", PlugwrightMatrixTask::class.java) { + doFirst { + if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) + } + dependsOn(plugwrightCompileTests) + matrixPrepareTasks.forEach { dependsOn(it) } + entries = matrixEntries + parallel.set(extension.matrix.parallel) + maxParallel.set(extension.matrix.maxParallel) + nodeVersion.set(extension.nodeVersion) + downloadNode.set(extension.downloadNode) + nodeInstallDir.set(defaultNodeInstallDir) + if (project.hasProperty("testFiles")) testFiles.set(project.property("testFiles") as String) + if (project.hasProperty("testNames")) testNames.set(project.property("testNames") as String) + } } /** The jar of the plugin under test, from `shadowJar` / `reobfJar` / `jar`. Absent when diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt index 3037f52..5413963 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt @@ -50,6 +50,14 @@ abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperti environments.action() } + /** Settings for `plugwrightTest`'s multi-environment matrix run. See [matrix]. */ + val matrix: MatrixSpec = project.objects.newInstance(MatrixSpec::class.java) + + /** Configures the matrix run: `matrix { parallel.set(true); maxParallel.set(2) }`. */ + fun matrix(action: MatrixSpec.() -> Unit) { + matrix.action() + } + // ---- Deprecated flat properties -------------------------------------------------- // Pre-3.0 shape: describes a single implicit "local" environment. Still read whenever // the build script has no environments { } block — see PlugwrightMode.applyLegacyDefaults. diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt new file mode 100644 index 0000000..47d65c7 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt @@ -0,0 +1,176 @@ +package me.drownek.plugwright + +import com.google.gson.JsonParser +import me.drownek.plugwright.api.ConfigNode +import org.gradle.api.GradleException +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.util.concurrent.Callable +import java.util.concurrent.Executors + +/** Everything [PlugwrightMatrixTask] needs to launch one environment, resolved once at + * `afterEvaluate` in [PlugwrightCorePlugin] — the same shape [PlugwrightTestTask] uses, + * minus the Gradle task machinery this task doesn't need per-environment. */ +internal data class MatrixEnvironmentInput( + val name: String, + val modeId: String, + val allowFailure: Boolean, + val testsDir: File, + val configFile: File, + val jsonReportFile: File, + val junitReportFile: File, + val logFile: File, + val excludeTests: List, + val environmentConfig: Provider, +) + +private data class EnvironmentSummary(val total: Int, val passed: Int, val failed: Int, val skipped: Int, val durationMs: Long) + +/** + * `plugwrightTest`: runs every environment with `includeInMatrix = true`, one runner process + * each, and aggregates the result. Does not `dependsOn` the per-environment `plugwrightTest` + * tasks — it launches the same [RunnerLauncher] they use directly, so one environment failing + * doesn't stop the others from reporting. + */ +abstract class PlugwrightMatrixTask : AbstractNodeTask() { + + @get:Internal + internal var entries: List = emptyList() + + @get:Internal + abstract val testFiles: Property + + @get:Internal + abstract val testNames: Property + + @get:Internal + abstract val parallel: Property + + @get:Internal + abstract val maxParallel: Property + + init { + group = "verification" + description = "Runs plugwrightTest for every environment with includeInMatrix = true, and aggregates the result." + outputs.upToDateWhen { false } + } + + @TaskAction + fun runMatrix() { + val active = entries + if (active.isEmpty()) { + logger.lifecycle("plugwrightTest: no environment has includeInMatrix = true, nothing to run.") + return + } + + val nodePaths = resolveNode() + val fileFilters = with(RunnerLauncher) { testFiles.orNull.splitFilter() } + val nameFilters = with(RunnerLauncher) { testNames.orNull.splitFilter() } + + val outcomes = if (parallel.get() && active.size > 1) { + val pool = Executors.newFixedThreadPool(maxParallel.get().coerceAtLeast(1)) + try { + active.map { env -> pool.submit(Callable { runOne(env, nodePaths, fileFilters, nameFilters) }) }.map { it.get() } + } finally { + pool.shutdown() + } + } else { + active.map { runOne(it, nodePaths, fileFilters, nameFilters) } + } + + printSummaryTable(outcomes) + + val hardFailures = outcomes.filter { (env, summary, error) -> + val environmentHadTrouble = error != null || summary == null || summary.failed > 0 + environmentHadTrouble && !env.allowFailure + } + if (hardFailures.isNotEmpty()) { + throw GradleException( + "plugwrightTest matrix failed: ${hardFailures.joinToString(", ") { it.env.name }}. " + + "See per-environment logs under build/reports/plugwright/." + ) + } + } + + private data class Outcome(val env: MatrixEnvironmentInput, val summary: EnvironmentSummary?, val error: Throwable?) + + private fun runOne( + env: MatrixEnvironmentInput, + nodePaths: NodeManager.NodePaths, + fileFilters: List?, + nameFilters: List? + ): Outcome { + logger.lifecycle("plugwrightTest [${env.name}]: starting") + env.logFile.parentFile?.mkdirs() + env.logFile.writeText("") + + return try { + val entry = RunnerLauncher.Entry( + environmentName = env.name, + modeId = env.modeId, + environmentConfig = env.environmentConfig.get(), + testsDir = env.testsDir, + configFile = env.configFile, + testFiles = fileFilters, + testNames = nameFilters, + excludeTests = env.excludeTests, + jsonReportFile = env.jsonReportFile, + junitReportFile = env.junitReportFile, + ) + RunnerLauncher.writeConfig(entry) + val cliJs = RunnerLauncher.resolveCliJs(env.testsDir) + + runCommand( + env.testsDir, nodePaths.node, cliJs.absolutePath, "--config", entry.configFile.absolutePath, + onStdoutLine = { line -> env.logFile.appendText(line + System.lineSeparator()) } + ) + Outcome(env, readSummary(env.jsonReportFile), null) + } catch (t: Throwable) { + logger.error("plugwrightTest [${env.name}]: ${t.message}") + Outcome(env, readSummary(env.jsonReportFile), t) + } + } + + private fun readSummary(file: File): EnvironmentSummary? { + if (!file.exists()) return null + return try { + val root = JsonParser.parseString(file.readText()).asJsonObject + val summary = root.getAsJsonObject("summary") + EnvironmentSummary( + total = summary.get("total").asInt, + passed = summary.get("passed").asInt, + failed = summary.get("failed").asInt, + skipped = summary.get("skipped").asInt, + durationMs = summary.get("durationMs").asLong, + ) + } catch (_: Exception) { + null + } + } + + private fun printSummaryTable(outcomes: List) { + val nameWidth = outcomes.maxOf { it.env.name.length } + logger.lifecycle("") + logger.lifecycle("Environment sumarries:") + for ((env, summary, error) in outcomes) { + val label = env.name.padEnd(nameWidth) + val flag = if (env.allowFailure) " [allowFailure]" else "" + if (summary != null) { + logger.lifecycle(" $label ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped (${formatDuration(summary.durationMs)})$flag") + } else { + logger.lifecycle(" $label ERROR: ${error?.message ?: "no report produced"}$flag") + } + } + logger.lifecycle("") + } + + private fun formatDuration(ms: Long): String { + val totalSeconds = ms / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s" + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt index 8ab44ae..6939cc4 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt @@ -1,8 +1,6 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNode -import me.drownek.plugwright.api.ConfigNodeBuilder -import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty @@ -56,6 +54,14 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { @get:OutputFile abstract val configFile: RegularFileProperty + /** Where the runner writes its JSON report (`build/reports/plugwright/.json`). */ + @get:OutputFile + abstract val jsonReportFile: RegularFileProperty + + /** Where the runner writes its JUnit XML report (`build/reports/plugwright/junit/.xml`). */ + @get:OutputFile + abstract val junitReportFile: RegularFileProperty + init { group = "verification" description = "Run E2E tests for Paper plugin" @@ -83,52 +89,25 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { logger.lifecycle("Running E2E tests for environment '${environmentName.get()}'...") val configDestination = configFile.get().asFile - writeRunnerConfig(configDestination, userTestsDirectory) + val entry = RunnerLauncher.Entry( + environmentName = environmentName.get(), + modeId = modeId.get(), + environmentConfig = environmentConfig.get(), + testsDir = userTestsDirectory, + configFile = configDestination, + testFiles = with(RunnerLauncher) { testFiles.orNull.splitFilter() }, + testNames = with(RunnerLauncher) { testNames.orNull.splitFilter() }, + excludeTests = if (excludeTests.isPresent) excludeTests.get() else emptyList(), + jsonReportFile = jsonReportFile.get().asFile, + junitReportFile = junitReportFile.get().asFile, + ) + RunnerLauncher.writeConfig(entry) logger.lifecycle("Runner config: ${configDestination.absolutePath}") - val defaultCliJs = File(userTestsDirectory, "node_modules/@drownek/plugwright/dist/cli.js") - val cliJsFile = sequenceOf( - // Canonical path resolves npm symlink bugs on CI - defaultCliJs.canonicalFile, - defaultCliJs, - // Dev-environment fallback when running inside this repository - File(userTestsDirectory, "../../../../runner-package/dist/cli.js") - ).firstOrNull { it.exists() } - ?: throw GradleException( - "plugwright cli.js not found at ${defaultCliJs.absolutePath}. " + - "Did 'npm install' succeed in ${userTestsDirectory.absolutePath}?" - ) + val cliJsFile = RunnerLauncher.resolveCliJs(userTestsDirectory) runCommand(userTestsDirectory, nodePaths.node, cliJsFile.absolutePath, "--config", configDestination.absolutePath) logger.lifecycle("E2E tests completed successfully") } - - private fun writeRunnerConfig(destination: File, testsDirectory: File) { - val fileFilters = testFiles.orNull.splitFilter() - val nameFilters = testNames.orNull.splitFilter() - val excludeList = if (excludeTests.isPresent) excludeTests.get() else emptyList() - - val root = ConfigNodeBuilder().apply { - put("version", RunnerConfigWriter.CONFIG_VERSION) - obj("environment") { - put("name", environmentName.get()) - put("mode", modeId.get()) - put("config", environmentConfig.get()) - } - obj("tests") { - put("dir", testsDirectory.absolutePath) - if (fileFilters != null) putStrings("include", fileFilters) else putNull("include") - if (nameFilters != null) putStrings("names", nameFilters) else putNull("names") - if (excludeList.isNotEmpty()) putStrings("exclude", excludeList) else putNull("exclude") - // null means "runner default", which TEST_TIMEOUT can still override. - putNull("timeoutMs") - } - }.build() - - RunnerConfigWriter.write(destination, root) - } - - private fun String?.splitFilter(): List? = - this?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.takeIf { it.isNotEmpty() } } diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt new file mode 100644 index 0000000..6f2a69a --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt @@ -0,0 +1,74 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.ConfigNodeBuilder +import org.gradle.api.GradleException +import java.io.File + +/** + * Config-writing and `cli.js` resolution shared by [PlugwrightTestTask] (one environment) and + * [PlugwrightMatrixTask] (many, in one process each). Process execution itself stays on + * [AbstractNodeTask] — both task types extend it and already have `runCommand`/`resolveNode`. + */ +object RunnerLauncher { + + /** Everything needed to write one environment's `config.json` and locate its `cli.js`. */ + data class Entry( + val environmentName: String, + val modeId: String, + val environmentConfig: ConfigNode, + val testsDir: File, + val configFile: File, + val testFiles: List?, + val testNames: List?, + val excludeTests: List, + val jsonReportFile: File, + val junitReportFile: File, + ) + + fun writeConfig(entry: Entry) { + val root = ConfigNodeBuilder().apply { + put("version", RunnerConfigWriter.CONFIG_VERSION) + obj("environment") { + put("name", entry.environmentName) + put("mode", entry.modeId) + put("config", entry.environmentConfig) + } + obj("tests") { + put("dir", entry.testsDir.absolutePath) + if (entry.testFiles != null) putStrings("include", entry.testFiles) else putNull("include") + if (entry.testNames != null) putStrings("names", entry.testNames) else putNull("names") + if (entry.excludeTests.isNotEmpty()) putStrings("exclude", entry.excludeTests) else putNull("exclude") + // null means "runner default", which TEST_TIMEOUT can still override. + putNull("timeoutMs") + } + obj("reports") { + put("json", entry.jsonReportFile.absolutePath) + put("junit", entry.junitReportFile.absolutePath) + } + }.build() + + RunnerConfigWriter.write(entry.configFile, root) + } + + /** Resolves `cli.js` relative to a test project's `node_modules`, falling back to the + * in-repo build for `example_plugin`-style development setups. */ + fun resolveCliJs(testsDir: File): File { + val defaultCliJs = File(testsDir, "node_modules/@drownek/plugwright/dist/cli.js") + return sequenceOf( + // Canonical path resolves npm symlink bugs on CI + defaultCliJs.canonicalFile, + defaultCliJs, + // Dev-environment fallback when running inside this repository + File(testsDir, "../../../../runner-package/dist/cli.js") + ).firstOrNull { it.exists() } + ?: throw GradleException( + "plugwright cli.js not found at ${defaultCliJs.absolutePath}. " + + "Did 'npm install' succeed in ${testsDir.absolutePath}?" + ) + } + + /** Splits a comma-separated `-P` property value the same way for every task. */ + fun String?.splitFilter(): List? = + this?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.takeIf { it.isNotEmpty() } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt index 0622c4a..0bc520d 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt @@ -30,12 +30,23 @@ internal class TaskRegistrationContextImpl( private val aliasedSuffixes = mutableSetOf() - override fun register(suffix: String, type: Class, action: T.() -> Unit): TaskProvider { + override fun register(suffix: String, type: Class, action: T.() -> Unit): TaskProvider = + registerInternal(suffix, type, aliasBare = true, action) + + /** + * Same as [register], but never creates the bare `plugwright` alias. Used by core + * itself for the "Test" suffix: `plugwrightTest` is claimed by [PlugwrightMatrixTask] + * instead, which runs the matrix rather than aliasing to one arbitrary environment. + */ + fun registerWithoutAlias(suffix: String, type: Class, action: T.() -> Unit): TaskProvider = + registerInternal(suffix, type, aliasBare = false, action) + + private fun registerInternal(suffix: String, type: Class, aliasBare: Boolean, action: T.() -> Unit): TaskProvider { val envSuffix = environmentName.replaceFirstChar { it.uppercaseChar() } val taskName = "plugwright$suffix$envSuffix" val provider = project.tasks.register(taskName, type) { action() } - if (isPrimary && aliasedSuffixes.add(suffix)) { + if (aliasBare && isPrimary && aliasedSuffixes.add(suffix)) { val aliasName = "plugwright$suffix" if (project.tasks.findByName(aliasName) == null) { project.tasks.register(aliasName) { diff --git a/runner-package/lib/config.ts b/runner-package/lib/config.ts index 88f2cd7..b082697 100644 --- a/runner-package/lib/config.ts +++ b/runner-package/lib/config.ts @@ -44,10 +44,18 @@ export interface TestsConfig { timeoutMs?: number | null; } +export interface ReportsConfig { + /** Path to write the machine-readable JSON report to. Omitted means "don't write one". */ + json?: string | null; + /** Path to write the JUnit XML report to. Omitted means "don't write one". */ + junit?: string | null; +} + export interface RunnerConfig { version: number; environment: EnvironmentConfig; tests: TestsConfig; + reports?: ReportsConfig | null; } /** Settings of the built-in `local` mode, which spawns its own Paper server. */ @@ -110,6 +118,7 @@ function readConfigFile(path: string): RunnerConfig { } parsed.tests = parsed.tests ?? {}; + parsed.reports = parsed.reports ?? {}; return parsed; } diff --git a/runner-package/lib/reporter.ts b/runner-package/lib/reporter.ts index 44edb65..5bb01ae 100644 --- a/runner-package/lib/reporter.ts +++ b/runner-package/lib/reporter.ts @@ -1,3 +1,5 @@ +import { mkdirSync, writeFileSync } from 'fs'; +import { dirname } from 'path'; import pc from 'picocolors'; import { extractSpecLocation } from './stack-trace.js'; import type { TestResult } from './types.js'; @@ -8,25 +10,33 @@ export function formatDuration(ms: number): string { return `${seconds.toFixed(1)}s`; } +function statusOf(result: TestResult): 'PASS' | 'FAIL' | 'SKIP' { + if (result.skipped) return 'SKIP'; + return result.passed ? 'PASS' : 'FAIL'; +} + export function printTestSummary(testResults: TestResult[]): number { console.log(`\n${pc.bold("=".repeat(40))}`); console.log(pc.bold(' Test Summary')); console.log(pc.bold("=".repeat(40))); - const passed = testResults.filter(r => r.passed); - const failed = testResults.filter(r => !r.passed); + const skipped = testResults.filter(r => r.skipped); + const executed = testResults.filter(r => !r.skipped); + const passed = executed.filter(r => r.passed); + const failed = executed.filter(r => !r.passed); const totalDuration = testResults.reduce((sum, r) => sum + r.durationMs, 0); console.log(` Total: ${pc.bold(String(testResults.length))}`); console.log(` Passed: ${pc.green(pc.bold(String(passed.length)))}`); console.log(` Failed: ${failed.length > 0 ? pc.red(pc.bold(String(failed.length))) : pc.dim(String(failed.length))}`); + console.log(` Skipped: ${skipped.length > 0 ? pc.yellow(pc.bold(String(skipped.length))) : pc.dim(String(skipped.length))}`); console.log(` Duration: ${pc.dim(formatDuration(totalDuration))}`); const statusCol = 'Status'; const testCol = 'Test'; const durationCol = 'Duration'; - const statusWidth = Math.max(statusCol.length, ...(testResults.map(r => r.passed ? 'PASS' : 'FAIL').map(s => s.length))); + const statusWidth = Math.max(statusCol.length, ...testResults.map(r => statusOf(r).length)); const durationWidth = Math.max(durationCol.length, ...testResults.map(r => formatDuration(r.durationMs).length)); const testWidth = Math.max(testCol.length, ...testResults.map(r => r.testName.length)); @@ -37,11 +47,13 @@ export function printTestSummary(testResults: TestResult[]): number { console.log(separator); for (const result of testResults) { - const status = result.passed ? 'PASS' : 'FAIL'; + const status = statusOf(result); const statusPadded = status.padEnd(statusWidth); - const coloredStatus = result.passed + const coloredStatus = status === 'PASS' ? pc.green(pc.bold(statusPadded)) - : pc.red(pc.bold(statusPadded)); + : status === 'SKIP' + ? pc.yellow(pc.bold(statusPadded)) + : pc.red(pc.bold(statusPadded)); const duration = formatDuration(result.durationMs); console.log(` ${coloredStatus} ${result.testName.padEnd(testWidth)} ${pc.dim(duration.padStart(durationWidth))}`); } @@ -49,6 +61,14 @@ export function printTestSummary(testResults: TestResult[]): number { console.log(separator); console.log(` ${''.padEnd(statusWidth)} ${pc.bold('Total'.padEnd(testWidth))} ${pc.dim(formatDuration(totalDuration).padStart(durationWidth))}`); + if (skipped.length > 0) { + console.log(`\n${pc.yellow(pc.bold('Skipped Tests:'))}\n`); + for (const result of skipped) { + console.log(` ${pc.yellow(`- ${result.testName}`)}`); + if (result.skipReason) console.log(` ${pc.dim(result.skipReason)}`); + } + } + if (failed.length > 0) { console.log(`\n${pc.red(pc.bold('Failed Tests:'))}\n`); @@ -71,4 +91,74 @@ export function printTestSummary(testResults: TestResult[]): number { console.log(`\n${pc.green(pc.bold('All tests passed!'))}`); return 0; } +} + +function xmlEscape(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Writes the machine-readable report a matrix run aggregates across environments. */ +export function writeJsonReport(path: string, environmentName: string, testResults: TestResult[]): void { + const skipped = testResults.filter(r => r.skipped); + const executed = testResults.filter(r => !r.skipped); + const passed = executed.filter(r => r.passed); + const failed = executed.filter(r => !r.passed); + const durationMs = testResults.reduce((sum, r) => sum + r.durationMs, 0); + + const report = { + environment: environmentName, + summary: { + total: testResults.length, + passed: passed.length, + failed: failed.length, + skipped: skipped.length, + durationMs, + }, + tests: testResults.map(r => ({ + file: r.file, + name: r.testName, + status: statusOf(r).toLowerCase(), + durationMs: r.durationMs, + error: r.error ? r.error.message : null, + skipReason: r.skipReason ?? null, + })), + }; + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(report, null, 2), 'utf8'); +} + +/** Writes a JUnit XML report: `testsuite name="plugwright."`, one `testcase` per test, + * spec file as `classname`, full `describe`-chain name as `name`. See modes-and-plugins §5.3. */ +export function writeJUnitReport(path: string, environmentName: string, testResults: TestResult[]): void { + const skipped = testResults.filter(r => r.skipped).length; + const failed = testResults.filter(r => !r.skipped && !r.passed).length; + const totalTimeSeconds = (testResults.reduce((sum, r) => sum + r.durationMs, 0) / 1000).toFixed(3); + + const cases = testResults.map(r => { + const timeSeconds = (r.durationMs / 1000).toFixed(3); + const classname = xmlEscape(r.file); + const name = xmlEscape(r.testName); + const inner = r.skipped + ? `\n \n ` + : !r.passed + ? `\n ${xmlEscape(r.error?.stack ?? r.error?.message ?? '')}\n ` + : ''; + return ` ${inner}`; + }); + + const xml = [ + '', + ``, + ...cases, + '', + '', + ].join('\n'); + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, xml, 'utf8'); } \ No newline at end of file diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index ac9a259..2ff4e7e 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -1,6 +1,20 @@ import type { TestContext } from './types.js'; type Hook = (context: TestContext) => Promise; +type TestFn = (context: TestContext) => Promise; + +/** + * Filters usable from a spec file, independent of environment names. + * + * `requires` checks capability flags on `env.capabilities` (e.g. `'console'`, `'op'`) — + * a value of `false` or `'none'` fails the check. `environments` checks the running + * environment's name directly, for cases that aren't about capability but about the + * content of a specific stand. See modes-and-plugins §5.4. + */ +export interface TestOptions { + requires?: string[]; + environments?: string[]; +} interface DescribeScope { label: string; @@ -10,13 +24,15 @@ interface DescribeScope { interface TestCase { name: string; - fn: (context: TestContext) => Promise; + fn: TestFn; + requires: string[]; + environments: string[] | null; } export const testRegistry: TestCase[] = []; export const scopeStack: DescribeScope[] = [{ label: '', beforeHooks: [], afterHooks: [] }]; -export function test(name: string, fn: (context: TestContext) => Promise): void { +function registerTest(name: string, options: TestOptions, fn: TestFn): void { const labels = scopeStack.map(s => s.label).filter(l => l); const fullName = [...labels, name].join(' > '); @@ -43,11 +59,30 @@ export function test(name: string, fn: (context: TestContext) => Promise): if (testError) throw testError; }; - testRegistry.push({ name: fullName, fn: wrappedFn }); + testRegistry.push({ + name: fullName, + fn: wrappedFn, + requires: options.requires ?? [], + environments: options.environments ?? null, + }); } -export function opTest(name: string, fn: (context: TestContext) => Promise): void { - test(name, async (context: TestContext) => { +export function test(name: string, fn: TestFn): void; +export function test(name: string, options: TestOptions, fn: TestFn): void; +export function test(name: string, fnOrOptions: TestFn | TestOptions, maybeFn?: TestFn): void { + if (typeof fnOrOptions === 'function') { + registerTest(name, {}, fnOrOptions); + } else { + registerTest(name, fnOrOptions, maybeFn!); + } +} + +export function opTest(name: string, fn: TestFn): void; +export function opTest(name: string, options: TestOptions, fn: TestFn): void; +export function opTest(name: string, fnOrOptions: TestFn | TestOptions, maybeFn?: TestFn): void { + const options = typeof fnOrOptions === 'function' ? {} : fnOrOptions; + const fn = typeof fnOrOptions === 'function' ? fnOrOptions : maybeFn!; + registerTest(name, options, async (context: TestContext) => { await context.player.makeOp(); await fn(context); }); @@ -68,4 +103,4 @@ export function beforeEach(hook: Hook): void { export function afterEach(hook: Hook): void { scopeStack[scopeStack.length - 1].afterHooks.push(hook); -} \ No newline at end of file +} diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 2c70f5b..28c07ab 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -14,4 +14,8 @@ export interface TestResult { passed: boolean; durationMs: number; error?: Error; + /** Set when the test was never run — a filter excluded it rather than it failing. */ + skipped?: boolean; + /** Human-readable reason shown in reports; required whenever `skipped` is true. */ + skipReason?: string; } \ No newline at end of file diff --git a/runner-package/runner.ts b/runner-package/runner.ts index c066d05..90d1c23 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -10,7 +10,7 @@ import { ServerWrapper } from './lib/server.js'; import { testRegistry, scopeStack } from './lib/test-registry.js'; import { Session } from './lib/session.js'; import { LocalEnvironment } from './lib/environments/local.js'; -import { formatDuration, printTestSummary } from './lib/reporter.js'; +import { formatDuration, printTestSummary, writeJsonReport, writeJUnitReport } from './lib/reporter.js'; import { loadRunnerConfig } from './lib/config.js'; import type { Environment } from './lib/environment.js'; import type { EnvironmentConfig, LocalEnvironmentConfig, RunnerConfig } from './lib/config.js'; @@ -24,6 +24,7 @@ export { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator }; export { PlayerWrapper } from './lib/player.js'; export { ServerWrapper } from './lib/server.js'; export { test, opTest, describe, beforeEach, afterEach } from './lib/test-registry.js'; +export type { TestOptions } from './lib/test-registry.js'; export { expect } from './lib/matchers.js'; export { loadRunnerConfig, resolveSecret, isSecretRef } from './lib/config.js'; export type { RunnerConfig, EnvironmentConfig, TestsConfig, LocalEnvironmentConfig, SecretRef } from './lib/config.js'; @@ -40,6 +41,16 @@ function resolveEnvironment(cfg: EnvironmentConfig): Environment { return new LocalEnvironment(cfg.config as unknown as LocalEnvironmentConfig); } +/** Capability keys from `testCase.requires` that `env` does not actually satisfy. A + * value of `false`, `'none'`, or an absent key all count as unmet. */ +function missingCapabilities(env: Environment, required: string[]): string[] { + const capabilities = env.capabilities as unknown as Record; + return required.filter(key => { + const value = capabilities[key]; + return value === false || value === 'none' || value === undefined; + }); +} + async function findSpecFiles(dir: string): Promise { const results: string[] = []; for (const entry of await readdir(dir, { withFileTypes: true })) { @@ -55,6 +66,7 @@ async function findSpecFiles(dir: string): Promise { export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): Promise { const testFileFilters = config.tests.include ?? null; const testNameFilters = config.tests.names ?? null; + const testNameExcludes = config.tests.exclude ?? null; const testResults: TestResult[] = []; const env = resolveEnvironment(config.environment); @@ -84,6 +96,27 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): console.log(`${pc.bold(`Found ${testFiles.length} test file(s)${testFileFilters ? ` matching filter: ${testFileFilters.join(',')}` : ''}`)}\n`); + /** Why a test should not run, or null to run it. Checked in order: name exclude, + * name filter, declared `environments`, declared `requires`. A skip always lands + * in the report with its reason — a silent skip on an external stand would look + * like coverage that isn't really there. */ + function skipReasonFor(testCase: (typeof testRegistry)[number]): string | null { + if (testNameExcludes?.some(pattern => testCase.name.includes(pattern))) { + return `excluded by tests.exclude (matches "${testNameExcludes.join(',')}")`; + } + if (testNameFilters && !testNameFilters.some(pattern => testCase.name.includes(pattern))) { + return `filtered out by tests.names (${testNameFilters.join(',')})`; + } + if (testCase.environments && !testCase.environments.includes(config.environment.name)) { + return `requires environment in [${testCase.environments.join(', ')}], running "${config.environment.name}"`; + } + const missing = missingCapabilities(env, testCase.requires); + if (missing.length > 0) { + return `requires capability [${missing.join(', ')}], unavailable on "${config.environment.name}"`; + } + return null; + } + for (const file of testFiles) { console.log(`\n${pc.blue(pc.bold(`Running tests from: ${file}`))}`); @@ -93,12 +126,11 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): await import(pathToFileURL(file).href); for (const testCase of testRegistry) { - if (testNameFilters) { - const matches = testNameFilters.some(pattern => testCase.name.includes(pattern)); - if (!matches) { - console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (filter: ${testNameFilters.join(',')})`)); - continue; - } + const skipReason = skipReasonFor(testCase); + if (skipReason) { + console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (${skipReason})`)); + testResults.push({ file, testName: testCase.name, passed: true, durationMs: 0, skipped: true, skipReason }); + continue; } console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); @@ -170,6 +202,15 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): await session.disconnectAllBots(); await env.teardown(); + if (config.reports?.json) { + writeJsonReport(config.reports.json, config.environment.name, testResults); + console.log(pc.dim(`JSON report: ${config.reports.json}`)); + } + if (config.reports?.junit) { + writeJUnitReport(config.reports.junit, config.environment.name, testResults); + console.log(pc.dim(`JUnit report: ${config.reports.junit}`)); + } + exitCode = printTestSummary(testResults); setTimeout(() => { From 91412100597601e5276c5bb53d8519430d22ce8d Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 22:04:30 +0300 Subject: [PATCH 05/15] feat(runner): add plugin host with hooks, fixtures, matchers, inherited tests, and cleanup journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlugwrightPlugin contract (setup/onPlayerCreate/beforeEach/afterEach/extendContext/matchers/tests/cleanup/teardown) loaded by PluginHost from config.json's new plugins[] list. Hook order matches modes-and-plugins §6.6: plugin.beforeEach -> spec beforeEach -> body -> cleanup finalizers -> spec afterEach -> plugin.afterEach. - lib/plugin.ts: PlugwrightPlugin, definePlugin, SessionContext, CleanupContext, PluginTestRef - lib/plugin-host.ts: loads/orders plugins, merges matchers into RunnerMatchers, runs hooks - lib/account.ts: Account type + syntheticAccount() placeholder until AccountPool (phase 6) - lib/journal.ts: CleanupJournal, typed-record crash journal for TestContext.cleanup - lib/test-runner.ts: runTestCase() extracted from runner.ts, sequences all the above - test-registry.ts: TestCase now exposes raw beforeHooks/afterHooks instead of a merged fn, so plugin hooks can be interleaved with spec hooks by the caller - player.ts: join()/rejoin() fire session.onPlayerCreate on every connection - runner.ts: wires PluginHost in, runs plugin preflight tests before user specs (abort on failure) and suite tests alongside them, tagged with plugin name in TestResult/reports --- runner-package/lib/account.ts | 20 ++++ runner-package/lib/config.ts | 14 +++ runner-package/lib/journal.ts | 65 +++++++++++ runner-package/lib/player.ts | 11 ++ runner-package/lib/plugin-host.ts | 136 +++++++++++++++++++++++ runner-package/lib/plugin.ts | 62 +++++++++++ runner-package/lib/reporter.ts | 6 +- runner-package/lib/session.ts | 12 +- runner-package/lib/test-registry.ts | 47 ++++---- runner-package/lib/test-runner.ts | 125 +++++++++++++++++++++ runner-package/lib/types.ts | 5 + runner-package/runner.ts | 166 ++++++++++++---------------- 12 files changed, 545 insertions(+), 124 deletions(-) create mode 100644 runner-package/lib/account.ts create mode 100644 runner-package/lib/journal.ts create mode 100644 runner-package/lib/plugin-host.ts create mode 100644 runner-package/lib/plugin.ts create mode 100644 runner-package/lib/test-runner.ts diff --git a/runner-package/lib/account.ts b/runner-package/lib/account.ts new file mode 100644 index 0000000..b86e885 --- /dev/null +++ b/runner-package/lib/account.ts @@ -0,0 +1,20 @@ +/** + * A bot's login identity as seen by an environment and its auth plugin. `justCreated` is + * the key field for authentication plugins: a fresh account needs to register, an existing + * one needs to log in. + */ +export interface Account { + username: string; + password?: string; + auth: 'offline' | 'microsoft'; + justCreated: boolean; +} + +/** + * Stand-in used until a proper `AccountPool` (a later phase) exists. `local` bots are + * always fresh, unauthenticated offline-mode connections, so this is accurate today — it + * just isn't pluggable to other sources yet. + */ +export function syntheticAccount(username: string): Account { + return { username, auth: 'offline', justCreated: true }; +} diff --git a/runner-package/lib/config.ts b/runner-package/lib/config.ts index b082697..d129e84 100644 --- a/runner-package/lib/config.ts +++ b/runner-package/lib/config.ts @@ -51,11 +51,24 @@ export interface ReportsConfig { junit?: string | null; } +export interface PluginConfig { + /** npm package name, or a resolvable path to a local plugin module. The default export + * must implement `PlugwrightPlugin`. */ + specifier: string; + options?: Record; + /** Set false to load the plugin's hooks/matchers without pulling in its `tests`. */ + inheritTests?: boolean; +} + export interface RunnerConfig { version: number; environment: EnvironmentConfig; tests: TestsConfig; reports?: ReportsConfig | null; + plugins?: PluginConfig[] | null; + /** Crash-recovery journal path for `Session.journal`. Omitted disables on-disk + * persistence — journal entries only survive within the process. */ + journal?: string | null; } /** Settings of the built-in `local` mode, which spawns its own Paper server. */ @@ -119,6 +132,7 @@ function readConfigFile(path: string): RunnerConfig { parsed.tests = parsed.tests ?? {}; parsed.reports = parsed.reports ?? {}; + parsed.plugins = parsed.plugins ?? []; return parsed; } diff --git a/runner-package/lib/journal.ts b/runner-package/lib/journal.ts new file mode 100644 index 0000000..845c73b --- /dev/null +++ b/runner-package/lib/journal.ts @@ -0,0 +1,65 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; +import { dirname } from 'path'; + +/** + * A crash-survivable record of one cleanup obligation. Typed and interpreted by a plugin's + * `cleanup({ scope: 'manual' })` handler — never a raw command string. A journal that + * replayed arbitrary strings would be a way to run arbitrary commands against a live server + * the next time someone runs `plugwrightClean`. + */ +export interface JournalEntry { + kind: string; + [key: string]: unknown; +} + +/** + * Append-only log of pending cleanup obligations, for the case where a test's `finally` + * never runs (SIGKILL, crashed process). `record()`/`forget()` bracket a normal, LIFO + * `TestContext.cleanup()` finalizer; whatever's still in the file when the process dies + * survived a crash and is replayed by the next run, or by a manual `plugwrightClean`. + * + * A plain JS closure can't be serialized to a file, so only entries explicitly journaled as + * a typed record (not a function) survive a crash — this is a lower-level, opt-in companion + * to `TestContext.cleanup()`, not a transparent upgrade of it. + */ +export class CleanupJournal { + private readonly path: string | null; + private readonly pending = new Map(); + + constructor(path: string | null) { + this.path = path; + if (!this.path || !existsSync(this.path)) return; + + for (const line of readFileSync(this.path, 'utf8').split('\n')) { + if (!line.trim()) continue; + try { + const { id, entry } = JSON.parse(line) as { id: string; entry: JournalEntry | null }; + if (entry === null) this.pending.delete(id); + else this.pending.set(id, entry); + } catch { + // A line torn mid-write by a crash. Skip it rather than fail the whole run. + } + } + } + + /** Entries a prior run recorded but never forgot — leftovers from a crash. */ + outstanding(): JournalEntry[] { + return [...this.pending.values()]; + } + + record(id: string, entry: JournalEntry): void { + this.pending.set(id, entry); + this._append({ id, entry }); + } + + forget(id: string): void { + if (!this.pending.delete(id)) return; + this._append({ id, entry: null }); + } + + private _append(line: { id: string; entry: JournalEntry | null }): void { + if (!this.path) return; + mkdirSync(dirname(this.path), { recursive: true }); + appendFileSync(this.path, JSON.stringify(line) + '\n', 'utf8'); + } +} diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 994d939..859bca3 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -4,6 +4,7 @@ import { ServerWrapper } from './server.js'; import type { Session } from './session.js'; import { MessageBuffer } from './session.js'; import type { BotConnectionOptions } from './environment.js'; +import type { Account } from './account.js'; import { poll } from './utils.js'; import { randomUUID } from 'node:crypto'; import pc from 'picocolors'; @@ -44,6 +45,7 @@ export class PlayerWrapper { private _botOptions?: BotConnectionOptions; private _spawnPromise: Promise | null = null; private _listenersBot: Bot | null = null; + private account?: Account; constructor(bot: Bot, session: Session) { this.bot = bot; @@ -116,6 +118,15 @@ export class PlayerWrapper { this._spawnPromise = null; this._registerPersistentListeners(); + + if (this.account) { + await this.session.onPlayerCreate?.(this, { account: this.account, env: this.session.env }); + } + } + + /** @internal */ + _setAccount(account: Account): void { + this.account = account; } private _registerPersistentListeners(): void { diff --git a/runner-package/lib/plugin-host.ts b/runner-package/lib/plugin-host.ts new file mode 100644 index 0000000..7499131 --- /dev/null +++ b/runner-package/lib/plugin-host.ts @@ -0,0 +1,136 @@ +import pc from 'picocolors'; +import { RunnerMatchers } from './matchers.js'; +import { PLUGIN_API_VERSION } from './plugin.js'; +import type { PlugwrightPlugin, PluginTestRef } from './plugin.js'; +import type { Session } from './session.js'; +import type { PlayerWrapper } from './player.js'; +import type { Environment } from './environment.js'; +import type { Account } from './account.js'; +import type { TestContext } from './types.js'; +import type { PluginConfig } from './config.js'; + +interface LoadedPlugin { + plugin: PlugwrightPlugin; + options: Record; + inheritTests: boolean; +} + +/** + * Owns every loaded `PlugwrightPlugin`: hooks fired around each test, matchers merged into + * `RunnerMatchers`, fixtures merged into `TestContext`, and inherited test files. One + * instance per session. + */ +export class PluginHost { + private readonly plugins: LoadedPlugin[] = []; + + async load(configs: PluginConfig[]): Promise { + for (const cfg of configs) { + let mod: any; + try { + mod = await import(cfg.specifier); + } catch (error) { + throw new Error(`Failed to load plugin "${cfg.specifier}": ${(error as Error).message}`); + } + + const plugin = (mod.default ?? mod) as PlugwrightPlugin; + if (!plugin || typeof plugin.name !== 'string') { + throw new Error(`Plugin "${cfg.specifier}" has no default export implementing PlugwrightPlugin (missing "name")`); + } + if (plugin.apiVersion !== undefined && plugin.apiVersion > PLUGIN_API_VERSION) { + throw new Error( + `Plugin "${plugin.name}" was built against plugin API v${plugin.apiVersion}, ` + + `this runner supports up to v${PLUGIN_API_VERSION}. Update @drownek/plugwright.` + ); + } + + this.plugins.push({ plugin, options: cfg.options ?? {}, inheritTests: cfg.inheritTests ?? true }); + console.log(pc.dim(`[plugin] loaded "${plugin.name}" (${cfg.specifier})`)); + } + } + + get names(): string[] { + return this.plugins.map(p => p.plugin.name); + } + + /** Merges declared matchers into the shared `RunnerMatchers` prototype. Must run before + * the first spec file is imported — `expect(x).foo()` looks the matcher up on the + * prototype at call time, not at registration time. */ + registerMatchers(): void { + for (const { plugin } of this.plugins) { + for (const [matcherName, fn] of Object.entries(plugin.matchers ?? {})) { + (RunnerMatchers.prototype as any)[matcherName] = fn; + } + } + } + + async setup(session: Session): Promise { + for (const { plugin, options } of this.plugins) { + await plugin.setup?.({ session, env: session.env, options }); + } + } + + async onPlayerCreate(player: PlayerWrapper, ctx: { account: Account; env: Environment }): Promise { + for (const { plugin } of this.plugins) { + await plugin.onPlayerCreate?.(player, ctx); + } + } + + async beforeEach(ctx: TestContext): Promise { + for (const { plugin } of this.plugins) { + await plugin.beforeEach?.(ctx); + } + } + + /** Runs in reverse plugin order, mirroring the LIFO shape of afterEach hooks elsewhere. + * Errors are logged, not thrown — a plugin's own afterEach hiccup shouldn't flip an + * otherwise-passing test's result. */ + async afterEach(ctx: TestContext): Promise { + for (const { plugin } of [...this.plugins].reverse()) { + try { + await plugin.afterEach?.(ctx); + } catch (error) { + console.error(pc.red(`[plugin ${plugin.name}] afterEach error: ${(error as Error).message}`)); + } + } + } + + extendContext(ctx: TestContext): void { + for (const { plugin } of this.plugins) { + const extra = plugin.extendContext?.(ctx); + if (extra) Object.assign(ctx, extra); + } + } + + /** Inherited test files for the given mode, across every plugin with `inheritTests` + * enabled. `findSpecFiles` never sees these — it skips `node_modules` — so this is the + * only way a plugin's own tests run. */ + testFiles(mode: PluginTestRef['mode']): { file: string; pluginName: string }[] { + return this.plugins + .filter(p => p.inheritTests) + .flatMap(({ plugin }) => + (plugin.tests ?? []) + .filter(t => t.mode === mode) + .map(t => ({ file: t.file, pluginName: plugin.name })) + ); + } + + async runCleanup(session: Session, scope: 'session' | 'manual'): Promise { + for (const { plugin } of [...this.plugins].reverse()) { + try { + await plugin.cleanup?.({ session, scope }); + } catch (error) { + console.error(pc.red(`[plugin ${plugin.name}] cleanup error: ${(error as Error).message}`)); + } + } + } + + async teardown(): Promise { + for (const { plugin } of [...this.plugins].reverse()) { + try { + await plugin.teardown?.(); + } catch (error) { + console.error(pc.red(`[plugin ${plugin.name}] teardown error: ${(error as Error).message}`)); + } + } + } +} diff --git a/runner-package/lib/plugin.ts b/runner-package/lib/plugin.ts new file mode 100644 index 0000000..352666c --- /dev/null +++ b/runner-package/lib/plugin.ts @@ -0,0 +1,62 @@ +import type { Session } from './session.js'; +import type { Environment } from './environment.js'; +import type { PlayerWrapper } from './player.js'; +import type { TestContext } from './types.js'; +import type { Account } from './account.js'; + +/** Bumped when a breaking change lands in the plugin contract. Checked against a loaded + * plugin's own `apiVersion` so a stale plugin fails with a clear message instead of a + * confusing runtime error. */ +export const PLUGIN_API_VERSION = 1; + +export interface SessionContext { + session: Session; + env: Environment; + options: O; +} + +export interface CleanupContext { + session: Session; + /** 'session' — after the run finishes; 'manual' — a dedicated cleanup invocation + * (e.g. `plugwrightClean` for a mode with a compensating cleanup strategy). */ + scope: 'session' | 'manual'; +} + +export interface PluginTestRef { + /** Path to a compiled spec file, same format the runner's own `test()`/`describe()` + * files use. */ + file: string; + /** `preflight` runs first, before user specs, and aborts the session on failure. + * `suite` runs alongside user specs as regular tests, tagged with the plugin's name + * in reports. */ + mode: 'preflight' | 'suite'; +} + +export type MatcherFn = (this: any, ...args: any[]) => unknown; + +/** + * Extends the test engine without the engine knowing about it: fixtures, matchers, + * authentication hooks, inherited tests, cleanup. + */ +export interface PlugwrightPlugin { + name: string; + apiVersion?: number; + setup?(session: SessionContext): Promise | void; + /** Fired on every bot connection — initial join and every `player.rejoin()` — not just + * the first. A one-shot "first test" can't cover a second bot or a rejoin, which is + * why this is a hook rather than a `preflight` test. */ + onPlayerCreate?(player: PlayerWrapper, ctx: { account: Account; env: Environment }): Promise | void; + beforeEach?(ctx: TestContext): Promise | void; + afterEach?(ctx: TestContext): Promise | void; + extendContext?(ctx: TestContext): Record | void; + matchers?: Record; + tests?: PluginTestRef[]; + cleanup?(ctx: CleanupContext): Promise | void; + teardown?(): Promise | void; +} + +/** Identity function — exists for type inference at the plugin's definition site, the same + * role `defineConfig()` plays in other tools. */ +export function definePlugin(plugin: PlugwrightPlugin): PlugwrightPlugin { + return plugin; +} diff --git a/runner-package/lib/reporter.ts b/runner-package/lib/reporter.ts index 5bb01ae..8211386 100644 --- a/runner-package/lib/reporter.ts +++ b/runner-package/lib/reporter.ts @@ -125,6 +125,7 @@ export function writeJsonReport(path: string, environmentName: string, testResul durationMs: r.durationMs, error: r.error ? r.error.message : null, skipReason: r.skipReason ?? null, + plugin: r.plugin ?? null, })), }; @@ -133,7 +134,7 @@ export function writeJsonReport(path: string, environmentName: string, testResul } /** Writes a JUnit XML report: `testsuite name="plugwright."`, one `testcase` per test, - * spec file as `classname`, full `describe`-chain name as `name`. See modes-and-plugins §5.3. */ + * spec file as `classname`, full `describe`-chain name as `name`. */ export function writeJUnitReport(path: string, environmentName: string, testResults: TestResult[]): void { const skipped = testResults.filter(r => r.skipped).length; const failed = testResults.filter(r => !r.skipped && !r.passed).length; @@ -143,12 +144,13 @@ export function writeJUnitReport(path: string, environmentName: string, testResu const timeSeconds = (r.durationMs / 1000).toFixed(3); const classname = xmlEscape(r.file); const name = xmlEscape(r.testName); + const pluginAttr = r.plugin ? ` plugin="${xmlEscape(r.plugin)}"` : ''; const inner = r.skipped ? `\n \n ` : !r.passed ? `\n ${xmlEscape(r.error?.stack ?? r.error?.message ?? '')}\n ` : ''; - return ` ${inner}`; + return ` ${inner}`; }); const xml = [ diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index a6c1c38..c25fea0 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -1,7 +1,10 @@ import mineflayer, { Bot } from 'mineflayer'; import pc from 'picocolors'; +import { CleanupJournal } from './journal.js'; import type { Environment, BotConnectionOptions } from './environment.js'; import type { ServerConsole } from './console.js'; +import type { PlayerWrapper } from './player.js'; +import type { Account } from './account.js'; /** * Append-only line buffer. Replaces the old module-level `string[]` singletons @@ -51,9 +54,16 @@ export class Session { console: ServerConsole | null = null; readonly bots: Bot[] = []; readonly consoleLog = new MessageBuffer(); + readonly journal: CleanupJournal; - constructor(env: Environment) { + /** Set once by the runner after loading plugins. Fired by `PlayerWrapper.join()` on + * every connection (initial join and every `rejoin()`), not called directly by + * `Session` itself. */ + onPlayerCreate: ((player: PlayerWrapper, ctx: { account: Account; env: Environment }) => Promise | void) | null = null; + + constructor(env: Environment, journalPath: string | null = null) { this.env = env; + this.journal = new CleanupJournal(journalPath); } /** Pulls the console channel from the environment. Called once `env.setup()` has produced one. */ diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 2ff4e7e..5e4705e 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -1,6 +1,6 @@ import type { TestContext } from './types.js'; -type Hook = (context: TestContext) => Promise; +export type Hook = (context: TestContext) => Promise | void; type TestFn = (context: TestContext) => Promise; /** @@ -9,7 +9,7 @@ type TestFn = (context: TestContext) => Promise; * `requires` checks capability flags on `env.capabilities` (e.g. `'console'`, `'op'`) — * a value of `false` or `'none'` fails the check. `environments` checks the running * environment's name directly, for cases that aren't about capability but about the - * content of a specific stand. See modes-and-plugins §5.4. + * content of a specific stand. */ export interface TestOptions { requires?: string[]; @@ -22,9 +22,14 @@ interface DescribeScope { afterHooks: Hook[]; } -interface TestCase { +export interface TestCase { name: string; fn: TestFn; + /** Spec-level `beforeEach` hooks in run order (outermost `describe` first). */ + beforeHooks: Hook[]; + /** Spec-level `afterEach` hooks in run order (innermost `describe` first) — already + * reversed at registration time, see `registerTest`. */ + afterHooks: Hook[]; requires: string[]; environments: string[] | null; } @@ -32,36 +37,24 @@ interface TestCase { export const testRegistry: TestCase[] = []; export const scopeStack: DescribeScope[] = [{ label: '', beforeHooks: [], afterHooks: [] }]; +/** Discards whatever a previously-imported spec file registered, ready for the next one. + * `testRegistry`/`scopeStack` stay module-level with this per-file reset — correct only + * as long as one process runs one environment and files run sequentially. */ +export function resetRegistry(): void { + testRegistry.length = 0; + scopeStack.length = 0; + scopeStack.push({ label: '', beforeHooks: [], afterHooks: [] }); +} + function registerTest(name: string, options: TestOptions, fn: TestFn): void { const labels = scopeStack.map(s => s.label).filter(l => l); const fullName = [...labels, name].join(' > '); - const beforeHooks = scopeStack.flatMap(s => s.beforeHooks); - const afterHooks = [...scopeStack].reverse().flatMap(s => s.afterHooks); - - const wrappedFn = async (ctx: TestContext) => { - let testError: unknown; - try { - for (const hook of beforeHooks) await hook(ctx); - await fn(ctx); - } catch (e) { - testError = e; - } finally { - for (const hook of afterHooks) { - try { - await hook(ctx); - } catch (e) { - testError ??= e; - console.error('[afterEach] Hook error:', (e as Error).message); - } - } - } - if (testError) throw testError; - }; - testRegistry.push({ name: fullName, - fn: wrappedFn, + fn, + beforeHooks: scopeStack.flatMap(s => s.beforeHooks), + afterHooks: [...scopeStack].reverse().flatMap(s => s.afterHooks), requires: options.requires ?? [], environments: options.environments ?? null, }); diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts new file mode 100644 index 0000000..bdee14a --- /dev/null +++ b/runner-package/lib/test-runner.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto'; +import pc from 'picocolors'; +import { PlayerWrapper } from './player.js'; +import { ServerWrapper } from './server.js'; +import { formatDuration } from './reporter.js'; +import { syntheticAccount } from './account.js'; +import type { Session } from './session.js'; +import type { PluginHost } from './plugin-host.js'; +import type { BotConnectionOptions } from './environment.js'; +import type { TestCase } from './test-registry.js'; +import type { TestContext, TestResult } from './types.js'; + +export interface RunTestCaseParams { + file: string; + testCase: TestCase; + session: Session; + plugins: PluginHost; + connOpts: BotConnectionOptions; + timeoutMs: number; + /** Set when this test came from a plugin's inherited `tests`, for report labeling. */ + pluginName?: string | null; +} + +/** + * Runs one test case end to end: creates the primary bot (firing `onPlayerCreate`), + * builds `TestContext`, and sequences hooks in order — plugin beforeEach → spec beforeEach + * → body → cleanup finalizers → spec afterEach → plugin afterEach. Finalizer errors are + * logged but never flip the test result; spec afterEach errors do, matching the runner's + * pre-plugin-host behavior. + */ +export async function runTestCase(params: RunTestCaseParams): Promise { + const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null } = params; + + console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); + session.consoleLog.clear(); + + const server = new ServerWrapper(session); + const finalizers: Array<() => void | Promise> = []; + + const createPlayer = async (options?: { username?: string }): Promise => { + const uniqueId = randomUUID().split('-')[0]; + const botUsername = options?.username || `Test_${uniqueId}`; + console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(botUsername)}`); + + const bot = session.createBot({ ...connOpts, username: botUsername }); + const player = new PlayerWrapper(bot, session); + player._captureSpawnPromise(); + player.setServerWrapper(server); + player._setBotOptions(connOpts); + player._setAccount(syntheticAccount(botUsername)); + + await player.join(); + return player; + }; + + const player = await createPlayer(); + const abortController = new AbortController(); + + const ctx: TestContext = { + player, + server, + createPlayer, + signal: abortController.signal, + cleanup: (fn: () => void | Promise) => { finalizers.push(fn); }, + }; + + plugins.extendContext(ctx); + + const testStartTime = Date.now(); + + try { + let timeoutHandle: ReturnType; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + abortController.abort(); + reject(new Error(`Test timed out after ${timeoutMs}ms. You can increase this by setting the TEST_TIMEOUT environment variable.`)); + }, timeoutMs); + }); + + const body = async (): Promise => { + await plugins.beforeEach(ctx); + for (const hook of testCase.beforeHooks) await hook(ctx); + + let testError: unknown; + try { + await testCase.fn(ctx); + } catch (e) { + testError = e; + } finally { + // Finalizers run before afterEach. Their errors are logged only — a + // cleanup hiccup isn't a second chance to fail the test. + for (const finalizer of [...finalizers].reverse()) { + try { + await finalizer(); + } catch (e) { + console.error(pc.red(`[cleanup] finalizer error: ${(e as Error).message}`)); + } + } + for (const hook of testCase.afterHooks) { + try { + await hook(ctx); + } catch (e) { + testError ??= e; + console.error(pc.red(`[afterEach] Hook error: ${(e as Error).message}`)); + } + } + await plugins.afterEach(ctx); + } + if (testError) throw testError; + }; + + await Promise.race([body().finally(() => clearTimeout(timeoutHandle)), timeoutPromise]); + + const durationMs = Date.now() - testStartTime; + console.log(` ${pc.green(pc.bold('PASSED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}\n`); + return { file, testName: testCase.name, passed: true, durationMs, plugin: pluginName }; + } catch (error) { + const durationMs = Date.now() - testStartTime; + const errorMsg = (error as Error).message; + console.log(` ${pc.red(pc.bold('FAILED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}: ${pc.red(errorMsg)}\n`); + return { file, testName: testCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName }; + } finally { + await session.disconnectAllBots(); + } +} diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 28c07ab..8909f91 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -6,6 +6,9 @@ export interface TestContext { server: ServerWrapper; createPlayer: (options?: { username?: string }) => Promise; signal: AbortSignal; + /** Registers a LIFO finalizer that always runs after the test body, before afterEach. + * Errors are logged but never override the test result. */ + cleanup: (fn: () => void | Promise) => void; } export interface TestResult { @@ -18,4 +21,6 @@ export interface TestResult { skipped?: boolean; /** Human-readable reason shown in reports; required whenever `skipped` is true. */ skipReason?: string; + /** Name of the plugin this test was inherited from, or null for a user spec. */ + plugin?: string | null; } \ No newline at end of file diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 90d1c23..539ac5e 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -1,20 +1,20 @@ import { readdir } from 'fs/promises'; import { join, basename } from 'path'; import { pathToFileURL } from 'url'; -import { randomUUID } from 'node:crypto'; import { install as installSourceMapSupport } from 'source-map-support'; import pc from 'picocolors'; import { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator } from './lib/wrappers.js'; -import { PlayerWrapper } from './lib/player.js'; -import { ServerWrapper } from './lib/server.js'; -import { testRegistry, scopeStack } from './lib/test-registry.js'; +import { testRegistry, resetRegistry } from './lib/test-registry.js'; import { Session } from './lib/session.js'; +import { PluginHost } from './lib/plugin-host.js'; +import { runTestCase } from './lib/test-runner.js'; import { LocalEnvironment } from './lib/environments/local.js'; -import { formatDuration, printTestSummary, writeJsonReport, writeJUnitReport } from './lib/reporter.js'; +import { printTestSummary, writeJsonReport, writeJUnitReport } from './lib/reporter.js'; import { loadRunnerConfig } from './lib/config.js'; import type { Environment } from './lib/environment.js'; import type { EnvironmentConfig, LocalEnvironmentConfig, RunnerConfig } from './lib/config.js'; import type { TestResult } from './lib/types.js'; +import type { TestCase } from './lib/test-registry.js'; // Enable source map support for accurate TypeScript stack traces installSourceMapSupport(); @@ -24,14 +24,20 @@ export { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator }; export { PlayerWrapper } from './lib/player.js'; export { ServerWrapper } from './lib/server.js'; export { test, opTest, describe, beforeEach, afterEach } from './lib/test-registry.js'; -export type { TestOptions } from './lib/test-registry.js'; +export type { TestOptions, TestCase } from './lib/test-registry.js'; export { expect } from './lib/matchers.js'; export { loadRunnerConfig, resolveSecret, isSecretRef } from './lib/config.js'; -export type { RunnerConfig, EnvironmentConfig, TestsConfig, LocalEnvironmentConfig, SecretRef } from './lib/config.js'; +export type { RunnerConfig, EnvironmentConfig, TestsConfig, LocalEnvironmentConfig, SecretRef, PluginConfig } from './lib/config.js'; export type { TestContext } from './lib/types.js'; export type { Environment, EnvironmentCapabilities, BotConnectionOptions } from './lib/environment.js'; export type { ServerConsole } from './lib/console.js'; export { Session } from './lib/session.js'; +export { PluginHost } from './lib/plugin-host.js'; +export { definePlugin, PLUGIN_API_VERSION } from './lib/plugin.js'; +export type { PlugwrightPlugin, SessionContext, CleanupContext, PluginTestRef, MatcherFn } from './lib/plugin.js'; +export type { Account } from './lib/account.js'; +export { CleanupJournal } from './lib/journal.js'; +export type { JournalEntry } from './lib/journal.js'; /** Only `local` is wired up yet; third-party modes arrive with the mode registry (phase 3). */ function resolveEnvironment(cfg: EnvironmentConfig): Environment { @@ -67,40 +73,32 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): const testFileFilters = config.tests.include ?? null; const testNameFilters = config.tests.names ?? null; const testNameExcludes = config.tests.exclude ?? null; + const timeoutMs = config.tests.timeoutMs + ?? (process.env.TEST_TIMEOUT ? parseInt(process.env.TEST_TIMEOUT, 10) : 30000); const testResults: TestResult[] = []; const env = resolveEnvironment(config.environment); - const session = new Session(env); + const session = new Session(env, config.journal ?? null); + const plugins = new PluginHost(); + await plugins.load(config.plugins ?? []); + // Must happen before the first spec file is imported — see PluginHost.registerMatchers. + plugins.registerMatchers(); let exitCode = 0; await env.setup(session); session.refreshConsole(); + await plugins.setup(session); + session.onPlayerCreate = (player, ctx) => plugins.onPlayerCreate(player, ctx); try { const connOpts = env.connection(); - let testFiles = await findSpecFiles(config.tests.dir || process.cwd()); - if (testFileFilters) { - const patterns = testFileFilters; - console.log(`${pc.dim(`Filtering test files with patterns: ${JSON.stringify(patterns)}`)}\n`); - testFiles = testFiles.filter(file => - patterns.some(pattern => { - const fileName = basename(file).replace(/\.spec\.js$/, ''); - const matches = fileName.includes(pattern) || file.includes(pattern); - console.log(pc.dim(` Testing ${file} (basename: ${fileName}) against pattern "${pattern}": ${matches}`)); - return matches; - }) - ); - } - - console.log(`${pc.bold(`Found ${testFiles.length} test file(s)${testFileFilters ? ` matching filter: ${testFileFilters.join(',')}` : ''}`)}\n`); - /** Why a test should not run, or null to run it. Checked in order: name exclude, * name filter, declared `environments`, declared `requires`. A skip always lands * in the report with its reason — a silent skip on an external stand would look * like coverage that isn't really there. */ - function skipReasonFor(testCase: (typeof testRegistry)[number]): string | null { + function skipReasonFor(testCase: TestCase): string | null { if (testNameExcludes?.some(pattern => testCase.name.includes(pattern))) { return `excluded by tests.exclude (matches "${testNameExcludes.join(',')}")`; } @@ -117,88 +115,68 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): return null; } - for (const file of testFiles) { - console.log(`\n${pc.blue(pc.bold(`Running tests from: ${file}`))}`); - - testRegistry.length = 0; - scopeStack.length = 0; - scopeStack.push({ label: '', beforeHooks: [], afterHooks: [] }); + /** Imports one compiled spec file (a fresh `testRegistry`) and runs everything it + * registered, appending results to `testResults`. Shared by user specs and every + * plugin-inherited test file. */ + async function runFile(file: string, pluginName: string | null): Promise { + resetRegistry(); await import(pathToFileURL(file).href); for (const testCase of testRegistry) { const skipReason = skipReasonFor(testCase); if (skipReason) { console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (${skipReason})`)); - testResults.push({ file, testName: testCase.name, passed: true, durationMs: 0, skipped: true, skipReason }); + testResults.push({ file, testName: testCase.name, passed: true, durationMs: 0, skipped: true, skipReason, plugin: pluginName }); continue; } - console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); - - session.consoleLog.clear(); - - const server = new ServerWrapper(session); - - const createPlayer = async (options?: { username?: string }): Promise => { - const uniqueId = randomUUID().split('-')[0]; - const botUsername = options?.username || `Test_${uniqueId}`; - console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(botUsername)}`); - - const bot = session.createBot({ ...connOpts, username: botUsername }); - - const player = new PlayerWrapper(bot, session); - player._captureSpawnPromise(); - player.setServerWrapper(server); - player._setBotOptions(connOpts); - - await player.join(); - return player; - }; - - const player = await createPlayer(); - - const testStartTime = Date.now(); - - try { - const abortController = new AbortController(); - const timeoutMs = config.tests.timeoutMs - ?? (process.env.TEST_TIMEOUT ? parseInt(process.env.TEST_TIMEOUT, 10) : 30000); - let timeoutHandle: ReturnType; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => { - abortController.abort(); - reject(new Error(`Test timed out after ${timeoutMs}ms. You can increase this by setting the TEST_TIMEOUT environment variable.`)); - }, timeoutMs); - }); - - await Promise.race([ - testCase.fn({ player, server, createPlayer, signal: abortController.signal }).finally(() => clearTimeout(timeoutHandle)), - timeoutPromise - ]); - - const durationMs = Date.now() - testStartTime; - console.log(` ${pc.green(pc.bold('PASSED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}\n`); - testResults.push({ file, testName: testCase.name, passed: true, durationMs }); - } catch (error) { - const durationMs = Date.now() - testStartTime; - const errorMsg = (error as Error).message; - - console.log(` ${pc.red(pc.bold('FAILED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}: ${pc.red(errorMsg)}\n`); - - testResults.push({ - file, - testName: testCase.name, - passed: false, - durationMs, - error: error as Error - }); - } finally { - await session.disconnectAllBots(); - } + const result = await runTestCase({ file, testCase, session, plugins, connOpts, timeoutMs, pluginName }); + testResults.push(result); + } + } + + // Preflight: plugin auth/setup tests, run before anything else. A failure aborts the + // whole session. + for (const { file, pluginName } of plugins.testFiles('preflight')) { + console.log(`\n${pc.blue(pc.bold(`Running preflight tests from: ${file} ${pc.dim(`(plugin ${pluginName})`)}`))}`); + const before = testResults.length; + await runFile(file, pluginName); + const failed = testResults.slice(before).find(r => !r.skipped && !r.passed); + if (failed) { + throw new Error(`Preflight test "${failed.testName}" failed (plugin ${pluginName}): ${failed.error?.message ?? 'unknown error'}`); } } + let testFiles = await findSpecFiles(config.tests.dir || process.cwd()); + if (testFileFilters) { + const patterns = testFileFilters; + console.log(`${pc.dim(`Filtering test files with patterns: ${JSON.stringify(patterns)}`)}\n`); + testFiles = testFiles.filter(file => + patterns.some(pattern => { + const fileName = basename(file).replace(/\.spec\.js$/, ''); + const matches = fileName.includes(pattern) || file.includes(pattern); + console.log(pc.dim(` Testing ${file} (basename: ${fileName}) against pattern "${pattern}": ${matches}`)); + return matches; + }) + ); + } + + console.log(`${pc.bold(`Found ${testFiles.length} test file(s)${testFileFilters ? ` matching filter: ${testFileFilters.join(',')}` : ''}`)}\n`); + + for (const file of testFiles) { + console.log(`\n${pc.blue(pc.bold(`Running tests from: ${file}`))}`); + await runFile(file, null); + } + + // Suite: plugin tests that run alongside user specs, tagged with the plugin's name. + for (const { file, pluginName } of plugins.testFiles('suite')) { + console.log(`\n${pc.blue(pc.bold(`Running tests from: ${file} ${pc.dim(`(plugin ${pluginName})`)}`))}`); + await runFile(file, pluginName); + } + } finally { + await plugins.runCleanup(session, 'session'); + await plugins.teardown(); await session.disconnectAllBots(); await env.teardown(); From cdac6b5470bc6d91eca222e00cdadf4c9c536559 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 22:42:44 +0300 Subject: [PATCH 06/15] feat(gradle): thread plugin configs and cleanup journal through config transport Extends the mode contract so a mode can declare runner plugins to load (TaskRegistrationContext.pluginConfigs) and reach the test project's tests directory (TaskRegistrationContext.testsDir), and wires both - plus a per-environment crash-recovery journal path - into the runner config alongside the existing environment/tests/reports sections. Also adds a small secret.env(...)/secret.file(...) DSL accessor on Project, so secret references read naturally in a build script instead of the fully-qualified Secrets.env(...). --- .../me/drownek/plugwright/api/PluginRef.kt | 20 +++++++++ .../me/drownek/plugwright/api/SecretRef.kt | 5 +++ .../plugwright/api/TaskRegistrationContext.kt | 10 +++++ .../plugwright/PlugwrightCorePlugin.kt | 12 +++++- .../plugwright/PlugwrightMatrixTask.kt | 5 +++ .../drownek/plugwright/PlugwrightTestTask.kt | 11 +++++ .../me/drownek/plugwright/RunnerLauncher.kt | 41 +++++++++++++++---- .../plugwright/TaskRegistrationContextImpl.kt | 12 +++++- 8 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt new file mode 100644 index 0000000..6e216bf --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt @@ -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 = emptyMap(), + val inheritTests: Boolean = true +) : Serializable { + companion object { + private const val serialVersionUID: Long = 1L + } +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt index 5f4e378..c3fbd0e 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt @@ -1,5 +1,6 @@ package me.drownek.plugwright.api +import org.gradle.api.Project import java.io.File import java.io.Serializable @@ -37,3 +38,7 @@ object Secrets { 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 diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt index 9d1707d..01e8c03 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt @@ -29,6 +29,9 @@ interface TaskRegistrationContext { */ val projectPluginJar: Provider + /** Directory the runner scans for spec files, same value `plugwrightTest` uses. */ + val testsDir: Provider + /** * Registers a task named `plugwright`, e.g. `plugwrightProvisionLocal` * for `register("Provision", …)` in the `local` environment. @@ -49,6 +52,13 @@ interface TaskRegistrationContext { * task can reach — a Gradle service such as the Java toolchain, for instance. */ fun environmentConfig(node: Provider) + + /** + * Declares the runner plugins this environment should load — the top-level `plugins` + * array in the config, sibling to `environment.config` rather than part of it. Empty by + * default; most modes have none. + */ + fun pluginConfigs(refs: Provider>) } /** Kotlin-friendly overload of [TaskRegistrationContext.register]. */ diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt index 8cda14d..0e0ec30 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -96,7 +96,11 @@ class PlugwrightCorePlugin : Plugin { extension.environments.all.forEach { entry -> val envName = entry.spec.name val mode = entry.mode.erased() - val ctx = TaskRegistrationContextImpl(project, envName, envName == primaryName, projectPluginJarProvider) + val ctx = TaskRegistrationContextImpl( + project, envName, envName == primaryName, projectPluginJarProvider, + extension.testsDir.map { it.asFile } + ) + val journalFilePath = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl").get().asFile val testTask = ctx.registerWithoutAlias("Test", PlugwrightTestTask::class.java) { doFirst { @@ -110,6 +114,7 @@ class PlugwrightCorePlugin : Plugin { configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$envName.json")) jsonReportFile.set(reportsDir.map { it.file("$envName.json") }) junitReportFile.set(reportsDir.map { it.dir("junit").file("$envName.xml") }) + journalFile.set(journalFilePath) nodeVersion.set(extension.nodeVersion) downloadNode.set(extension.downloadNode) nodeInstallDir.set(defaultNodeInstallDir) @@ -126,10 +131,13 @@ class PlugwrightCorePlugin : Plugin { val environmentConfigProvider = ctx.environmentConfigProvider ?: project.provider { ConfigNodeBuilder().apply { mode.serialize(entry.spec, this) }.build() } + val pluginConfigsProvider = ctx.pluginConfigsProvider + ?: project.provider { emptyList() } testTask.configure { ctx.prepareTaskRef?.let { dependsOn(it) } environmentConfig.set(environmentConfigProvider) + pluginConfigs.set(pluginConfigsProvider) } if (entry.spec.includeInMatrix.get() && (matrixEnvFilter == null || envName in matrixEnvFilter)) { @@ -145,6 +153,8 @@ class PlugwrightCorePlugin : Plugin { logFile = File(reportsDirFile, "$envName.log"), excludeTests = entry.spec.excludeTests.get(), environmentConfig = environmentConfigProvider, + pluginConfigs = pluginConfigsProvider, + journalFile = journalFilePath, ) ctx.prepareTaskRef?.let { matrixPrepareTasks += it } } diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt index 47d65c7..559c204 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt @@ -2,6 +2,7 @@ package me.drownek.plugwright import com.google.gson.JsonParser import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.PluginRef import org.gradle.api.GradleException import org.gradle.api.provider.Property import org.gradle.api.provider.Provider @@ -25,6 +26,8 @@ internal data class MatrixEnvironmentInput( val logFile: File, val excludeTests: List, val environmentConfig: Provider, + val pluginConfigs: Provider>, + val journalFile: File?, ) private data class EnvironmentSummary(val total: Int, val passed: Int, val failed: Int, val skipped: Int, val durationMs: Long) @@ -119,6 +122,8 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { excludeTests = env.excludeTests, jsonReportFile = env.jsonReportFile, junitReportFile = env.junitReportFile, + pluginConfigs = env.pluginConfigs.get(), + journalFile = env.journalFile, ) RunnerLauncher.writeConfig(entry) val cliJs = RunnerLauncher.resolveCliJs(env.testsDir) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt index 6939cc4..20e16cf 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt @@ -1,6 +1,7 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.PluginRef import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty @@ -50,6 +51,14 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { @get:Internal abstract val environmentConfig: Property + /** Runner plugins this environment loads, from [me.drownek.plugwright.api.TaskRegistrationContext.pluginConfigs]. */ + @get:Internal + abstract val pluginConfigs: ListProperty + + /** Crash-recovery journal for this environment's run. */ + @get:Internal + abstract val journalFile: RegularFileProperty + /** Where the generated runner config is written before the CLI is invoked. */ @get:OutputFile abstract val configFile: RegularFileProperty @@ -100,6 +109,8 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { excludeTests = if (excludeTests.isPresent) excludeTests.get() else emptyList(), jsonReportFile = jsonReportFile.get().asFile, junitReportFile = junitReportFile.get().asFile, + pluginConfigs = pluginConfigs.get(), + journalFile = journalFile.orNull?.asFile, ) RunnerLauncher.writeConfig(entry) logger.lifecycle("Runner config: ${configDestination.absolutePath}") diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt index 6f2a69a..01ddb59 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt @@ -2,17 +2,21 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNode import me.drownek.plugwright.api.ConfigNodeBuilder +import me.drownek.plugwright.api.PluginRef import org.gradle.api.GradleException import java.io.File /** - * Config-writing and `cli.js` resolution shared by [PlugwrightTestTask] (one environment) and - * [PlugwrightMatrixTask] (many, in one process each). Process execution itself stays on - * [AbstractNodeTask] — both task types extend it and already have `runCommand`/`resolveNode`. + * Config-writing and `cli.js` resolution shared by [PlugwrightTestTask] (one environment), + * [PlugwrightMatrixTask] (many, in one process each), and the service tasks a mode registers + * for itself (ping, compensating cleanup). Process execution itself stays on + * [AbstractNodeTask] — every task type here extends it and already has `runCommand`/`resolveNode`. */ object RunnerLauncher { - /** Everything needed to write one environment's `config.json` and locate its `cli.js`. */ + /** Everything needed to write one environment's `config.json` and locate its `cli.js`. + * [jsonReportFile]/[junitReportFile] are omitted for service runs (`--ping`, `--cleanup`) + * that never produce a report. */ data class Entry( val environmentName: String, val modeId: String, @@ -22,8 +26,11 @@ object RunnerLauncher { val testFiles: List?, val testNames: List?, val excludeTests: List, - val jsonReportFile: File, - val junitReportFile: File, + val jsonReportFile: File? = null, + val junitReportFile: File? = null, + val pluginConfigs: List = emptyList(), + /** Crash-recovery journal path for `Session.journal`; null disables on-disk persistence. */ + val journalFile: File? = null, ) fun writeConfig(entry: Entry) { @@ -42,10 +49,26 @@ object RunnerLauncher { // null means "runner default", which TEST_TIMEOUT can still override. putNull("timeoutMs") } - obj("reports") { - put("json", entry.jsonReportFile.absolutePath) - put("junit", entry.junitReportFile.absolutePath) + if (entry.jsonReportFile != null || entry.junitReportFile != null) { + obj("reports") { + entry.jsonReportFile?.let { put("json", it.absolutePath) } + entry.junitReportFile?.let { put("junit", it.absolutePath) } + } } + if (entry.pluginConfigs.isNotEmpty()) { + array("plugins") { + entry.pluginConfigs.forEach { ref -> + obj { + put("specifier", ref.specifier) + put("inheritTests", ref.inheritTests) + if (ref.options.isNotEmpty()) { + obj("options") { ref.options.forEach { (k, v) -> put(k, v) } } + } + } + } + } + } + entry.journalFile?.let { put("journal", it.absolutePath) } ?: putNull("journal") }.build() RunnerConfigWriter.write(entry.configFile, root) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt index 0bc520d..63abda4 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt @@ -1,6 +1,7 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.PluginRef import me.drownek.plugwright.api.TaskRegistrationContext import org.gradle.api.Project import org.gradle.api.Task @@ -17,7 +18,8 @@ internal class TaskRegistrationContextImpl( override val project: Project, override val environmentName: String, private val isPrimary: Boolean, - override val projectPluginJar: Provider + override val projectPluginJar: Provider, + override val testsDir: Provider ) : TaskRegistrationContext { /** Set by [prepareTask]; read by the plugin once every mode has registered its tasks. */ @@ -28,6 +30,10 @@ internal class TaskRegistrationContextImpl( var environmentConfigProvider: Provider? = null private set + /** Set by [pluginConfigs]; when null, the environment loads no runner plugins. */ + var pluginConfigsProvider: Provider>? = null + private set + private val aliasedSuffixes = mutableSetOf() override fun register(suffix: String, type: Class, action: T.() -> Unit): TaskProvider = @@ -66,4 +72,8 @@ internal class TaskRegistrationContextImpl( override fun environmentConfig(node: Provider) { environmentConfigProvider = node } + + override fun pluginConfigs(refs: Provider>) { + pluginConfigsProvider = refs + } } From a574308093ca7a5f2620e605e2cb25ad237226d9 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 22:43:07 +0300 Subject: [PATCH 07/15] feat(external): add ExternalMode with account pool, console channels, ping and cleanup tasks New plugwright-external module, mirroring plugwright-local's shape for a mode that attaches to an already-running server instead of spawning one: - ExternalEnvironmentSpec: host/port/minecraftVersion (mandatory), joinThrottleMs, plus nested console { rcon { ... }; adminBot(...) { ... } }, accounts { pool { ... }; autoRegister { ... }; microsoft { ... } } and plugins { npm(...); local(...) } blocks. - ExternalMode: validates the spec, serializes it into the runner config (secrets stay references), and pulls in the RCON runner package only when a rcon console block is actually declared. - PlugwrightPingTask (plugwrightPing) and PlugwrightCleanupTask (plugwrightClean) run the runner in a service mode instead of the normal test mode - reachability/auth check, and compensating cleanup + journal replay, respectively. --- .../plugwright-external/build.gradle.kts | 12 ++ .../plugwright/external/AccountsSpec.kt | 61 ++++++++ .../plugwright/external/ConsoleSpec.kt | 41 +++++ .../external/ExternalEnvironmentSpec.kt | 52 +++++++ .../plugwright/external/ExternalMode.kt | 144 ++++++++++++++++++ .../plugwright/external/PluginsSpec.kt | 37 +++++ .../external/PlugwrightCleanupTask.kt | 68 +++++++++ .../plugwright/external/PlugwrightPingTask.kt | 63 ++++++++ gradle-plugin/settings.gradle.kts | 10 +- 9 files changed, 484 insertions(+), 4 deletions(-) create mode 100644 gradle-plugin/plugwright-external/build.gradle.kts create mode 100644 gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt create mode 100644 gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt create mode 100644 gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt create mode 100644 gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt create mode 100644 gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PluginsSpec.kt create mode 100644 gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt create mode 100644 gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt diff --git a/gradle-plugin/plugwright-external/build.gradle.kts b/gradle-plugin/plugwright-external/build.gradle.kts new file mode 100644 index 0000000..d46c81e --- /dev/null +++ b/gradle-plugin/plugwright-external/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + `kotlin-dsl` +} + +dependencies { + implementation(gradleApi()) + implementation(project(":plugwright-core")) + + // Compile-time only: its classes reach the runtime classpath through the bundle module's + // merged jar, which is what actually gets published. + compileOnly(project(":plugwright-api")) +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt new file mode 100644 index 0000000..90b039f --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt @@ -0,0 +1,61 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.api.SecretRef +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property + +/** One named account in the fixed `pool`. */ +class PoolAccountSpec(val username: String, objects: ObjectFactory) { + val password: Property = objects.property(SecretRef::class.java) +} + +/** `pool { account("TestBot1") { password.set(...) } }`. */ +class PoolSpec(private val objects: ObjectFactory) { + internal val accounts = mutableListOf() + + fun account(username: String, action: PoolAccountSpec.() -> Unit) { + accounts.add(PoolAccountSpec(username, objects).apply(action)) + } +} + +/** `autoRegister { usernamePattern.set("pw_%04d"); password.set(...); max.set(4) }`. Generates + * fresh accounts on demand, up to [max] at once; each one registers on its first login. */ +class AutoRegisterSpec(objects: ObjectFactory) { + /** Must start with `pw_` — generated accounts have to be recognizable as test accounts, + * the same convention the cleanup journal requires of entities it creates. */ + val usernamePattern: Property = objects.property(String::class.java).convention("pw_%04d") + val password: Property = objects.property(SecretRef::class.java) + val max: Property = objects.property(Int::class.java).convention(4) +} + +/** `microsoft { account("bot@example.com"); cacheDir.set(...) }`. Online-mode accounts; + * no password — mineflayer authenticates through a cached Microsoft token. */ +class MicrosoftAccountsSpec(objects: ObjectFactory) { + internal val accountNames = mutableListOf() + val cacheDir: DirectoryProperty = objects.directoryProperty() + + fun account(usernameOrEmail: String) { + accountNames.add(usernameOrEmail) + } +} + +/** `accounts { pool { ... }; autoRegister { ... }; microsoft { ... } }` — the three sources an + * account pool merges at runtime. All three are optional and independent. */ +class AccountsSpec(private val objects: ObjectFactory) { + internal var pool: PoolSpec? = null + internal var autoRegister: AutoRegisterSpec? = null + internal var microsoft: MicrosoftAccountsSpec? = null + + fun pool(action: PoolSpec.() -> Unit) { + pool = PoolSpec(objects).apply(action) + } + + fun autoRegister(action: AutoRegisterSpec.() -> Unit) { + autoRegister = AutoRegisterSpec(objects).apply(action) + } + + fun microsoft(action: MicrosoftAccountsSpec.() -> Unit) { + microsoft = MicrosoftAccountsSpec(objects).apply(action) + } +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt new file mode 100644 index 0000000..6033300 --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt @@ -0,0 +1,41 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.api.SecretRef +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property + +/** One console channel a build script can declare. Channels are probed in declaration order + * at runtime; the first one that connects becomes the session's console. */ +sealed class ConsoleChannelSpec { + + /** `console { rcon { port.set(25575); password.set(secret.env("RCON_PASS")) } }`. Needs the + * separate `@plugwright/console-rcon` runner package. */ + class Rcon(objects: ObjectFactory) : ConsoleChannelSpec() { + val port: Property = objects.property(Int::class.java).convention(25575) + val password: Property = objects.property(SecretRef::class.java) + } + + /** `console { adminBot("StaffBot") { password.set(secret.env("STAFF_PASS")) } }`. A second + * mineflayer bot with staff rights, sending commands through chat. */ + class AdminBot(val username: String, objects: ObjectFactory) : ConsoleChannelSpec() { + val password: Property = objects.property(SecretRef::class.java) + } +} + +/** + * `console { rcon { ... }; adminBot("Name") { ... } }`. + * + * Declaring neither channel is valid — the environment just runs without a console, and any + * test requiring one is skipped and reported as such. + */ +class ConsoleSpec(private val objects: ObjectFactory) { + internal val channels = mutableListOf() + + fun rcon(action: ConsoleChannelSpec.Rcon.() -> Unit) { + channels.add(ConsoleChannelSpec.Rcon(objects).apply(action)) + } + + fun adminBot(username: String, action: ConsoleChannelSpec.AdminBot.() -> Unit) { + channels.add(ConsoleChannelSpec.AdminBot(username, objects).apply(action)) + } +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt new file mode 100644 index 0000000..7e12975 --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt @@ -0,0 +1,52 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.api.EnvironmentSpec +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** + * Build-script description of an already-running server: bots connect to [host]:[port] + * instead of anything this mode spawns, patches or owns. Deploying the plugin under test onto + * that server is left to the user — this mode assumes it's already installed. + */ +class ExternalEnvironmentSpec(private val environmentName: String, private val objects: ObjectFactory) : EnvironmentSpec { + + override fun getName(): String = environmentName + + // Opt-in, unlike local's opt-out default: a shared external stand shouldn't join every + // local `plugwrightTest` run unasked. + override val includeInMatrix: Property = objects.property(Boolean::class.java).convention(false) + override val allowFailure: Property = objects.property(Boolean::class.java).convention(false) + override val excludeTests: ListProperty = objects.listProperty(String::class.java).convention(emptyList()) + + val host: Property = objects.property(String::class.java) + val port: Property = objects.property(Int::class.java).convention(25565) + + /** Mandatory: a proxy in front of the stand (ViaVersion and similar) defeats automatic + * protocol version detection, so this can't default to "whatever the server reports". */ + val minecraftVersion: Property = objects.property(String::class.java) + + /** Minimum delay between two bot connects, to stay under anti-bot heuristics on a shared + * public server. Zero means "connect as fast as possible", same as today. */ + val joinThrottleMs: Property = objects.property(Long::class.java).convention(0L) + + internal var consoleSpec: ConsoleSpec? = null + internal val accountsSpec: AccountsSpec = AccountsSpec(objects) + internal val pluginsSpec: PluginsSpec = PluginsSpec() + + /** `console { rcon { ... }; adminBot("Name") { ... } }`. */ + fun console(action: ConsoleSpec.() -> Unit) { + consoleSpec = ConsoleSpec(objects).apply(action) + } + + /** `accounts { pool { ... }; autoRegister { ... }; microsoft { ... } }`. */ + fun accounts(action: AccountsSpec.() -> Unit) { + accountsSpec.action() + } + + /** `plugins { npm(...); local(...) }`. */ + fun plugins(action: PluginsSpec.() -> Unit) { + pluginsSpec.action() + } +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt new file mode 100644 index 0000000..17582d5 --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt @@ -0,0 +1,144 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.api.ConfigNodeBuilder +import me.drownek.plugwright.api.PlugwrightMode +import me.drownek.plugwright.api.RunnerPackageRef +import me.drownek.plugwright.api.TaskRegistrationContext +import me.drownek.plugwright.api.ValidationContext +import org.gradle.api.model.ObjectFactory + +/** + * Built-in mode: attaches bots to a server that's already running somewhere, instead of + * spawning and owning one. No provisioning step, no deploy of the jar under test — the + * counterpart of everything [me.drownek.plugwright.local.LocalMode] does for a local Paper. + */ +object ExternalMode : PlugwrightMode { + override val id = "external" + override val specType = ExternalEnvironmentSpec::class.java + + override fun createSpec(name: String, objects: ObjectFactory): ExternalEnvironmentSpec = + ExternalEnvironmentSpec(name, objects) + + override fun runnerPackages(spec: ExternalEnvironmentSpec): List = buildList { + add(RunnerPackageRef("@drownek/plugwright", export = "externalEnvironment")) + val needsRcon = spec.consoleSpec?.channels?.any { it is ConsoleChannelSpec.Rcon } == true + if (needsRcon) { + add(RunnerPackageRef("@plugwright/console-rcon", "^1.0.0", export = "rconConsole")) + } + } + + override fun validate(spec: ExternalEnvironmentSpec, ctx: ValidationContext) { + if (!spec.host.isPresent || spec.host.get().isBlank()) { + ctx.error("host must be set") + } + if (!spec.minecraftVersion.isPresent || spec.minecraftVersion.get().isBlank()) { + ctx.error("minecraftVersion must be set (a proxy in front of the stand defeats automatic protocol detection)") + } + + spec.accountsSpec.autoRegister?.let { autoRegister -> + val pattern = autoRegister.usernamePattern.getOrElse("") + if (!pattern.startsWith("pw_")) { + ctx.error("accounts.autoRegister.usernamePattern must start with \"pw_\" (got \"$pattern\") — generated accounts must be recognizable as test accounts") + } + if (autoRegister.max.getOrElse(0) <= 0) { + ctx.error("accounts.autoRegister.max must be positive") + } + } + + for (channel in spec.consoleSpec?.channels ?: emptyList()) { + when (channel) { + is ConsoleChannelSpec.Rcon -> + if (!channel.password.isPresent) ctx.error("console.rcon.password must be set") + is ConsoleChannelSpec.AdminBot -> + if (!channel.password.isPresent) ctx.error("console.adminBot(\"${channel.username}\").password must be set") + } + } + } + + override fun serialize(spec: ExternalEnvironmentSpec, node: ConfigNodeBuilder) { + node.put("host", spec.host.get()) + node.put("port", spec.port.get()) + node.put("minecraftVersion", spec.minecraftVersion.get()) + node.put("joinThrottleMs", spec.joinThrottleMs.get()) + + node.array("console") { + (spec.consoleSpec?.channels ?: emptyList()).forEach { channel -> + obj { + when (channel) { + is ConsoleChannelSpec.Rcon -> { + put("kind", "rcon") + put("port", channel.port.get()) + put("password", channel.password.get()) + } + is ConsoleChannelSpec.AdminBot -> { + put("kind", "adminBot") + put("username", channel.username) + put("password", channel.password.get()) + } + } + } + } + } + + node.obj("accounts") { + array("pool") { + (spec.accountsSpec.pool?.accounts ?: emptyList()).forEach { account -> + obj { + put("username", account.username) + put("password", account.password.get()) + } + } + } + val autoRegister = spec.accountsSpec.autoRegister + if (autoRegister != null) { + obj("autoRegister") { + put("usernamePattern", autoRegister.usernamePattern.get()) + put("password", autoRegister.password.get()) + put("max", autoRegister.max.get()) + } + } else { + putNull("autoRegister") + } + val microsoft = spec.accountsSpec.microsoft + if (microsoft != null) { + obj("microsoft") { + putStrings("accounts", microsoft.accountNames) + if (microsoft.cacheDir.isPresent) { + put("cacheDir", microsoft.cacheDir.get().asFile.absolutePath) + } + } + } else { + putNull("microsoft") + } + } + } + + override fun registerTasks(spec: ExternalEnvironmentSpec, ctx: TaskRegistrationContext) { + val project = ctx.project + val envName = spec.name + + ctx.pluginConfigs(project.provider { spec.pluginsSpec.entries.toList() }) + val configProvider = project.provider { ConfigNodeBuilder().also { serialize(spec, it) }.build() } + val journalFile = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl") + + ctx.register("Ping", PlugwrightPingTask::class.java) { + environmentName.set(envName) + modeId.set(id) + testsDir.set(ctx.testsDir) + configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$envName-ping.json")) + environmentConfig.set(configProvider) + } + + ctx.register("Clean", PlugwrightCleanupTask::class.java) { + environmentName.set(envName) + modeId.set(id) + testsDir.set(ctx.testsDir) + configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$envName-cleanup.json")) + environmentConfig.set(configProvider) + this.journalFile.set(journalFile) + } + + // No prepareTask: unlike local, external doesn't provision anything before + // plugwrightTest — the stand is assumed to already be up. + } +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PluginsSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PluginsSpec.kt new file mode 100644 index 0000000..4e9e1f9 --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PluginsSpec.kt @@ -0,0 +1,37 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.api.PluginRef +import java.io.File + +/** Per-plugin options and inheritance flag, configured in the trailing lambda of [PluginsSpec.npm] + * / [PluginsSpec.local]. */ +class PluginRefSpec { + /** `options["loginCommand"] = "/login"` or `options.put("loginCommand", "/login")`. */ + val options: MutableMap = linkedMapOf() + + /** Set false to load the plugin's hooks/matchers without pulling in its `tests`. */ + var inheritTests: Boolean = true +} + +/** + * `plugins { npm("@plugwright/auth-authme") { ... }; local(file("...")) { ... } }`. + * + * Declares runner plugins to load for this environment: fixtures, matchers, authentication + * hooks, inherited tests. See the runner's own plugin contract for what a plugin can do once + * loaded. + */ +class PluginsSpec { + internal val entries = mutableListOf() + + /** An npm-published plugin, e.g. `@plugwright/auth-authme`. */ + fun npm(specifier: String, action: PluginRefSpec.() -> Unit = {}) { + val spec = PluginRefSpec().apply(action) + entries.add(PluginRef(specifier, spec.options, spec.inheritTests)) + } + + /** A plugin living as a file in the test project, e.g. under `src/test/e2e`. */ + fun local(file: File, action: PluginRefSpec.() -> Unit = {}) { + val spec = PluginRefSpec().apply(action) + entries.add(PluginRef(file.absolutePath, spec.options, spec.inheritTests)) + } +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt new file mode 100644 index 0000000..9a01f8b --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt @@ -0,0 +1,68 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.AbstractNodeTask +import me.drownek.plugwright.RunnerLauncher +import me.drownek.plugwright.api.ConfigNode +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.io.File + +/** + * `plugwrightClean` for a mode with a compensating cleanup strategy: no run directory to + * wipe, so instead this runs every loaded plugin's `cleanup({ scope: 'manual' })` handler and + * replays whatever the crash-recovery journal still has outstanding — entries a prior run's + * `finally` never reached because the process died first. + */ +abstract class PlugwrightCleanupTask : AbstractNodeTask() { + + @get:Internal + abstract val testsDir: Property + + @get:Input + abstract val environmentName: Property + + @get:Input + abstract val modeId: Property + + @get:Internal + abstract val environmentConfig: Property + + @get:OutputFile + abstract val configFile: RegularFileProperty + + @get:Internal + abstract val journalFile: RegularFileProperty + + init { + group = "verification" + description = "Runs compensating cleanup and replays the crash-recovery journal for an external environment." + outputs.upToDateWhen { false } + } + + @TaskAction + fun cleanup() { + val nodePaths = resolveNode() + val userTestsDirectory = testsDir.get() + + val entry = RunnerLauncher.Entry( + environmentName = environmentName.get(), + modeId = modeId.get(), + environmentConfig = environmentConfig.get(), + testsDir = userTestsDirectory, + configFile = configFile.get().asFile, + testFiles = null, + testNames = null, + excludeTests = emptyList(), + journalFile = journalFile.orNull?.asFile, + ) + RunnerLauncher.writeConfig(entry) + logger.lifecycle("Runner config: ${entry.configFile.absolutePath}") + + val cliJsFile = RunnerLauncher.resolveCliJs(userTestsDirectory) + runCommand(userTestsDirectory, nodePaths.node, cliJsFile.absolutePath, "--config", entry.configFile.absolutePath, "--cleanup") + } +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt new file mode 100644 index 0000000..c141a80 --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt @@ -0,0 +1,63 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.AbstractNodeTask +import me.drownek.plugwright.RunnerLauncher +import me.drownek.plugwright.api.ConfigNode +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.io.File + +/** + * `plugwrightPing`: connects to the environment, probes its declared console channel(s) + * in order, and verifies authentication — no test files are run. Meant as the first thing to + * run against a new external stand, before trusting it with the real matrix. + */ +abstract class PlugwrightPingTask : AbstractNodeTask() { + + @get:Internal + abstract val testsDir: Property + + @get:Input + abstract val environmentName: Property + + @get:Input + abstract val modeId: Property + + @get:Internal + abstract val environmentConfig: Property + + @get:OutputFile + abstract val configFile: RegularFileProperty + + init { + group = "verification" + description = "Checks that an external environment is reachable and authentication works, without running tests." + outputs.upToDateWhen { false } + } + + @TaskAction + fun ping() { + val nodePaths = resolveNode() + val userTestsDirectory = testsDir.get() + + val entry = RunnerLauncher.Entry( + environmentName = environmentName.get(), + modeId = modeId.get(), + environmentConfig = environmentConfig.get(), + testsDir = userTestsDirectory, + configFile = configFile.get().asFile, + testFiles = null, + testNames = null, + excludeTests = emptyList(), + ) + RunnerLauncher.writeConfig(entry) + logger.lifecycle("Runner config: ${entry.configFile.absolutePath}") + + val cliJsFile = RunnerLauncher.resolveCliJs(userTestsDirectory) + runCommand(userTestsDirectory, nodePaths.node, cliJsFile.absolutePath, "--config", entry.configFile.absolutePath, "--ping") + } +} diff --git a/gradle-plugin/settings.gradle.kts b/gradle-plugin/settings.gradle.kts index 664d0d4..765e552 100644 --- a/gradle-plugin/settings.gradle.kts +++ b/gradle-plugin/settings.gradle.kts @@ -1,9 +1,11 @@ rootProject.name = "plugwright" -// plugwright-api — stable contract third-party modes compile against -// plugwright-core — mode-agnostic engine: extension, mode registry, generic tasks -// plugwright-local — built-in "local" mode; also hosts the published plugin id for now, -// until a second built-in mode exists for a dedicated bundle module to combine +// plugwright-api — stable contract third-party modes compile against +// plugwright-core — mode-agnostic engine: extension, mode registry, generic tasks +// plugwright-local — built-in "local" mode; also hosts the published plugin id for now, +// until a dedicated bundle module registers it alongside plugwright-external +// plugwright-external — built-in "external" mode: attaches to an already-running server include(":plugwright-api") include(":plugwright-core") include(":plugwright-local") +include(":plugwright-external") From 4af068fb1193e11d014dcf9ef07be9e6a00206ff Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 22:43:58 +0300 Subject: [PATCH 08/15] feat(bundle): split published plugin id into its own module registering local + external Moves PlugwrightPlugin (the io.github.drownek.plugwright entry point) out of plugwright-local into a new plugwright-bundle module that applies the core engine and registers both built-in modes. Mirrors plugwright-local's old jar-merging trick, now pulling in api, core, local and external classes since none of them publish standalone coordinates. plugwright-local goes back to being just a mode module - no publish plugin, no plugin-id registration - matching plugwright-external's shape. Published plugin id and artifact coordinates are unchanged, so existing consumer build scripts keep working. --- .../plugwright-bundle/build.gradle.kts | 54 +++++++++++++++++++ .../me/drownek/plugwright/PlugwrightPlugin.kt | 8 +-- .../plugwright-local/build.gradle.kts | 31 +---------- gradle-plugin/settings.gradle.kts | 5 +- 4 files changed, 64 insertions(+), 34 deletions(-) create mode 100644 gradle-plugin/plugwright-bundle/build.gradle.kts rename gradle-plugin/{plugwright-local => plugwright-bundle}/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt (59%) diff --git a/gradle-plugin/plugwright-bundle/build.gradle.kts b/gradle-plugin/plugwright-bundle/build.gradle.kts new file mode 100644 index 0000000..d5fc54c --- /dev/null +++ b/gradle-plugin/plugwright-bundle/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + `kotlin-dsl` + `maven-publish` + id("com.gradle.plugin-publish") version "1.2.1" +} + +// This module's jar physically embeds the other modules' classes (see below), so their jar +// tasks must be configured before this script reaches that point. +evaluationDependsOn(":plugwright-api") +evaluationDependsOn(":plugwright-core") +evaluationDependsOn(":plugwright-local") +evaluationDependsOn(":plugwright-external") + +dependencies { + implementation(gradleApi()) + implementation("com.google.code.gson:gson:2.10.1") + implementation("org.yaml:snakeyaml:2.0") + implementation(project(":plugwright-core")) + implementation(project(":plugwright-local")) + implementation(project(":plugwright-external")) + + // Compile-time only: its classes reach the runtime classpath through this module's + // merged jar below. + compileOnly(project(":plugwright-api")) +} + +// This is the module published under the plugin id, so its jar must carry the api, core and +// mode classes too — none of them are published under their own coordinates. +val apiJar = project(":plugwright-api").tasks.named("jar", Jar::class) +val coreJar = project(":plugwright-core").tasks.named("jar", Jar::class) +val localJar = project(":plugwright-local").tasks.named("jar", Jar::class) +val externalJar = project(":plugwright-external").tasks.named("jar", Jar::class) + +tasks.named("jar") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(apiJar.map { zipTree(it.archiveFile) }) + from(coreJar.map { zipTree(it.archiveFile) }) + from(localJar.map { zipTree(it.archiveFile) }) + from(externalJar.map { zipTree(it.archiveFile) }) +} + +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" + } + } +} diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt b/gradle-plugin/plugwright-bundle/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt similarity index 59% rename from gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt rename to gradle-plugin/plugwright-bundle/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt index 5177a0d..6053412 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt +++ b/gradle-plugin/plugwright-bundle/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt @@ -1,5 +1,6 @@ package me.drownek.plugwright +import me.drownek.plugwright.external.ExternalMode import me.drownek.plugwright.local.LocalMode import org.gradle.api.Plugin import org.gradle.api.Project @@ -7,14 +8,15 @@ import org.gradle.api.Project /** * Entry point for the `io.github.drownek.plugwright` id. * - * Applies the mode-agnostic engine and registers the built-in modes — just `local` for - * now. A dedicated bundle module can take over this role once a second built-in mode - * exists to combine with it. + * Applies the mode-agnostic engine and registers both built-in modes: `local` and `external`. + * A third-party mode registers itself the same way, from its own plugin or from the build + * script directly, via `plugwright.registerMode(...)`. */ class PlugwrightPlugin : Plugin { override fun apply(project: Project) { project.pluginManager.apply(PlugwrightCorePlugin::class.java) val extension = project.extensions.getByType(PlugwrightExtension::class.java) extension.registerMode(LocalMode) + extension.registerMode(ExternalMode) } } diff --git a/gradle-plugin/plugwright-local/build.gradle.kts b/gradle-plugin/plugwright-local/build.gradle.kts index 689739f..8e80560 100644 --- a/gradle-plugin/plugwright-local/build.gradle.kts +++ b/gradle-plugin/plugwright-local/build.gradle.kts @@ -1,7 +1,5 @@ plugins { `kotlin-dsl` - `maven-publish` - id("com.gradle.plugin-publish") version "1.2.1" } dependencies { @@ -10,32 +8,7 @@ dependencies { implementation("org.yaml:snakeyaml:2.0") implementation(project(":plugwright-core")) - // Compile-time only: its classes reach the runtime classpath through plugwright-core's - // jar, which this module re-merges below. + // Compile-time only: its classes reach the runtime classpath through the bundle module's + // merged jar, which is what actually gets published. compileOnly(project(":plugwright-api")) } - -// This is the module published under the plugin id, so its jar must carry the api and -// core classes too — neither is published under its own coordinates. -val apiJar = project(":plugwright-api").tasks.named("jar", Jar::class) -val coreJar = project(":plugwright-core").tasks.named("jar", Jar::class) - -tasks.named("jar") { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - from(apiJar.map { zipTree(it.archiveFile) }) - from(coreJar.map { zipTree(it.archiveFile) }) -} - -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" - } - } -} diff --git a/gradle-plugin/settings.gradle.kts b/gradle-plugin/settings.gradle.kts index 765e552..d5455e2 100644 --- a/gradle-plugin/settings.gradle.kts +++ b/gradle-plugin/settings.gradle.kts @@ -2,10 +2,11 @@ rootProject.name = "plugwright" // plugwright-api — stable contract third-party modes compile against // plugwright-core — mode-agnostic engine: extension, mode registry, generic tasks -// plugwright-local — built-in "local" mode; also hosts the published plugin id for now, -// until a dedicated bundle module registers it alongside plugwright-external +// plugwright-local — built-in "local" mode // plugwright-external — built-in "external" mode: attaches to an already-running server +// plugwright-bundle — id "io.github.drownek.plugwright": applies core, registers local + external include(":plugwright-api") include(":plugwright-core") include(":plugwright-local") include(":plugwright-external") +include(":plugwright-bundle") From b8675bfd24f874fb5170a5dd226e4b3a1c67ffe6 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 22:44:13 +0300 Subject: [PATCH 09/15] feat(runner): add account pool, external environment, admin-bot console, ping/cleanup entry points - AccountPool merges pool/autoRegister/microsoft accounts, leased per test and released in the test's finally block. local's account generation is unchanged: it only degrades to a pool when Environment.accounts() is implemented, which local still doesn't do. - externalEnvironment: attaches to a running server, probes declared console channels in order (rcon via a dynamic import of the optional @plugwright/console-rcon package, else admin-bot), and honours joinThrottleMs on every bot connect via a new Environment.beforeJoin hook. - AdminBotConsole: a second mineflayer bot with staff rights, console commands sent through chat, responses read from its own buffer. Its connection goes through PlayerWrapper.join(), so it authenticates through the same onPlayerCreate hook a test bot does - runner.ts now wires that hook before env.setup() runs so this actually applies during environment setup, not just afterward. - resolveEnvironment is now async and falls back to a dynamic import(runtime.package) for any mode besides the two built-ins. - runPingSession/runCleanupSession + cli.ts --ping/--cleanup: connect and verify the console/auth without running tests, or replay the crash-recovery journal via each plugin's cleanup({ scope: 'manual' }) handler. --- runner-package/cli.ts | 18 ++- runner-package/lib/account.ts | 85 +++++++++- runner-package/lib/admin-bot-console.ts | 82 ++++++++++ runner-package/lib/environment.ts | 14 +- runner-package/lib/environments/external.ts | 170 ++++++++++++++++++++ runner-package/lib/session.ts | 1 + runner-package/lib/test-runner.ts | 33 +++- runner-package/runner.ts | 169 +++++++++++++++++-- 8 files changed, 552 insertions(+), 20 deletions(-) create mode 100644 runner-package/lib/admin-bot-console.ts create mode 100644 runner-package/lib/environments/external.ts diff --git a/runner-package/cli.ts b/runner-package/cli.ts index bf0ed79..566361a 100644 --- a/runner-package/cli.ts +++ b/runner-package/cli.ts @@ -1,8 +1,22 @@ #!/usr/bin/env node -import { runTestSession } from './runner.js'; +import { runTestSession, runPingSession, runCleanupSession } from './runner.js'; -runTestSession().catch((error: Error) => { +const argv = process.argv.slice(2); + +async function main(): Promise { + if (argv.includes('--ping')) { + await runPingSession(); + return; + } + if (argv.includes('--cleanup')) { + await runCleanupSession(); + return; + } + await runTestSession(); +} + +main().catch((error: Error) => { console.error('\nTest run failed:', error); process.exit(1); }); diff --git a/runner-package/lib/account.ts b/runner-package/lib/account.ts index b86e885..84841ce 100644 --- a/runner-package/lib/account.ts +++ b/runner-package/lib/account.ts @@ -1,3 +1,6 @@ +import { resolveSecret } from './config.js'; +import type { SecretRef } from './config.js'; + /** * A bot's login identity as seen by an environment and its auth plugin. `justCreated` is * the key field for authentication plugins: a fresh account needs to register, an existing @@ -8,13 +11,89 @@ export interface Account { password?: string; auth: 'offline' | 'microsoft'; justCreated: boolean; + /** Set for `microsoft` accounts: where mineflayer should cache the device-code token. */ + microsoftCacheDir?: string; } /** - * Stand-in used until a proper `AccountPool` (a later phase) exists. `local` bots are - * always fresh, unauthenticated offline-mode connections, so this is accurate today — it - * just isn't pluggable to other sources yet. + * Stand-in used when an environment has no [AccountPool] of its own — `local` bots are + * always fresh, unauthenticated offline-mode connections, so this stays exactly what it + * always was. */ export function syntheticAccount(username: string): Account { return { username, auth: 'offline', justCreated: true }; } + +export interface AccountsConfig { + pool?: Array<{ username: string; password: SecretRef }>; + autoRegister?: { usernamePattern: string; password: SecretRef; max: number } | null; + microsoft?: { accounts: string[]; cacheDir?: string | null } | null; +} + +/** Formats an auto-register username from a `pw_%04d`-style pattern. Only zero-padded + * decimal substitution is supported — no other printf feature. */ +function formatUsername(pattern: string, n: number): string { + return pattern.replace(/%(\d*)d/, (_match, width: string) => { + const digits = String(n); + return width ? digits.padStart(parseInt(width, 10), '0') : digits; + }); +} + +/** + * Leasable accounts for `external`, merged from three sources: a fixed `pool`, generated + * `autoRegister` names (fresh on first lease, reusable after), and `microsoft` accounts for + * an online-mode server. Accounts are leased per test and returned in `finally` — see + * `test-runner.ts`. + * + * Exhausted when every pool/microsoft slot is checked out and `autoRegister` (if any) has + * reached its `max`: `lease()` then throws rather than silently handing out an identity two + * concurrently-connected bots would fight over. + */ +export class AccountPool { + private readonly queue: Account[] = []; + private autoRegisterIssued = 0; + private readonly autoRegister: { usernamePattern: string; password: string; max: number } | null; + + constructor(config: AccountsConfig | null | undefined) { + for (const entry of config?.pool ?? []) { + this.queue.push({ username: entry.username, password: resolveSecret(entry.password), auth: 'offline', justCreated: false }); + } + for (const username of config?.microsoft?.accounts ?? []) { + this.queue.push({ + username, + auth: 'microsoft', + justCreated: false, + microsoftCacheDir: config?.microsoft?.cacheDir ?? undefined, + }); + } + this.autoRegister = config?.autoRegister + ? { + usernamePattern: config.autoRegister.usernamePattern, + password: resolveSecret(config.autoRegister.password), + max: config.autoRegister.max, + } + : null; + } + + async lease(): Promise { + const account = this.queue.shift(); + if (account) return account; + + if (this.autoRegister && this.autoRegisterIssued < this.autoRegister.max) { + this.autoRegisterIssued++; + const username = formatUsername(this.autoRegister.usernamePattern, this.autoRegisterIssued); + return { username, password: this.autoRegister.password, auth: 'offline', justCreated: true }; + } + + throw new Error( + 'AccountPool exhausted: no pool/microsoft account is free and accounts.autoRegister has reached its max' + ); + } + + /** Returns a leased account to the pool, `finally`-style. An `autoRegister`-created + * account comes back with `justCreated: false` — the server already registered it on + * its first lease, so the auth plugin logs in on every lease after. */ + release(account: Account): void { + this.queue.push(account.justCreated ? { ...account, justCreated: false } : account); + } +} diff --git a/runner-package/lib/admin-bot-console.ts b/runner-package/lib/admin-bot-console.ts new file mode 100644 index 0000000..6a962e8 --- /dev/null +++ b/runner-package/lib/admin-bot-console.ts @@ -0,0 +1,82 @@ +import type { ServerConsole } from './console.js'; +import type { Session } from './session.js'; +import type { BotConnectionOptions } from './environment.js'; +import type { Account } from './account.js'; +import { PlayerWrapper } from './player.js'; +import { sleep } from './utils.js'; + +/** + * A second mineflayer bot with staff rights, used as a console channel when nothing lower- + * level (RCON) is available. Commands go out through chat; responses are read back from this + * bot's own `PlayerWrapper.messageBuffer` — already isolated per bot, so console traffic + * naturally never mixes with a test player's chat log without this class keeping a second + * copy of the same lines. + * + * Connects lazily, on the first `probe()`: that's also where authentication happens, through + * the exact same `PlayerWrapper.join()` → `session.onPlayerCreate` path a test bot goes + * through, so a plugin's login flow applies here unmodified. + */ +export class AdminBotConsole implements ServerConsole { + readonly kind = 'admin-bot' as const; + readonly output = 'responses' as const; + + private player: PlayerWrapper | null = null; + + constructor( + private readonly session: Session, + private readonly connOpts: BotConnectionOptions, + private readonly identity: { username: string; password?: string }, + ) {} + + async probe(): Promise { + if (this.player) return true; + try { + const bot = this.session.createBot({ ...this.connOpts, username: this.identity.username }); + + const player = new PlayerWrapper(bot, this.session); + player._captureSpawnPromise(); + player._setBotOptions(this.connOpts); + const account: Account = { + username: this.identity.username, + password: this.identity.password, + auth: this.connOpts.auth === 'microsoft' ? 'microsoft' : 'offline', + justCreated: false, + }; + player._setAccount(account); + + await player.join(); + this.player = player; + return true; + } catch (error) { + console.warn(`[console] admin-bot probe failed: ${(error as Error).message}`); + return false; + } + } + + execute(cmd: string): void { + if (!this.player) throw new Error('admin-bot console is not connected'); + this.player.chat(toChatCommand(cmd)); + } + + async executeAndWait(cmd: string, timeoutMs: number = 5000): Promise { + if (!this.player) throw new Error('admin-bot console is not connected'); + const buffer = this.player.messageBuffer; + const since = buffer.length; + this.execute(cmd); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const lines = buffer.slice(since); + if (lines.length > 0) return lines.join('\n'); + await sleep(50); + } + throw new Error(`admin-bot console command timed out: ${cmd}`); + } +} + +/** stdio-style console commands use `minecraft:`; a chat-based console needs a leading + * slash instead. */ +function toChatCommand(cmd: string): string { + const stripped = cmd.startsWith('minecraft:') ? cmd.slice('minecraft:'.length) : cmd; + return stripped.startsWith('/') ? stripped : `/${stripped}`; +} diff --git a/runner-package/lib/environment.ts b/runner-package/lib/environment.ts index b37cfe7..61e43c6 100644 --- a/runner-package/lib/environment.ts +++ b/runner-package/lib/environment.ts @@ -1,5 +1,6 @@ import type { ServerConsole } from './console.js'; import type { Session } from './session.js'; +import type { AccountPool } from './account.js'; /** What an environment actually supports. Declared expectations in the DSL are checked * against this after `setup()`; a mismatch is printed once in the run header. */ @@ -18,12 +19,15 @@ export interface BotConnectionOptions { port: number; version?: string; auth: 'offline' | 'microsoft' | 'mojang'; + /** Cache directory for a Microsoft device-code token, so a CI machine doesn't redo the + * interactive flow on every run. Only meaningful when `auth === 'microsoft'`. */ + profilesFolder?: string; } /** * A Minecraft server the runner can point bots at, plus however it needs to be * prepared and torn down. `local` spawns and kills its own Paper process; - * `external` (a later phase) attaches to an already-running server instead. + * `external` attaches to an already-running one instead. */ export interface Environment { readonly id: string; @@ -33,5 +37,13 @@ export interface Environment { setup(session: Session): Promise; connection(): BotConnectionOptions; console(): ServerConsole | null; + /** Leasable accounts for this environment. Absent means "generate a throwaway + * `Test_` per bot" — `local`'s only mode, unchanged from before `AccountPool` + * existed. */ + accounts?(): AccountPool | null; + /** Called immediately before each bot connects. Environments that must not hammer a + * shared server (e.g. `external`'s `joinThrottleMs`) rate-limit connects here; the + * default (no-op when absent) matches `local`'s always-immediate connect. */ + beforeJoin?(): Promise; teardown(): Promise; } diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts new file mode 100644 index 0000000..e1705af --- /dev/null +++ b/runner-package/lib/environments/external.ts @@ -0,0 +1,170 @@ +import pc from 'picocolors'; +import type { Environment, EnvironmentCapabilities, BotConnectionOptions } from '../environment.js'; +import type { ServerConsole } from '../console.js'; +import type { Session } from '../session.js'; +import type { SecretRef } from '../config.js'; +import { resolveSecret } from '../config.js'; +import { AccountPool } from '../account.js'; +import type { AccountsConfig } from '../account.js'; +import { AdminBotConsole } from '../admin-bot-console.js'; +import { sleep } from '../utils.js'; + +export interface ExternalConsoleChannelConfig { + kind: 'rcon' | 'adminBot'; + port?: number; + username?: string; + password?: SecretRef; +} + +export interface ExternalEnvironmentConfig { + host: string; + port: number; + minecraftVersion?: string | null; + joinThrottleMs?: number | null; + console?: ExternalConsoleChannelConfig[] | null; + accounts?: AccountsConfig | null; +} + +const BASE_CAPABILITIES: EnvironmentCapabilities = { + console: false, + consoleOutput: 'none', + // Never assumed true: nothing here proves the leased accounts actually have op rights + // on the stand. A mode that can prove it would override this after setup(). + op: false, + freshState: false, + arbitraryUsernames: true, + lifecycle: false, + cleanupStrategy: 'compensating', +}; + +/** + * Attaches bots to a server this mode does not own: no spawn, no patch, no shutdown. What it + * does provide — a console channel (probed in declaration order), a merged account pool, and + * join throttling — exists because a shared, already-running stand can't offer the guarantees + * `local` gets for free from owning the whole process. + */ +class ExternalEnvironment implements Environment { + readonly id = 'external'; + + private readonly config: ExternalEnvironmentConfig; + private readonly accountPool: AccountPool; + private _capabilities: EnvironmentCapabilities = BASE_CAPABILITIES; + private _console: ServerConsole | null = null; + private lastJoinAt = 0; + + constructor(config: ExternalEnvironmentConfig) { + this.config = config; + this.accountPool = new AccountPool(config.accounts); + } + + get capabilities(): EnvironmentCapabilities { + return this._capabilities; + } + + accounts(): AccountPool { + return this.accountPool; + } + + async setup(session: Session): Promise { + const connOpts = this.connection(); + + for (const channel of this.config.console ?? []) { + const candidate = await this.buildChannel(channel, session, connOpts); + if (!candidate) continue; + try { + if (await candidate.probe()) { + this._console = candidate; + break; + } + console.log(pc.yellow(`[external] console channel "${channel.kind}" did not respond to probe()`)); + } catch (error) { + console.log(pc.yellow(`[external] console channel "${channel.kind}" failed to connect: ${(error as Error).message}`)); + } + } + + this._capabilities = { + ...BASE_CAPABILITIES, + console: this._console !== null, + consoleOutput: this._console?.output ?? 'none', + }; + + console.log(this._console + ? pc.green(`[external] console channel: ${this._console.kind} (output=${this._console.output})`) + : pc.dim('[external] no console channel reachable, running without one')); + } + + private async buildChannel( + channel: ExternalConsoleChannelConfig, + session: Session, + connOpts: BotConnectionOptions, + ): Promise { + if (channel.kind === 'rcon') { + // A bare string literal here would make tsc try to resolve + // "@plugwright/console-rcon"'s types even though it's an optional peer package + // this repo doesn't depend on — routing through a variable keeps the import + // dynamic (untyped) without an ambient module declaration. + const rconPackage = '@plugwright/console-rcon'; + let mod: any; + try { + mod = await import(rconPackage); + } catch { + console.error(pc.red( + 'Mode "external": console { rcon { } } needs the "@plugwright/console-rcon" package.\n' + + 'It installs automatically as part of plugwrightCompileTests — check that npm install\n' + + 'completed in your tests directory and that the package appears under node_modules.' + )); + return null; + } + const factory = mod.rconConsole ?? mod.default; + if (typeof factory !== 'function') { + console.error(pc.red('"@plugwright/console-rcon" has no "rconConsole" export')); + return null; + } + return factory({ + host: this.config.host, + port: channel.port ?? 25575, + password: channel.password ? resolveSecret(channel.password) : '', + }); + } + + if (channel.kind === 'adminBot') { + return new AdminBotConsole(session, connOpts, { + username: channel.username!, + password: channel.password ? resolveSecret(channel.password) : undefined, + }); + } + + return null; + } + + connection(): BotConnectionOptions { + return { + host: this.config.host, + port: this.config.port, + version: this.config.minecraftVersion ?? undefined, + // Per-bot auth is decided by the leased Account, not here — test-runner.ts + // overrides this default when the account is `microsoft`. + auth: 'offline', + }; + } + + console(): ServerConsole | null { + return this._console; + } + + async beforeJoin(): Promise { + const throttle = this.config.joinThrottleMs ?? 0; + if (throttle <= 0) return; + const wait = this.lastJoinAt + throttle - Date.now(); + if (wait > 0) await sleep(wait); + this.lastJoinAt = Date.now(); + } + + async teardown(): Promise { + // No lifecycle: the tested server isn't ours to stop. + } +} + +export function externalEnvironment(config: ExternalEnvironmentConfig): Environment { + return new ExternalEnvironment(config); +} diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index c25fea0..ba508bc 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -78,6 +78,7 @@ export class Session { username: options.username, version: options.version, auth: options.auth, + ...(options.profilesFolder ? { profilesFolder: options.profilesFolder } : {}), }); this.bots.push(bot); diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index bdee14a..6088d0b 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -4,6 +4,7 @@ import { PlayerWrapper } from './player.js'; import { ServerWrapper } from './server.js'; import { formatDuration } from './reporter.js'; import { syntheticAccount } from './account.js'; +import type { Account, AccountPool } from './account.js'; import type { Session } from './session.js'; import type { PluginHost } from './plugin-host.js'; import type { BotConnectionOptions } from './environment.js'; @@ -37,17 +38,38 @@ export async function runTestCase(params: RunTestCaseParams): Promise void | Promise> = []; + // Accounts leased from `session.env.accounts()` for this test, returned in the `finally` + // below regardless of how the test ends. + const leasedAccounts: Array<{ account: Account; pool: AccountPool }> = []; + const createPlayer = async (options?: { username?: string }): Promise => { - const uniqueId = randomUUID().split('-')[0]; - const botUsername = options?.username || `Test_${uniqueId}`; + // An explicit username always bypasses the pool: it names a specific bot identity + // the test wants, not "give me whatever account is free". + const pool = options?.username ? null : session.env.accounts?.() ?? null; + let account: Account; + if (pool) { + account = await pool.lease(); + leasedAccounts.push({ account, pool }); + } else { + const uniqueId = randomUUID().split('-')[0]; + account = syntheticAccount(options?.username || `Test_${uniqueId}`); + } + const botUsername = account.username; console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(botUsername)}`); - const bot = session.createBot({ ...connOpts, username: botUsername }); + await session.env.beforeJoin?.(); + + const botOptions: BotConnectionOptions = { + ...connOpts, + auth: account.auth, + profilesFolder: account.microsoftCacheDir, + }; + const bot = session.createBot({ ...botOptions, username: botUsername }); const player = new PlayerWrapper(bot, session); player._captureSpawnPromise(); player.setServerWrapper(server); - player._setBotOptions(connOpts); - player._setAccount(syntheticAccount(botUsername)); + player._setBotOptions(botOptions); + player._setAccount(account); await player.join(); return player; @@ -121,5 +143,6 @@ export async function runTestCase(params: RunTestCaseParams): Promise { + if (cfg.mode === 'local') { + return new LocalEnvironment(cfg.config as unknown as LocalEnvironmentConfig); } - return new LocalEnvironment(cfg.config as unknown as LocalEnvironmentConfig); + if (cfg.mode === 'external') { + return externalEnvironment(cfg.config as unknown as ExternalEnvironmentConfig); + } + if (cfg.runtime) { + let mod: any; + try { + mod = await import(cfg.runtime.package); + } catch (error) { + throw new Error( + `Environment "${cfg.name}" needs package "${cfg.runtime.package}", which failed to load: ` + + `${(error as Error).message}` + ); + } + const exportName = cfg.runtime.export ?? 'default'; + const factory = mod[exportName]; + if (typeof factory !== 'function') { + throw new Error(`Package "${cfg.runtime.package}" has no export "${exportName}" for environment "${cfg.name}"`); + } + return factory(cfg.config) as Environment; + } + throw new Error(`Environment "${cfg.name}" uses mode "${cfg.mode}", which this runner cannot run yet.`); } /** Capability keys from `testCase.requires` that `env` does not actually satisfy. A @@ -77,19 +108,21 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): ?? (process.env.TEST_TIMEOUT ? parseInt(process.env.TEST_TIMEOUT, 10) : 30000); const testResults: TestResult[] = []; - const env = resolveEnvironment(config.environment); + const env = await resolveEnvironment(config.environment); const session = new Session(env, config.journal ?? null); const plugins = new PluginHost(); await plugins.load(config.plugins ?? []); // Must happen before the first spec file is imported — see PluginHost.registerMatchers. plugins.registerMatchers(); + // Wired before env.setup(): an environment's own console channel can be a bot that needs + // to authenticate during setup() (see AdminBotConsole), which goes through this same hook. + session.onPlayerCreate = (player, ctx) => plugins.onPlayerCreate(player, ctx); let exitCode = 0; await env.setup(session); session.refreshConsole(); await plugins.setup(session); - session.onPlayerCreate = (player, ctx) => plugins.onPlayerCreate(player, ctx); try { const connOpts = env.connection(); @@ -198,3 +231,121 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): } export { sleep, poll, waitForAssertion, waitUntil, waitForStable } from './lib/utils.js'; + +/** + * `--ping`: connects to the environment, probes its declared console channel(s), and — if the + * environment has an account pool — leases one account and checks that it authenticates. No + * spec files run. Exits non-zero (after a readable diagnosis) on any problem, so it's safe to + * gate a build on. + */ +export async function runPingSession(config: RunnerConfig = loadRunnerConfig()): Promise { + console.log(pc.bold(`plugwright ping: environment "${config.environment.name}" (${config.environment.mode})`)); + + const env = await resolveEnvironment(config.environment); + const session = new Session(env, null); + const plugins = new PluginHost(); + await plugins.load(config.plugins ?? []); + plugins.registerMatchers(); + session.onPlayerCreate = (player, ctx) => plugins.onPlayerCreate(player, ctx); + + const problems: string[] = []; + let account: Account | undefined; + let pool: AccountPool | null = null; + + try { + await env.setup(session); + session.refreshConsole(); + await plugins.setup(session); + + if (env.capabilities.console) { + console.log(pc.green(`console: reachable (${session.console?.kind}, output=${session.console?.output})`)); + } else { + console.log(pc.yellow('console: unavailable')); + problems.push('no console channel could be reached'); + } + + pool = env.accounts?.() ?? null; + if (pool) { + try { + account = await pool.lease(); + await env.beforeJoin?.(); + const connOpts = env.connection(); + const bot = session.createBot({ ...connOpts, auth: account.auth, username: account.username }); + const player = new PlayerWrapper(bot, session); + player._captureSpawnPromise(); + player._setBotOptions({ ...connOpts, auth: account.auth }); + player._setAccount(account); + await player.join(); + console.log(pc.green(`auth: "${account.username}" connected and authenticated`)); + await session.disconnectBot(bot, account.username); + session.removeBot(bot); + } catch (error) { + problems.push(`auth check failed: ${(error as Error).message}`); + } + } else { + console.log(pc.dim('auth: no account pool configured for this environment, skipped')); + } + } catch (error) { + problems.push((error as Error).message); + } finally { + if (account && pool) pool.release(account); + await plugins.teardown(); + await session.disconnectAllBots(); + await env.teardown(); + } + + let exitCode = 0; + if (problems.length > 0) { + console.log(pc.red('\nplugwrightPing failed:')); + for (const problem of problems) console.log(pc.red(` - ${problem}`)); + exitCode = 1; + } else { + console.log(pc.green('\nplugwrightPing: environment is reachable')); + } + + setTimeout(() => process.exit(exitCode), 500).unref(); +} + +/** + * `--cleanup`: runs every loaded plugin's `cleanup({ scope: 'manual' })` handler and reports + * what the crash-recovery journal still has outstanding afterward. Replaying journal entries + * is the plugin's job — it owns what a typed entry means — this only gives it the chance. + */ +export async function runCleanupSession(config: RunnerConfig = loadRunnerConfig()): Promise { + console.log(pc.bold(`plugwright cleanup: environment "${config.environment.name}"`)); + + const env = await resolveEnvironment(config.environment); + const session = new Session(env, config.journal ?? null); + const plugins = new PluginHost(); + await plugins.load(config.plugins ?? []); + plugins.registerMatchers(); + + let exitCode = 0; + try { + const outstandingBefore = session.journal.outstanding(); + console.log(pc.dim(`journal: ${outstandingBefore.length} outstanding entr${outstandingBefore.length === 1 ? 'y' : 'ies'}`)); + + await env.setup(session); + session.refreshConsole(); + await plugins.setup(session); + + await plugins.runCleanup(session, 'manual'); + + const outstandingAfter = session.journal.outstanding(); + if (outstandingAfter.length > 0) { + console.log(pc.yellow(`journal: ${outstandingAfter.length} entr${outstandingAfter.length === 1 ? 'y' : 'ies'} still outstanding after cleanup`)); + for (const entry of outstandingAfter) console.log(pc.yellow(` - ${JSON.stringify(entry)}`)); + } else { + console.log(pc.green('journal: clean')); + } + } catch (error) { + console.error(pc.red(`cleanup failed: ${(error as Error).message}`)); + exitCode = 1; + } finally { + await plugins.teardown(); + await session.disconnectAllBots(); + await env.teardown(); + } + + setTimeout(() => process.exit(exitCode), 500).unref(); +} From 2aaf4c11c63cfb32fb969cf4ff95b8501e3e2a1f Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 23:00:48 +0300 Subject: [PATCH 10/15] fix(gradle): give mode-registered tasks the shared Node setup A task a mode registers through TaskRegistrationContext never got nodeVersion, downloadNode or nodeInstallDir, so any of them extending AbstractNodeTask failed validation before running. --- .../me/drownek/plugwright/PlugwrightCorePlugin.kt | 3 ++- .../plugwright/TaskRegistrationContextImpl.kt | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt index 0e0ec30..09a2ec8 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -92,13 +92,14 @@ class PlugwrightCorePlugin : Plugin { val matrixEntries = mutableListOf() val matrixPrepareTasks = mutableListOf>() + val runnerPackageSpecs = linkedSetOf() extension.environments.all.forEach { entry -> val envName = entry.spec.name val mode = entry.mode.erased() val ctx = TaskRegistrationContextImpl( project, envName, envName == primaryName, projectPluginJarProvider, - extension.testsDir.map { it.asFile } + extension.testsDir.map { it.asFile }, extension, defaultNodeInstallDir ) val journalFilePath = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl").get().asFile diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt index 63abda4..4fae8ff 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt @@ -19,7 +19,9 @@ internal class TaskRegistrationContextImpl( override val environmentName: String, private val isPrimary: Boolean, override val projectPluginJar: Provider, - override val testsDir: Provider + override val testsDir: Provider, + private val extension: PlugwrightExtension, + private val nodeInstallDir: File ) : TaskRegistrationContext { /** Set by [prepareTask]; read by the plugin once every mode has registered its tasks. */ @@ -50,7 +52,16 @@ internal class TaskRegistrationContextImpl( private fun registerInternal(suffix: String, type: Class, aliasBare: Boolean, action: T.() -> Unit): TaskProvider { val envSuffix = environmentName.replaceFirstChar { it.uppercaseChar() } val taskName = "plugwright$suffix$envSuffix" - val provider = project.tasks.register(taskName, type) { action() } + val provider = project.tasks.register(taskName, type) { + // A mode's task that shells out to Node gets the same Node resolution as core's + // own tasks, without every mode having to know where the shared cache lives. + if (this is AbstractNodeTask) { + nodeVersion.set(extension.nodeVersion) + downloadNode.set(extension.downloadNode) + this.nodeInstallDir.set(this@TaskRegistrationContextImpl.nodeInstallDir) + } + action() + } if (aliasBare && isPrimary && aliasedSuffixes.add(suffix)) { val aliasName = "plugwright$suffix" From 410fdfe921bd3ca7caf8eafda48f2b30c2cb726b Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 23:01:18 +0300 Subject: [PATCH 11/15] fix(runner): keep secrets lazy and report ping/cleanup exit codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccountPool resolved every password in its constructor, so a run that never connects a bot — a cleanup pass, a console-only ping — died on an unset variable it had no use for. The ping and cleanup entry points also relied on an unref'd timer to set the exit code, which never fires when nothing else holds the event loop open, so a failed check reported success. --- runner-package/lib/account.ts | 27 ++++++++++++++++++++------- runner-package/runner.ts | 6 ++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/runner-package/lib/account.ts b/runner-package/lib/account.ts index 84841ce..7bb7a36 100644 --- a/runner-package/lib/account.ts +++ b/runner-package/lib/account.ts @@ -24,6 +24,11 @@ export function syntheticAccount(username: string): Account { return { username, auth: 'offline', justCreated: true }; } +/** An account as it sits in the pool: an [Account] whose password may still be a reference to + * a secret rather than the secret itself. Once leased, the resolved password stays on the + * entry, so a second lease of the same account doesn't re-read the environment. */ +type PooledEntry = Account & { secret?: SecretRef }; + export interface AccountsConfig { pool?: Array<{ username: string; password: SecretRef }>; autoRegister?: { usernamePattern: string; password: SecretRef; max: number } | null; @@ -50,13 +55,16 @@ function formatUsername(pattern: string, n: number): string { * concurrently-connected bots would fight over. */ export class AccountPool { - private readonly queue: Account[] = []; + /** Queue entries keep the secret *reference*: a run that never connects a bot — a + * cleanup pass, a console-only ping — must not demand that the passwords be set. They + * are resolved in [lease], where an unset variable is a real problem. */ + private readonly queue: PooledEntry[] = []; private autoRegisterIssued = 0; - private readonly autoRegister: { usernamePattern: string; password: string; max: number } | null; + private readonly autoRegister: { usernamePattern: string; password: SecretRef; max: number } | null; constructor(config: AccountsConfig | null | undefined) { for (const entry of config?.pool ?? []) { - this.queue.push({ username: entry.username, password: resolveSecret(entry.password), auth: 'offline', justCreated: false }); + this.queue.push({ username: entry.username, secret: entry.password, auth: 'offline', justCreated: false }); } for (const username of config?.microsoft?.accounts ?? []) { this.queue.push({ @@ -69,20 +77,25 @@ export class AccountPool { this.autoRegister = config?.autoRegister ? { usernamePattern: config.autoRegister.usernamePattern, - password: resolveSecret(config.autoRegister.password), + password: config.autoRegister.password, max: config.autoRegister.max, } : null; } async lease(): Promise { - const account = this.queue.shift(); - if (account) return account; + const entry = this.queue.shift(); + if (entry) { + const { secret, ...account } = entry; + return secret && account.password === undefined + ? { ...account, password: resolveSecret(secret) } + : account; + } if (this.autoRegister && this.autoRegisterIssued < this.autoRegister.max) { this.autoRegisterIssued++; const username = formatUsername(this.autoRegister.usernamePattern, this.autoRegisterIssued); - return { username, password: this.autoRegister.password, auth: 'offline', justCreated: true }; + return { username, password: resolveSecret(this.autoRegister.password), auth: 'offline', justCreated: true }; } throw new Error( diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 8a13765..887e0dd 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -303,6 +303,9 @@ export async function runPingSession(config: RunnerConfig = loadRunnerConfig()): console.log(pc.green('\nplugwrightPing: environment is reachable')); } + // Both: the unref'd timer only fires if something else is still holding the loop + // open (a lingering socket); process.exitCode carries the result when it isn't. + process.exitCode = exitCode; setTimeout(() => process.exit(exitCode), 500).unref(); } @@ -347,5 +350,8 @@ export async function runCleanupSession(config: RunnerConfig = loadRunnerConfig( await env.teardown(); } + // Both: the unref'd timer only fires if something else is still holding the loop + // open (a lingering socket); process.exitCode carries the result when it isn't. + process.exitCode = exitCode; setTimeout(() => process.exit(exitCode), 500).unref(); } From 2db8b3c7d8e5a5305a5770a9f16ac25ddd944d6d Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 17 Aug 2026 00:19:25 +0300 Subject: [PATCH 12/15] fix(session): throttle bot error logging instead of logging every packet decode error mineflayer's default logErrors:true does an unconditional console.log(err) on every bot 'error' event. A backend sending a packet type outside the client's protocol data (e.g. an unrecognised particle) can emit that error hundreds of times a second; logging each one synchronously starves the event loop and the piped stdout, so timers that would otherwise fail a test fast stop firing in any useful time. Disable mineflayer's built-in logging and replace it with a throttled one (max once per second) that still reports total error count, keeping the connection usable against a server that outruns minecraft-data's coverage instead of hanging tests until their own timeout. Co-Authored-By: Claude Sonnet 5 --- runner-package/lib/session.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index ba508bc..93f201c 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -78,11 +78,32 @@ export class Session { username: options.username, version: options.version, auth: options.auth, + // mineflayer's own default (logErrors: true) does `bot.on('error', e => + // console.log(e))` unconditionally — fine for an occasional bad packet, but a + // server sending something outside the client's protocol data (e.g. a particle + // type minecraft-data doesn't recognise for this version) can emit that error + // hundreds of times a second. Full exceptions logged synchronously at that rate + // starve the event loop and the piped stdout, so timers that would otherwise + // fail the test fast stop firing in any useful time. Handled below instead, with + // logging throttled so the connection survives being spammed by a packet type it + // can't decode. + logErrors: false, ...(options.profilesFolder ? { profilesFolder: options.profilesFolder } : {}), }); this.bots.push(bot); + let errorCount = 0; + let lastLoggedAt = 0; + bot.on('error', (err: Error) => { + errorCount++; + const now = Date.now(); + if (now - lastLoggedAt > 1000) { + console.log(pc.dim(`[Bot] ${options.username} error (${errorCount} so far): ${err.message}`)); + lastLoggedAt = now; + } + }); + bot.once('end', (reason: string) => { console.log(pc.dim(`[Bot] ${options.username} connection ended: ${reason}`)); }); From da65879544d70cc8d9fc62d3d4faf75bde4e9478 Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 21 Aug 2026 02:06:10 +0300 Subject: [PATCH 13/15] feat(gradle): keep the IntelliJ sync trigger through the module split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream runs `plugwrightNpmInstall` after an IDEA sync, so a fresh checkout has its `node_modules` before anyone opens a spec file and finds every import unresolved. Splitting the plugin across modules moved the code that did it and merged that task into `plugwrightCompileTests`, which would have dropped the feature without anyone deciding to. It now hangs off `PlugwrightCorePlugin` and triggers the compile task, which installs and compiles in one step — so a sync leaves the workspace in a better state than it did before rather than the same one. Still guarded by `plugins.withId("idea")`: `idea-ext` is what carries `afterSync`, and applying it unconditionally would push a plugin onto builds that never asked for one. The plugin marker it compiles against comes from the Gradle Plugin Portal, which the root build now lists alongside Maven Central. --- gradle-plugin/build.gradle.kts | 2 ++ .../plugwright-core/build.gradle.kts | 3 ++ .../plugwright/PlugwrightCorePlugin.kt | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index ac35b44..eee25a7 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -6,6 +6,8 @@ allprojects { repositories { mavenCentral() + // The idea-ext plugin marker plugwright-core compiles against lives here, not in Central. + gradlePluginPortal() } } diff --git a/gradle-plugin/plugwright-core/build.gradle.kts b/gradle-plugin/plugwright-core/build.gradle.kts index d4185f9..02c63fb 100644 --- a/gradle-plugin/plugwright-core/build.gradle.kts +++ b/gradle-plugin/plugwright-core/build.gradle.kts @@ -7,6 +7,9 @@ val projectVersion = version.toString() dependencies { implementation(gradleApi()) implementation("com.google.code.gson:gson:2.10.1") + // Carries `afterSync`, used to run the compile task after an IntelliJ sync. Applied to a + // consumer's build only when that build already applies the `idea` plugin. + implementation("org.jetbrains.gradle.plugin.idea-ext:org.jetbrains.gradle.plugin.idea-ext.gradle.plugin:1.4.1") // The api module has no separate published coordinates yet, so its classes are // merged into this jar below. compileOnly keeps it out of the published POM. diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt index 09a2ec8..be7b9f8 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -2,11 +2,15 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNodeBuilder import org.gradle.api.GradleException +import org.gradle.api.plugins.ExtensionAware import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider +import org.gradle.plugins.ide.idea.model.IdeaModel +import org.jetbrains.gradle.ext.ProjectSettings +import org.jetbrains.gradle.ext.TaskTriggersConfig import java.io.File import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -56,11 +60,37 @@ class PlugwrightCorePlugin : Plugin { registerInitTask(project, extension, defaultNodeInstallDir) + registerIdeaSyncTrigger(project, plugwrightCompileTests) + project.afterEvaluate { wireEnvironments(project, extension, plugwrightCompileTests, defaultNodeInstallDir) } } + /** + * Runs the compile task after an IntelliJ IDEA sync, so a fresh checkout has its + * `node_modules` and its compiled specs before anyone opens a spec file and finds every + * import unresolved. + * + * Only when the project already applies the `idea` plugin — `idea-ext` is what carries + * `afterSync`, and applying it unconditionally would push a plugin onto builds that never + * asked for one. + */ + private fun registerIdeaSyncTrigger( + project: Project, + plugwrightCompileTests: TaskProvider, + ) { + project.plugins.withId("idea") { + project.pluginManager.apply("org.jetbrains.gradle.plugin.idea-ext") + project.afterEvaluate { + val ideaModel = project.extensions.findByType(IdeaModel::class.java) ?: return@afterEvaluate + val ideaProject = ideaModel.project as? ExtensionAware + val settings = ideaProject?.extensions?.findByType(ProjectSettings::class.java) as? ExtensionAware + settings?.extensions?.findByType(TaskTriggersConfig::class.java)?.afterSync(plugwrightCompileTests) + } + } + } + private fun wireEnvironments( project: Project, extension: PlugwrightExtension, From 86fd6fb8afe86407278713c335a049c689c9962f Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 22 Aug 2026 19:03:15 +0300 Subject: [PATCH 14/15] fix(gradle): wire the IDEA sync trigger when idea applies late IntelliJ applies the `idea` plugin to an already-evaluated project during sync, so the plugins.withId("idea") callback ran too late for Project.afterEvaluate and the sync failed with "Failed to apply plugin 'org.gradle.idea': Cannot run Project.afterEvaluate(Action) when the project is already evaluated". Register the afterSync trigger straight away when the project is already evaluated, and keep the deferred path for the normal case. --- .../plugwright/PlugwrightCorePlugin.kt | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt index be7b9f8..5fd75e3 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -75,6 +75,11 @@ class PlugwrightCorePlugin : Plugin { * Only when the project already applies the `idea` plugin — `idea-ext` is what carries * `afterSync`, and applying it unconditionally would push a plugin onto builds that never * asked for one. + * + * That plugin does not always show up while the project is being configured: an IDEA sync + * applies it to an already-evaluated project, and `Project.afterEvaluate` throws once that + * has happened. So the trigger is wired right away in that case and deferred only while + * configuration is still running. */ private fun registerIdeaSyncTrigger( project: Project, @@ -82,15 +87,28 @@ class PlugwrightCorePlugin : Plugin { ) { project.plugins.withId("idea") { project.pluginManager.apply("org.jetbrains.gradle.plugin.idea-ext") - project.afterEvaluate { - val ideaModel = project.extensions.findByType(IdeaModel::class.java) ?: return@afterEvaluate - val ideaProject = ideaModel.project as? ExtensionAware - val settings = ideaProject?.extensions?.findByType(ProjectSettings::class.java) as? ExtensionAware - settings?.extensions?.findByType(TaskTriggersConfig::class.java)?.afterSync(plugwrightCompileTests) + if (project.state.executed) { + wireIdeaSyncTrigger(project, plugwrightCompileTests) + } else { + project.afterEvaluate { wireIdeaSyncTrigger(project, plugwrightCompileTests) } } } } + /** + * The `taskTriggers` block lives on the root project's `idea.project.settings`, so on a + * subproject the lookup finds nothing and the trigger is simply skipped. + */ + private fun wireIdeaSyncTrigger( + project: Project, + plugwrightCompileTests: TaskProvider, + ) { + val ideaModel = project.extensions.findByType(IdeaModel::class.java) ?: return + val ideaProject = ideaModel.project as? ExtensionAware + val settings = ideaProject?.extensions?.findByType(ProjectSettings::class.java) as? ExtensionAware + settings?.extensions?.findByType(TaskTriggersConfig::class.java)?.afterSync(plugwrightCompileTests) + } + private fun wireEnvironments( project: Project, extension: PlugwrightExtension, From 5bdcc6db6ebb8a6437edb9258fec5af1668bdb8d Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 22 Aug 2026 19:03:15 +0300 Subject: [PATCH 15/15] build(gradle): load kotlin-dsl once for all subprojects Applying `kotlin-dsl` from every subproject's plugins block loaded the Kotlin plugin several times, which Gradle warns is unsupported: "The Kotlin Gradle plugin was loaded multiple times in different subprojects ... ':plugwright-api', ':plugwright-bundle'". Declare it once in the root build with `apply false` and hand it to the subprojects from the shared subprojects block. --- gradle-plugin/build.gradle.kts | 9 +++++++++ gradle-plugin/plugwright-api/build.gradle.kts | 4 ---- gradle-plugin/plugwright-bundle/build.gradle.kts | 1 - gradle-plugin/plugwright-core/build.gradle.kts | 4 ---- gradle-plugin/plugwright-external/build.gradle.kts | 4 ---- gradle-plugin/plugwright-local/build.gradle.kts | 4 ---- 6 files changed, 9 insertions(+), 17 deletions(-) diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index eee25a7..081308b 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -1,3 +1,10 @@ +// 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` apply false +} + val projectVersion = file("../version.txt").readText().trim() allprojects { @@ -12,6 +19,8 @@ allprojects { } subprojects { + apply(plugin = "org.gradle.kotlin.kotlin-dsl") + plugins.withId("java") { extensions.configure { toolchain { diff --git a/gradle-plugin/plugwright-api/build.gradle.kts b/gradle-plugin/plugwright-api/build.gradle.kts index 1dd0b3b..db63032 100644 --- a/gradle-plugin/plugwright-api/build.gradle.kts +++ b/gradle-plugin/plugwright-api/build.gradle.kts @@ -1,7 +1,3 @@ -plugins { - `kotlin-dsl` -} - dependencies { implementation(gradleApi()) } diff --git a/gradle-plugin/plugwright-bundle/build.gradle.kts b/gradle-plugin/plugwright-bundle/build.gradle.kts index d5fc54c..71ea4d5 100644 --- a/gradle-plugin/plugwright-bundle/build.gradle.kts +++ b/gradle-plugin/plugwright-bundle/build.gradle.kts @@ -1,5 +1,4 @@ plugins { - `kotlin-dsl` `maven-publish` id("com.gradle.plugin-publish") version "1.2.1" } diff --git a/gradle-plugin/plugwright-core/build.gradle.kts b/gradle-plugin/plugwright-core/build.gradle.kts index 02c63fb..c86e57c 100644 --- a/gradle-plugin/plugwright-core/build.gradle.kts +++ b/gradle-plugin/plugwright-core/build.gradle.kts @@ -1,7 +1,3 @@ -plugins { - `kotlin-dsl` -} - val projectVersion = version.toString() dependencies { diff --git a/gradle-plugin/plugwright-external/build.gradle.kts b/gradle-plugin/plugwright-external/build.gradle.kts index d46c81e..ce5af71 100644 --- a/gradle-plugin/plugwright-external/build.gradle.kts +++ b/gradle-plugin/plugwright-external/build.gradle.kts @@ -1,7 +1,3 @@ -plugins { - `kotlin-dsl` -} - dependencies { implementation(gradleApi()) implementation(project(":plugwright-core")) diff --git a/gradle-plugin/plugwright-local/build.gradle.kts b/gradle-plugin/plugwright-local/build.gradle.kts index 8e80560..d2c2352 100644 --- a/gradle-plugin/plugwright-local/build.gradle.kts +++ b/gradle-plugin/plugwright-local/build.gradle.kts @@ -1,7 +1,3 @@ -plugins { - `kotlin-dsl` -} - dependencies { implementation(gradleApi()) implementation("com.google.code.gson:gson:2.10.1")