From 169bf6a5866b34df35e3702daf50ed120c37f8f2 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 21:06:11 +0300 Subject: [PATCH 001/125] 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 002/125] 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 003/125] 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 004/125] 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 005/125] 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 006/125] 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 007/125] 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 008/125] 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 009/125] 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 010/125] 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 011/125] 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 012/125] 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 013/125] 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 014/125] 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 015/125] 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") From e89bde68fca4319d810f4d357308a3df7beda7e6 Mon Sep 17 00:00:00 2001 From: Drownek Date: Sun, 23 Aug 2026 14:31:59 +0200 Subject: [PATCH 016/125] chore: bump to 3.0.0-dev.0 --- example_plugin/build.gradle.kts | 2 +- example_plugin/src/test/e2e/package-lock.json | 2 +- runner-package/package-lock.json | 4 ++-- runner-package/package.json | 2 +- version.txt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index 595f55d..a302baa 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -2,7 +2,7 @@ plugins { `java-library` id("de.eldoria.plugin-yml.bukkit") version "0.8.0" id("com.gradleup.shadow") version "9.0.0" - id("io.github.drownek.plugwright") version "2.0.4-dev.0" + id("io.github.drownek.plugwright") version "3.0.0-dev.0" } plugwright { diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index 49ce52c..5559adb 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -15,7 +15,7 @@ }, "../../../../runner-package": { "name": "@drownek/plugwright", - "version": "2.0.4-dev.0", + "version": "3.0.0-dev.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", diff --git a/runner-package/package-lock.json b/runner-package/package-lock.json index 27920d2..e021a78 100644 --- a/runner-package/package-lock.json +++ b/runner-package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@drownek/plugwright", - "version": "2.0.4-dev.0", + "version": "3.0.0-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@drownek/plugwright", - "version": "2.0.4-dev.0", + "version": "3.0.0-dev.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", diff --git a/runner-package/package.json b/runner-package/package.json index 4839a39..d5a1305 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -1,6 +1,6 @@ { "name": "@drownek/plugwright", - "version": "2.0.4-dev.0", + "version": "3.0.0-dev.0", "description": "End-to-end testing framework for Paper/Spigot Minecraft plugins", "type": "module", "main": "dist/runner.js", diff --git a/version.txt b/version.txt index 2b44836..2442953 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.4-dev.0 +3.0.0-dev.0 From e8461ad6cd53f32aaed086bb5043b48355df10bf Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 22:44:23 +0300 Subject: [PATCH 017/125] feat(console-rcon): add @plugwright/console-rcon package Implements ServerConsole over the Source RCON protocol directly on top of net.Socket - no third-party dependency, and kept out of @drownek/plugwright's own dependency list since it's only needed when a build script declares console { rcon { ... } }. executeAndWait resolves from the server's own response packet, so it doesn't need the minecraft:say round-trip the stdio and admin-bot consoles rely on. --- console-rcon-package/README.md | 32 +++ console-rcon-package/index.ts | 39 ++++ console-rcon-package/lib/protocol.ts | 40 ++++ console-rcon-package/lib/rcon-connection.ts | 121 ++++++++++++ console-rcon-package/package-lock.json | 203 ++++++++++++++++++++ console-rcon-package/package.json | 50 +++++ console-rcon-package/tsconfig.json | 25 +++ 7 files changed, 510 insertions(+) create mode 100644 console-rcon-package/README.md create mode 100644 console-rcon-package/index.ts create mode 100644 console-rcon-package/lib/protocol.ts create mode 100644 console-rcon-package/lib/rcon-connection.ts create mode 100644 console-rcon-package/package-lock.json create mode 100644 console-rcon-package/package.json create mode 100644 console-rcon-package/tsconfig.json diff --git a/console-rcon-package/README.md b/console-rcon-package/README.md new file mode 100644 index 0000000..c818d20 --- /dev/null +++ b/console-rcon-package/README.md @@ -0,0 +1,32 @@ +# @plugwright/console-rcon + +RCON server console for [plugwright](https://github.com/Drownek/plugwright)'s `external` mode. + +Implements the Source RCON protocol directly over Node's `net` module — no extra dependency. +Unlike the built-in `stdio` and `admin-bot` channels, RCON gives a synchronous response to +every command, so `executeAndWait` doesn't need any client-side sync trick. + +## Usage + +Declared through the `external` environment's DSL, not imported directly: + +```kotlin +environments { + create("gtamine", ExternalMode) { + console { + rcon { + port.set(25575) + password.set(secret.env("RCON_PASS")) + } + } + } +} +``` + +`plugwrightCompileTests` installs this package automatically once a build script declares an +`rcon` block. If it's missing from `node_modules`, the runner prints a message telling you to +run `npm install` in your tests directory rather than a stack trace. + +## License + +MIT diff --git a/console-rcon-package/index.ts b/console-rcon-package/index.ts new file mode 100644 index 0000000..36073d5 --- /dev/null +++ b/console-rcon-package/index.ts @@ -0,0 +1,39 @@ +import type { ServerConsole } from '@drownek/plugwright'; +import { RconConnection } from './lib/rcon-connection.js'; + +export interface RconConsoleConfig { + host: string; + port: number; + password: string; +} + +/** + * `ServerConsole` over RCON: unlike `stdio` and `admin-bot`, the protocol gives a synchronous + * response to every command, so `executeAndWait` doesn't need the `minecraft:say ` + * round-trip trick those two rely on. + */ +export function rconConsole(config: RconConsoleConfig): ServerConsole { + const connection = new RconConnection(config.host, config.port, config.password); + + return { + kind: 'rcon', + output: 'responses', + + async probe(): Promise { + try { + await connection.ensureConnected(); + return true; + } catch { + return false; + } + }, + + execute(cmd: string): void { + connection.execute(cmd); + }, + + async executeAndWait(cmd: string, timeoutMs: number = 5000): Promise { + return connection.executeAndWait(cmd, timeoutMs); + }, + }; +} diff --git a/console-rcon-package/lib/protocol.ts b/console-rcon-package/lib/protocol.ts new file mode 100644 index 0000000..85de2de --- /dev/null +++ b/console-rcon-package/lib/protocol.ts @@ -0,0 +1,40 @@ +/** + * Wire format for the Source RCON protocol (used unmodified by vanilla/Paper/Spigot): + * a 4-byte little-endian length prefix, a 4-byte request id, a 4-byte packet type, the + * payload as a null-terminated string, and one extra trailing null byte. + */ +export const PacketType = { + RESPONSE_VALUE: 0, + EXECCOMMAND: 2, + AUTH_RESPONSE: 2, + AUTH: 3, +} as const; + +export interface DecodedPacket { + id: number; + type: number; + payload: string; +} + +export function encodePacket(id: number, type: number, payload: string): Buffer { + const payloadBuf = Buffer.from(payload, 'utf8'); + const bodySize = 4 + 4 + payloadBuf.length + 2; // id + type + payload + 2 null terminators + const buf = Buffer.alloc(4 + bodySize); + let offset = 0; + buf.writeInt32LE(bodySize, offset); offset += 4; + buf.writeInt32LE(id, offset); offset += 4; + buf.writeInt32LE(type, offset); offset += 4; + payloadBuf.copy(buf, offset); offset += payloadBuf.length; + buf.writeUInt8(0, offset); offset += 1; + buf.writeUInt8(0, offset); + return buf; +} + +/** Decodes one packet body — everything after the 4-byte length prefix a caller already + * stripped off while reassembling the stream. */ +export function decodePacketBody(body: Buffer): DecodedPacket { + const id = body.readInt32LE(0); + const type = body.readInt32LE(4); + const payload = body.toString('utf8', 8, body.length - 2); + return { id, type, payload }; +} diff --git a/console-rcon-package/lib/rcon-connection.ts b/console-rcon-package/lib/rcon-connection.ts new file mode 100644 index 0000000..6e7b515 --- /dev/null +++ b/console-rcon-package/lib/rcon-connection.ts @@ -0,0 +1,121 @@ +import { createConnection, Socket } from 'net'; +import { PacketType, decodePacketBody, encodePacket } from './protocol.js'; + +interface Waiter { + resolve: (payload: string) => void; + reject: (error: Error) => void; +} + +/** + * One authenticated RCON connection: connects and authenticates lazily on first use, + * reassembles the length-prefixed packet stream, and matches responses back to callers by + * request id. Reconnects on the next call after the socket closes — an RCON server dropping + * an idle connection is normal, not a hard failure. + */ +export class RconConnection { + private socket: Socket | null = null; + private connectPromise: Promise | null = null; + private inbound: Buffer = Buffer.alloc(0); + private nextId = 1; + private pendingAuth: Waiter | null = null; + private readonly pending = new Map(); + + constructor( + private readonly host: string, + private readonly port: number, + private readonly password: string, + ) {} + + async ensureConnected(): Promise { + if (this.connectPromise) return this.connectPromise; + + this.connectPromise = new Promise((resolve, reject) => { + const socket = createConnection({ host: this.host, port: this.port }); + this.socket = socket; + + socket.once('connect', () => { + this.pendingAuth = { + resolve: () => resolve(), + reject: (err) => reject(err), + }; + const id = this.nextId++; + socket.write(encodePacket(id, PacketType.AUTH, this.password)); + }); + + socket.on('data', (chunk) => this.onData(chunk)); + + socket.once('error', (err) => { + this.connectPromise = null; + reject(err); + }); + + socket.once('close', () => { + this.connectPromise = null; + this.socket = null; + const closedError = new Error('RCON connection closed'); + this.pendingAuth?.reject(closedError); + this.pendingAuth = null; + for (const waiter of this.pending.values()) waiter.reject(closedError); + this.pending.clear(); + }); + }); + + return this.connectPromise; + } + + private onData(chunk: Buffer): void { + this.inbound = this.inbound.length > 0 ? Buffer.concat([this.inbound, chunk]) : chunk; + + while (this.inbound.length >= 4) { + const size = this.inbound.readInt32LE(0); + if (this.inbound.length < 4 + size) break; + + const body = this.inbound.subarray(4, 4 + size); + this.inbound = this.inbound.subarray(4 + size); + this.handlePacket(decodePacketBody(body)); + } + } + + private handlePacket(packet: { id: number; type: number; payload: string }): void { + if (packet.type === PacketType.AUTH_RESPONSE && this.pendingAuth) { + const waiter = this.pendingAuth; + this.pendingAuth = null; + if (packet.id === -1) waiter.reject(new Error('RCON authentication failed: wrong password')); + else waiter.resolve(''); + return; + } + + const waiter = this.pending.get(packet.id); + if (waiter) { + this.pending.delete(packet.id); + waiter.resolve(packet.payload); + } + } + + async executeAndWait(cmd: string, timeoutMs: number): Promise { + await this.ensureConnected(); + const socket = this.socket; + if (!socket) throw new Error('RCON connection is not open'); + + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`RCON command timed out after ${timeoutMs}ms: ${cmd}`)); + }, timeoutMs); + + this.pending.set(id, { + resolve: (payload) => { clearTimeout(timer); resolve(payload); }, + reject: (err) => { clearTimeout(timer); reject(err); }, + }); + + socket.write(encodePacket(id, PacketType.EXECCOMMAND, cmd)); + }); + } + + execute(cmd: string): void { + this.executeAndWait(cmd, 5000).catch((error: Error) => { + console.error(`[rcon] command failed: ${cmd}: ${error.message}`); + }); + } +} diff --git a/console-rcon-package/package-lock.json b/console-rcon-package/package-lock.json new file mode 100644 index 0000000..eb1a967 --- /dev/null +++ b/console-rcon-package/package-lock.json @@ -0,0 +1,203 @@ +{ + "name": "@plugwright/console-rcon", + "version": "3.0.0-dev.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@plugwright/console-rcon", + "version": "3.0.0-dev.0", + "license": "MIT", + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@drownek/plugwright": ">=3.0.0-dev.0" + } + }, + "../runner-package": { + "name": "@drownek/plugwright", + "version": "3.0.0-dev.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "mineflayer": "^4.0.0", + "picocolors": "^1.1.1", + "source-map-support": "^0.5.21" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.5", + "@types/source-map-support": "^0.5.10", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@drownek/plugwright": { + "resolved": "../runner-package", + "link": true + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/console-rcon-package/package.json b/console-rcon-package/package.json new file mode 100644 index 0000000..a182928 --- /dev/null +++ b/console-rcon-package/package.json @@ -0,0 +1,50 @@ +{ + "name": "@plugwright/console-rcon", + "version": "3.0.0-dev.0", + "description": "RCON server console for plugwright's \"external\" mode", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "rimraf dist && tsc", + "prepare": "npm run build", + "prepublishOnly": "npm run build", + "watch": "tsc --watch", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "keywords": [ + "minecraft", + "rcon", + "plugwright", + "testing" + ], + "author": "drownek", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Drownek/plugwright.git", + "directory": "console-rcon-package" + }, + "homepage": "https://github.com/Drownek/plugwright#readme", + "bugs": { + "url": "https://github.com/Drownek/plugwright/issues" + }, + "peerDependencies": { + "@drownek/plugwright": ">=3.0.0-dev.0" + }, + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/console-rcon-package/tsconfig.json b/console-rcon-package/tsconfig.json new file mode 100644 index 0000000..f2df9c3 --- /dev/null +++ b/console-rcon-package/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} From 3522191317fb841e7ed3e4181774f5080de5c807 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 22:44:33 +0300 Subject: [PATCH 018/125] feat(auth-authme): add reference AuthMe authentication plugin @plugwright/auth-authme: on every bot connection - initial join, rejoin, and the external mode's admin-bot console, all of which go through the same onPlayerCreate hook - waits for the login or register prompt and answers it: /register for a freshly generated account (account.justCreated), /login otherwise. Commands and prompt/success patterns are configurable options; Microsoft accounts are skipped since AuthMe never prompts them. Ships a preflight spec (auth.spec.js) so a broken login flow surfaces as a named failure at the top of the report instead of buried in the first user test that happens to create a bot. --- auth-authme-package/README.md | 46 ++++++ auth-authme-package/auth.spec.ts | 10 ++ auth-authme-package/index.ts | 80 ++++++++++ auth-authme-package/package-lock.json | 203 ++++++++++++++++++++++++++ auth-authme-package/package.json | 50 +++++++ auth-authme-package/tsconfig.json | 25 ++++ 6 files changed, 414 insertions(+) create mode 100644 auth-authme-package/README.md create mode 100644 auth-authme-package/auth.spec.ts create mode 100644 auth-authme-package/index.ts create mode 100644 auth-authme-package/package-lock.json create mode 100644 auth-authme-package/package.json create mode 100644 auth-authme-package/tsconfig.json diff --git a/auth-authme-package/README.md b/auth-authme-package/README.md new file mode 100644 index 0000000..2559e4d --- /dev/null +++ b/auth-authme-package/README.md @@ -0,0 +1,46 @@ +# @plugwright/auth-authme + +Reference [plugwright](https://github.com/Drownek/plugwright) authentication plugin for a +server running AuthMe (or anything else with the same login/register-by-chat flow). + +On every bot connection — the initial join, a `player.rejoin()`, and the `external` mode's +admin-bot console — it waits for the login or register prompt and answers it: + +- `account.justCreated` → `/register ` +- otherwise → `/login ` + +Microsoft (online-mode) accounts are left alone; AuthMe never prompts them. + +## Usage + +```kotlin +environments { + create("gtamine", ExternalMode) { + plugins { + npm("@plugwright/auth-authme") { + options["loginCommand"] = "/log" + } + } + } +} +``` + +## Options + +| Option | Default | Meaning | +|---|---|---| +| `loginCommand` | `/login` | Sent (with the password appended) for an existing account | +| `registerCommand` | `/register` | Sent (with the password twice) for a freshly generated account | +| `loginPromptPattern` | `log ?in\|password` | Regex matched against server messages to detect the login prompt | +| `registerPromptPattern` | `regist` | Regex matched against server messages to detect the register prompt | +| `successPattern` | `success\|welcome\|logged in\|authenticat` | Regex confirming the command worked | +| `timeoutMs` | `15000` | How long to wait for each prompt/confirmation | + +A `preflight` test ships alongside the plugin and runs before any user spec: if the +login/register handshake above ever fails, the very first bot connection already throws, so +the preflight test mostly exists to put a clear, named failure at the top of the report +instead of a buried stack trace. + +## License + +MIT diff --git a/auth-authme-package/auth.spec.ts b/auth-authme-package/auth.spec.ts new file mode 100644 index 0000000..879f4da --- /dev/null +++ b/auth-authme-package/auth.spec.ts @@ -0,0 +1,10 @@ +import { test } from '@drownek/plugwright'; + +// If the login/register handshake in `onPlayerCreate` failed or timed out, `createPlayer()` +// would already have thrown before this test body ever runs — so reaching here at all is +// the actual assertion. The check below just makes that visible in the report. +test('authme login/register flow completes', async ({ player }) => { + if (!player.username) { + throw new Error('authme preflight: player has no username after join'); + } +}); diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts new file mode 100644 index 0000000..1fe5b28 --- /dev/null +++ b/auth-authme-package/index.ts @@ -0,0 +1,80 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { definePlugin, poll } from '@drownek/plugwright'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface AuthAuthmeOptions { + /** Command sent for an existing account. */ + loginCommand?: string; + /** Command sent for a freshly generated account (`account.justCreated`); receives the + * password twice, matching AuthMe's own `/register `. */ + registerCommand?: string; + /** Regex (source only, case-insensitive) matched against server messages to detect the + * login prompt. */ + loginPromptPattern?: string; + /** Regex matched against server messages to detect the register prompt. */ + registerPromptPattern?: string; + /** Regex matched against server messages to confirm login/registration succeeded. */ + successPattern?: string; + /** How long to wait for each prompt/confirmation before giving up. */ + timeoutMs?: number; +} + +const DEFAULTS: Required = { + loginCommand: '/login', + registerCommand: '/register', + loginPromptPattern: 'log ?in|password', + registerPromptPattern: 'regist', + successPattern: 'success|welcome|logged in|authenticat', + timeoutMs: 15000, +}; + +// `onPlayerCreate` doesn't receive the plugin's options — only `setup()` does — so the +// resolved settings live here, captured once when the session starts. Safe because a runner +// process only ever runs one session at a time (see Session's own module-level caveats). +let resolved: Required = DEFAULTS; + +/** + * Reference authentication plugin for a server running AuthMe (or anything with the same + * login/register-by-chat flow). `onPlayerCreate` fires on every bot connection — the initial + * join and every `player.rejoin()` — and on the `external` mode's admin-bot console too, since + * that connects through the exact same `PlayerWrapper.join()` path a test bot does. + */ +export default definePlugin({ + name: 'authme', + apiVersion: 1, + tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight' }], + + setup({ options }) { + resolved = { ...DEFAULTS, ...options }; + }, + + async onPlayerCreate(player, { account }) { + // Online-mode (Microsoft) accounts never see AuthMe's offline-mode login wall. + if (account.auth === 'microsoft') return; + + const password = account.password; + if (!password) { + throw new Error(`authme: account "${account.username}" has no password to log in with`); + } + + const command = account.justCreated + ? `${resolved.registerCommand} ${password} ${password}` + : `${resolved.loginCommand} ${password}`; + const promptPattern = new RegExp(account.justCreated ? resolved.registerPromptPattern : resolved.loginPromptPattern, 'i'); + const successPattern = new RegExp(resolved.successPattern, 'i'); + + await poll(() => player.session.messages.find((m) => promptPattern.test(m)), { + timeout: resolved.timeoutMs, + message: `authme: never saw the ${account.justCreated ? 'register' : 'login'} prompt for "${account.username}"`, + }); + + player.chat(command); + + await poll(() => player.session.messages.find((m) => successPattern.test(m)), { + timeout: resolved.timeoutMs, + message: `authme: "${account.username}" did not confirm ${account.justCreated ? 'registration' : 'login'} in time`, + }); + }, +}); diff --git a/auth-authme-package/package-lock.json b/auth-authme-package/package-lock.json new file mode 100644 index 0000000..51e9f33 --- /dev/null +++ b/auth-authme-package/package-lock.json @@ -0,0 +1,203 @@ +{ + "name": "@plugwright/auth-authme", + "version": "3.0.0-dev.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@plugwright/auth-authme", + "version": "3.0.0-dev.0", + "license": "MIT", + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@drownek/plugwright": ">=3.0.0-dev.0" + } + }, + "../runner-package": { + "name": "@drownek/plugwright", + "version": "3.0.0-dev.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "mineflayer": "^4.0.0", + "picocolors": "^1.1.1", + "source-map-support": "^0.5.21" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.5", + "@types/source-map-support": "^0.5.10", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@drownek/plugwright": { + "resolved": "../runner-package", + "link": true + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/auth-authme-package/package.json b/auth-authme-package/package.json new file mode 100644 index 0000000..3a8f25e --- /dev/null +++ b/auth-authme-package/package.json @@ -0,0 +1,50 @@ +{ + "name": "@plugwright/auth-authme", + "version": "3.0.0-dev.0", + "description": "Reference plugwright authentication plugin for an AuthMe-style login/register flow", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "rimraf dist && tsc", + "prepare": "npm run build", + "prepublishOnly": "npm run build", + "watch": "tsc --watch", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "keywords": [ + "minecraft", + "authme", + "plugwright", + "testing" + ], + "author": "drownek", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Drownek/plugwright.git", + "directory": "auth-authme-package" + }, + "homepage": "https://github.com/Drownek/plugwright#readme", + "bugs": { + "url": "https://github.com/Drownek/plugwright/issues" + }, + "peerDependencies": { + "@drownek/plugwright": ">=3.0.0-dev.0" + }, + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/auth-authme-package/tsconfig.json b/auth-authme-package/tsconfig.json new file mode 100644 index 0000000..f2df9c3 --- /dev/null +++ b/auth-authme-package/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} From 0977204c178c7d27190b10835d75a852a163280b Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 23:00:56 +0300 Subject: [PATCH 019/125] feat(gradle): install the npm packages an environment declares runnerPackages() was declared by every mode and read by nobody, so an optional runner package such as the RCON console could never actually reach the test project. plugwrightCompileTests now installs the ones missing from node_modules, merged across environments, and only warns when an install fails: the runner already reports the missing package with the context to fix it. --- .../plugwright/PlugwrightCompileTestsTask.kt | 44 +++++++++++++++++++ .../plugwright/PlugwrightCorePlugin.kt | 9 ++++ 2 files changed, 53 insertions(+) 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 index c0de6af..253bd84 100644 --- 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 @@ -1,6 +1,8 @@ package me.drownek.plugwright import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction @@ -18,6 +20,15 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { @get:Optional abstract val testsDir: DirectoryProperty + /** + * Packages the configured environments need at runtime, as npm install arguments + * (`name` or `name@range`), merged across every environment so one install covers the + * whole matrix. Only the missing ones are installed — a package the test project already + * depends on (including a local `file:` link during development) is left alone. + */ + @get:Input + abstract val runnerPackages: ListProperty + init { group = "verification" description = "Install npm dependencies and compile the E2E tests" @@ -49,6 +60,8 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { runCommand(userTestsDirectory, nodePaths.npm, "install", env = npmEnv) } + installMissingRunnerPackages(userTestsDirectory, nodePaths, npmEnv) + // Build TypeScript tests if tsconfig.json exists val tsconfigFile = File(userTestsDirectory, "tsconfig.json") if (tsconfigFile.exists()) { @@ -58,4 +71,35 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { logger.lifecycle("No TypeScript config found, running JavaScript tests directly") } } + + private fun installMissingRunnerPackages( + testsDirectory: File, + nodePaths: NodeManager.NodePaths, + npmEnv: Map + ) { + val nodeModules = File(testsDirectory, "node_modules") + val missing = runnerPackages.get().filterNot { spec -> + File(nodeModules, packageNameOf(spec)).exists() + } + if (missing.isEmpty()) return + + logger.lifecycle("Installing runner packages: ${missing.joinToString(", ")}") + try { + // --no-save: these come from the build script's environments, so the test project's + // package.json shouldn't grow a second, drifting copy of the same decision. + runCommand(testsDirectory, nodePaths.npm, "install", "--no-save", *missing.toTypedArray(), env = npmEnv) + } catch (e: Exception) { + // A package that can't be installed is not a reason to stop compiling the tests: + // only the environment that asked for it is affected, and the runner reports the + // missing package with the context to fix it when that environment actually runs. + logger.warn("Could not install runner packages ${missing.joinToString(", ")}: ${e.message}") + } + } + + /** `@scope/name@^1.0.0` → `@scope/name`; the version separator is the last `@`, which for + * a scoped package is never the leading one. */ + private fun packageNameOf(spec: String): String { + val separator = spec.lastIndexOf('@') + return if (separator > 0) spec.substring(0, separator) else spec + } } 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 5fd75e3..0f5f5c8 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 @@ -53,6 +53,8 @@ class PlugwrightCorePlugin : Plugin { } testsDir.set(extension.testsDir) + // Filled in once every environment has been wired; empty until then. + runnerPackages.convention(emptyList()) nodeVersion.set(extension.nodeVersion) downloadNode.set(extension.downloadNode) nodeInstallDir.set(defaultNodeInstallDir) @@ -172,6 +174,11 @@ class PlugwrightCorePlugin : Plugin { if (project.hasProperty("testNames")) testNames.set(project.property("testNames") as String) } + // Merged across environments so the whole matrix is covered by one install. + mode.runnerPackages(entry.spec).forEach { ref -> + runnerPackageSpecs += if (ref.version != null) "${ref.name}@${ref.version}" else ref.name + } + val validation = ValidationContextImpl(envName, project.logger) mode.validate(entry.spec, validation) validationProblems += validation.errors.map { "[$envName] $it" } @@ -209,6 +216,8 @@ class PlugwrightCorePlugin : Plugin { } } + plugwrightCompileTests.configure { runnerPackages.set(runnerPackageSpecs.toList()) } + if (validationProblems.isNotEmpty()) { throw GradleException("plugwright configuration problems:\n" + validationProblems.joinToString("\n") { " $it" }) } From 3be6e8ef7f2ae81af4437fcf7440a70d6f4899b6 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sat, 15 Aug 2026 23:01:12 +0300 Subject: [PATCH 020/125] fix(runner): resolve optional packages from the test project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dynamic import of a package this one doesn't depend on — an optional console, a third-party mode, a plugin — resolved relative to the runner's own location, which finds nothing when the runner is a linked checkout instead of an entry in the test project's node_modules. Fall back to resolving from the test project, and include the underlying error in the missing-package message. --- runner-package/lib/environments/external.ts | 9 ++++--- runner-package/lib/plugin-host.ts | 3 ++- runner-package/lib/utils.ts | 27 ++++++++++++++++++++- runner-package/runner.ts | 3 ++- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts index e1705af..bd7736e 100644 --- a/runner-package/lib/environments/external.ts +++ b/runner-package/lib/environments/external.ts @@ -7,7 +7,7 @@ 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'; +import { sleep, importOptionalPackage } from '../utils.js'; export interface ExternalConsoleChannelConfig { kind: 'rcon' | 'adminBot'; @@ -106,12 +106,13 @@ class ExternalEnvironment implements Environment { const rconPackage = '@plugwright/console-rcon'; let mod: any; try { - mod = await import(rconPackage); - } catch { + mod = await importOptionalPackage(rconPackage); + } catch (error) { 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.' + 'completed in your tests directory and that the package appears under node_modules.\n' + + `(${(error as Error).message})` )); return null; } diff --git a/runner-package/lib/plugin-host.ts b/runner-package/lib/plugin-host.ts index 7499131..177a18f 100644 --- a/runner-package/lib/plugin-host.ts +++ b/runner-package/lib/plugin-host.ts @@ -1,6 +1,7 @@ import pc from 'picocolors'; import { RunnerMatchers } from './matchers.js'; import { PLUGIN_API_VERSION } from './plugin.js'; +import { importOptionalPackage } from './utils.js'; import type { PlugwrightPlugin, PluginTestRef } from './plugin.js'; import type { Session } from './session.js'; import type { PlayerWrapper } from './player.js'; @@ -27,7 +28,7 @@ export class PluginHost { for (const cfg of configs) { let mod: any; try { - mod = await import(cfg.specifier); + mod = await importOptionalPackage(cfg.specifier); } catch (error) { throw new Error(`Failed to load plugin "${cfg.specifier}": ${(error as Error).message}`); } diff --git a/runner-package/lib/utils.ts b/runner-package/lib/utils.ts index 8aa3bac..1d0a4c5 100644 --- a/runner-package/lib/utils.ts +++ b/runner-package/lib/utils.ts @@ -1,3 +1,8 @@ + +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + export const sleep = (ms: number, signal?: AbortSignal) => { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(new Error('Aborted')); @@ -144,4 +149,24 @@ export async function waitForStable( if (Date.now() >= stableDeadline) break; await sleep(Math.min(interval, Math.max(0, stableDeadline - Date.now())), signal); } -} \ No newline at end of file +} + +/** + * Imports a package that isn't a dependency of this one — an optional console package, a + * third-party mode, a plugin. A plain `import()` resolves from this file, which finds + * nothing when the runner itself is a linked checkout rather than an entry under the test + * project's `node_modules`; the fallback resolves from the test project instead, which is + * where the Gradle plugin installs these packages and is the runner's working directory. + */ +export async function importOptionalPackage(name: string): Promise { + try { + return await import(name); + } catch (error) { + const fromTestProject = createRequire(pathToFileURL(join(process.cwd(), 'package.json'))); + try { + return await import(pathToFileURL(fromTestProject.resolve(name)).href); + } catch { + throw error; + } + } +} diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 887e0dd..c6851e5 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -13,6 +13,7 @@ import { externalEnvironment } from './lib/environments/external.js'; import { PlayerWrapper } from './lib/player.js'; import { printTestSummary, writeJsonReport, writeJUnitReport } from './lib/reporter.js'; import { loadRunnerConfig } from './lib/config.js'; +import { importOptionalPackage } from './lib/utils.js'; import type { Environment } from './lib/environment.js'; import type { EnvironmentConfig, LocalEnvironmentConfig, RunnerConfig } from './lib/config.js'; import type { ExternalEnvironmentConfig } from './lib/environments/external.js'; @@ -61,7 +62,7 @@ async function resolveEnvironment(cfg: EnvironmentConfig): Promise if (cfg.runtime) { let mod: any; try { - mod = await import(cfg.runtime.package); + mod = await importOptionalPackage(cfg.runtime.package); } catch (error) { throw new Error( `Environment "${cfg.name}" needs package "${cfg.runtime.package}", which failed to load: ` + From 0ac3064e98c01f245efca964caf94dc60806bcec Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 00:20:35 +0300 Subject: [PATCH 021/125] feat(gradle): let any mode declare runner plugins PluginsSpec was in plugwright-external, so `plugins { npm(...) }` only existed on external environments. Nothing about a runner plugin is mode-specific: a local server running an authentication plugin needs the login hook exactly as much as a remote one does. Move the spec into plugwright-api and give LocalEnvironmentSpec the same block. An npm-named plugin now also joins the environment's runner packages, so plugwrightCompileTests installs it instead of leaving the runner to fail on a package nobody fetched. A plugin given as a path is left alone. --- .../me/drownek/plugwright/api}/PluginsSpec.kt | 13 ++++++++----- .../me/drownek/plugwright/PlugwrightCorePlugin.kt | 15 +++++++++++++++ .../external/ExternalEnvironmentSpec.kt | 1 + .../drownek/plugwright/external/ExternalMode.kt | 2 +- .../plugwright/local/LocalEnvironmentSpec.kt | 12 ++++++++++++ .../me/drownek/plugwright/local/LocalMode.kt | 2 ++ 6 files changed, 39 insertions(+), 6 deletions(-) rename gradle-plugin/{plugwright-external/src/main/kotlin/me/drownek/plugwright/external => plugwright-api/src/main/kotlin/me/drownek/plugwright/api}/PluginsSpec.kt (71%) diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PluginsSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt similarity index 71% rename from gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PluginsSpec.kt rename to gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt index 4e9e1f9..f4274dd 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PluginsSpec.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt @@ -1,6 +1,5 @@ -package me.drownek.plugwright.external +package me.drownek.plugwright.api -import me.drownek.plugwright.api.PluginRef import java.io.File /** Per-plugin options and inheritance flag, configured in the trailing lambda of [PluginsSpec.npm] @@ -16,13 +15,17 @@ class PluginRefSpec { /** * `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. + * Declares runner plugins to load for an environment: fixtures, matchers, authentication + * hooks, inherited tests. Lives in the API module rather than in one mode, because nothing + * about a plugin is mode-specific — a mode only has to pass [entries] to + * [TaskRegistrationContext.pluginConfigs] to support the block. */ class PluginsSpec { internal val entries = mutableListOf() + /** Entries declared so far, for a mode wiring them into its config. */ + fun refs(): List = entries.toList() + /** An npm-published plugin, e.g. `@plugwright/auth-authme`. */ fun npm(specifier: String, action: PluginRefSpec.() -> Unit = {}) { val spec = PluginRefSpec().apply(action) 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 0f5f5c8..b9faf69 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 @@ -190,6 +190,13 @@ class PlugwrightCorePlugin : Plugin { val pluginConfigsProvider = ctx.pluginConfigsProvider ?: project.provider { emptyList() } + // A plugin declared by npm name is installed alongside the environment's own + // runner packages; a plugin given as a path is already in the project. + pluginConfigsProvider.get() + .map { it.specifier } + .filter { isNpmPackageName(it) } + .forEach { runnerPackageSpecs += it } + testTask.configure { ctx.prepareTaskRef?.let { dependsOn(it) } environmentConfig.set(environmentConfigProvider) @@ -239,6 +246,14 @@ class PlugwrightCorePlugin : Plugin { } } + /** Whether a plugin specifier names an npm package rather than a file in the project. + * Paths are what `plugins { local(file(...)) }` produces; everything else is installable. */ + private fun isNpmPackageName(specifier: String): Boolean { + if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("\\")) return false + if (specifier.length > 1 && specifier[1] == ':') return false + return true + } + /** 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 { 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 index 7e12975..8c1b451 100644 --- 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 @@ -1,6 +1,7 @@ package me.drownek.plugwright.external import me.drownek.plugwright.api.EnvironmentSpec +import me.drownek.plugwright.api.PluginsSpec import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property 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 index 17582d5..1efa034 100644 --- 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 @@ -117,7 +117,7 @@ object ExternalMode : PlugwrightMode { val project = ctx.project val envName = spec.name - ctx.pluginConfigs(project.provider { spec.pluginsSpec.entries.toList() }) + ctx.pluginConfigs(project.provider { spec.pluginsSpec.refs() }) val configProvider = project.provider { ConfigNodeBuilder().also { serialize(spec, it) }.build() } val journalFile = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl") 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 index c011243..e9fa421 100644 --- 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 @@ -1,6 +1,7 @@ package me.drownek.plugwright.local import me.drownek.plugwright.api.EnvironmentSpec +import me.drownek.plugwright.api.PluginsSpec import me.drownek.plugwright.api.RunDirFile import org.gradle.api.file.DirectoryProperty import org.gradle.api.model.ObjectFactory @@ -47,6 +48,17 @@ class LocalEnvironmentSpec(private val environmentName: String, objects: ObjectF /** When true, the plugin under test is not built or installed automatically. */ val useExternalPluginsOnly: Property = objects.property(Boolean::class.java).convention(false) + internal val pluginsSpec: PluginsSpec = PluginsSpec() + + /** + * `plugins { npm("@plugwright/auth-authme"); local(file("...")) }` — runner plugins loaded + * for this environment. A locally spawned server still needs them whenever it runs a + * plugin that changes what a connecting bot has to do, authentication being the usual case. + */ + fun plugins(action: PluginsSpec.() -> Unit) { + pluginsSpec.action() + } + /** * DSL method for configuring plugin downloads. * ``` 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 index 4d9f401..861436b 100644 --- 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 @@ -91,6 +91,8 @@ object LocalMode : PlugwrightMode { ctx.prepareTask(provision) + ctx.pluginConfigs(project.provider { spec.pluginsSpec.refs() }) + ctx.environmentConfig(project.provider { buildConfigNode(spec, resolveJavaPath(javaLauncherProvider)) }) From 2b213ce104b72a44a0c7c478c2eee8579ecce1dd Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 00:20:58 +0300 Subject: [PATCH 022/125] feat(gradle): write the runtime reference a third-party mode needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner resolves `local` and `external` by mode id, since both are compiled into it. Anything else has to be imported from a package, and the config file had no field saying which one — so a custom mode always died with "mode X, which this runner cannot run yet". The first RunnerPackageRef naming an export is that package, so write it into environment.runtime for every mode but the two built-in ones. Also fixes the misspelled "Environment sumarries" header. --- .../drownek/plugwright/PlugwrightCorePlugin.kt | 18 +++++++++++++++++- .../drownek/plugwright/PlugwrightMatrixTask.kt | 6 +++++- .../drownek/plugwright/PlugwrightTestTask.kt | 13 +++++++++++++ .../me/drownek/plugwright/RunnerLauncher.kt | 15 +++++++++++++++ 4 files changed, 50 insertions(+), 2 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 b9faf69..22c99b9 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 @@ -152,6 +152,16 @@ class PlugwrightCorePlugin : Plugin { extension.testsDir.map { it.asFile }, extension, defaultNodeInstallDir ) val journalFilePath = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl").get().asFile + val modePackages = mode.runnerPackages(entry.spec) + + // The package a mode names an export in is the one holding its environment factory. + // Only a third-party mode needs it written into the config; `local` and `external` + // are compiled into the runner, which resolves them by mode id. + val runtimeRef = if (mode.id == "local" || mode.id == "external") { + null + } else { + modePackages.firstOrNull { it.export != null } + } val testTask = ctx.registerWithoutAlias("Test", PlugwrightTestTask::class.java) { doFirst { @@ -169,13 +179,17 @@ class PlugwrightCorePlugin : Plugin { nodeVersion.set(extension.nodeVersion) downloadNode.set(extension.downloadNode) nodeInstallDir.set(defaultNodeInstallDir) + runtimeRef?.let { ref -> + runtimePackage.set(ref.name) + ref.export?.let { runtimeExport.set(it) } + } if (project.hasProperty("testFiles")) testFiles.set(project.property("testFiles") as String) if (project.hasProperty("testNames")) testNames.set(project.property("testNames") as String) } // Merged across environments so the whole matrix is covered by one install. - mode.runnerPackages(entry.spec).forEach { ref -> + modePackages.forEach { ref -> runnerPackageSpecs += if (ref.version != null) "${ref.name}@${ref.version}" else ref.name } @@ -218,6 +232,8 @@ class PlugwrightCorePlugin : Plugin { environmentConfig = environmentConfigProvider, pluginConfigs = pluginConfigsProvider, journalFile = journalFilePath, + runtimePackage = runtimeRef?.name, + runtimeExport = runtimeRef?.export, ) 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 559c204..895a219 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 @@ -28,6 +28,8 @@ internal data class MatrixEnvironmentInput( val environmentConfig: Provider, val pluginConfigs: Provider>, val journalFile: File?, + val runtimePackage: String? = null, + val runtimeExport: String? = null, ) private data class EnvironmentSummary(val total: Int, val passed: Int, val failed: Int, val skipped: Int, val durationMs: Long) @@ -124,6 +126,8 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { junitReportFile = env.junitReportFile, pluginConfigs = env.pluginConfigs.get(), journalFile = env.journalFile, + runtimePackage = env.runtimePackage, + runtimeExport = env.runtimeExport, ) RunnerLauncher.writeConfig(entry) val cliJs = RunnerLauncher.resolveCliJs(env.testsDir) @@ -159,7 +163,7 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { private fun printSummaryTable(outcomes: List) { val nameWidth = outcomes.maxOf { it.env.name.length } logger.lifecycle("") - logger.lifecycle("Environment sumarries:") + logger.lifecycle("Environment summaries:") for ((env, summary, error) in outcomes) { val label = env.name.padEnd(nameWidth) val flag = if (env.allowFailure) " [allowFailure]" else "" 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 20e16cf..1cfb18e 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 @@ -59,6 +59,17 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { @get:Internal abstract val journalFile: RegularFileProperty + /** npm package exporting this environment's factory. Unset for a built-in mode, which the + * runner already carries. */ + @get:Input + @get:Optional + abstract val runtimePackage: Property + + /** Named export holding the factory; unset means the package's default export. */ + @get:Input + @get:Optional + abstract val runtimeExport: Property + /** Where the generated runner config is written before the CLI is invoked. */ @get:OutputFile abstract val configFile: RegularFileProperty @@ -111,6 +122,8 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { junitReportFile = junitReportFile.get().asFile, pluginConfigs = pluginConfigs.get(), journalFile = journalFile.orNull?.asFile, + runtimePackage = runtimePackage.orNull, + runtimeExport = runtimeExport.orNull, ) 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 01ddb59..36349ec 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 @@ -29,6 +29,10 @@ object RunnerLauncher { val jsonReportFile: File? = null, val junitReportFile: File? = null, val pluginConfigs: List = emptyList(), + /** npm package exporting this environment's factory; null for a built-in mode. */ + val runtimePackage: String? = null, + /** Named export holding the factory; null means the package's default export. */ + val runtimeExport: String? = null, /** Crash-recovery journal path for `Session.journal`; null disables on-disk persistence. */ val journalFile: File? = null, ) @@ -39,6 +43,17 @@ object RunnerLauncher { obj("environment") { put("name", entry.environmentName) put("mode", entry.modeId) + // Where the runner loads the environment implementation from. The built-in + // modes are compiled into the runner and ignore it; a third-party mode is + // only reachable through this reference. + if (entry.runtimePackage != null) { + obj("runtime") { + put("package", entry.runtimePackage) + putIfPresent("export", entry.runtimeExport) + } + } else { + putNull("runtime") + } put("config", entry.environmentConfig) } obj("tests") { From 5074f679cd022c624f2bd4956d64a123e796c2a3 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 00:21:38 +0300 Subject: [PATCH 023/125] fix(runner): authenticate before waiting for the spawn join() waited for the spawn event and only then fired onPlayerCreate. A server with a login wall never spawns an unauthenticated player, so the hook that would have logged the bot in never ran and every test died on a 30s spawn timeout. Wait for the play state instead, run the hook there, then wait for the spawn. Message listeners now go up before the first await as well: the login prompt arrives immediately, and a prompt that lands before the buffer exists is one no authentication plugin can answer. --- runner-package/lib/player.ts | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 859bca3..be12fb7 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -114,14 +114,42 @@ export class PlayerWrapper { this._captureSpawnPromise(timeout); } - await this._spawnPromise; - this._spawnPromise = null; - + // Listeners go up before the first await: a login wall greets the bot as soon as it + // enters the play state, and a prompt that arrives before the message buffer exists + // is a prompt no authentication plugin can answer. this._registerPersistentListeners(); if (this.account) { + // Authentication has to happen while the server still holds the player: AuthMe and + // friends keep an unauthenticated bot out of the world entirely, so waiting for the + // spawn first would wait for something login is the precondition of. + await Promise.race([this._spawnPromise, this._waitForLogin(timeout)]); await this.session.onPlayerCreate?.(this, { account: this.account, env: this.session.env }); } + + await this._spawnPromise; + this._spawnPromise = null; + } + + /** Resolves once the client is in the play state, where chat works and the server's login + * prompt has been delivered. Never rejects on its own — it is raced against the spawn + * promise, which already fails on a kick, an error or a timeout. */ + private _waitForLogin(timeout: number): Promise { + if (this.bot.entity) return Promise.resolve(); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.bot.removeListener('login', onLogin); + resolve(); + }, timeout); + + const onLogin = (): void => { + clearTimeout(timer); + resolve(); + }; + + this.bot.once('login', onLogin); + }); } /** @internal */ From 2e1054a07d6db642641074e885d033d58a160757 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 00:21:47 +0300 Subject: [PATCH 024/125] feat(runner): run op and command sync through a console that answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeOp waited for "Made X a server operator" in the player's chat, and deOp waited for a `say` marker to come back. Both assume the whole server log reaches the bot, which is true for the stdio console and false for RCON: the answer goes back over the RCON socket, so every op-dependent test timed out against an external server. When the console returns responses, run the command through it and read the answer. Also expose executeAndWait on ServerWrapper, and report op: true for an external environment once a console channel answers — having a console is what being able to op means. --- runner-package/lib/environments/external.ts | 3 +++ runner-package/lib/player.ts | 23 ++++++++++++++++++++- runner-package/lib/server.ts | 9 ++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts index bd7736e..8a24fbc 100644 --- a/runner-package/lib/environments/external.ts +++ b/runner-package/lib/environments/external.ts @@ -86,6 +86,9 @@ class ExternalEnvironment implements Environment { ...BASE_CAPABILITIES, console: this._console !== null, consoleOutput: this._console?.output ?? 'none', + // A reachable console is the ability to run `op`, which is what this capability + // claims. Without one there is no way to grant it, hence the false in the base. + op: this._console !== null, }; console.log(this._console diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index be12fb7..4fc1052 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -233,7 +233,19 @@ export class PlayerWrapper { async makeOp(): Promise { this.requireServer(); - this.serverWrapper!.execute(`minecraft:op ${this.username}`); + const command = `minecraft:op ${this.username}`; + + // A console that answers (RCON) says whether the command worked; the confirmation is + // never broadcast to the player, so there is nothing to wait for in the chat buffer. + if (this.session.console?.output === 'responses') { + const response = await this.serverWrapper!.executeAndWait(command); + // "Made X a server operator" on success, "Nothing changed. The player already is + // an operator" when it was already granted — both mean the player is op now. + if (/operator/i.test(response)) return; + throw new Error(`Player ${this.username} was not opped: ${response.trim() || 'no response from the console'}`); + } + + this.serverWrapper!.execute(command); await poll( () => this.messageBuffer.find(m => m.includes(`Made ${this.username} a server operator`)), @@ -336,6 +348,15 @@ export class PlayerWrapper { private async executeAndSync(cmd: string): Promise { this.requireServer(); + + // A console that answers has already finished the command by the time it replies. The + // marker below exists for the stdio console, where output and command completion are + // two unrelated streams. + if (this.session.console?.output === 'responses') { + await this.serverWrapper!.executeAndWait(cmd); + return; + } + const syncId = `sync_${randomUUID().split('-')[0]}`; this.serverWrapper!.execute(cmd); this.serverWrapper!.execute(`minecraft:say ${syncId}`); diff --git a/runner-package/lib/server.ts b/runner-package/lib/server.ts index 1954f4a..876423a 100644 --- a/runner-package/lib/server.ts +++ b/runner-package/lib/server.ts @@ -13,4 +13,13 @@ export class ServerWrapper { } this.session.console.execute(cmd); } + + /** Runs a command and resolves with whatever the console gives back. A console with + * `output: 'none'` has nothing to give back and resolves empty. */ + executeAndWait(cmd: string, timeoutMs?: number): Promise { + if (!this.session.console) { + throw new Error('No server console available for this environment'); + } + return this.session.console.executeAndWait(cmd, timeoutMs); + } } From 7c2cdc84f6da31cfbc95c100f408266b4b7a5fc8 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 00:21:56 +0300 Subject: [PATCH 025/125] feat(runner): let a test require a specific capability value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requires: ['console'] is satisfied by an RCON console, which answers its own commands and nothing else. Reading the server log needs more than that, so a log matcher on such an environment neither skipped nor worked — it timed out after the full assertion timeout with no explanation. requires now accepts 'key:value' ('consoleOutput:full'), and the server log matcher fails immediately with the level it found and the requires clause that would have skipped it. --- runner-package/lib/matchers.ts | 15 ++++++++++++++- runner-package/runner.ts | 10 +++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/runner-package/lib/matchers.ts b/runner-package/lib/matchers.ts index 8be4276..6bab347 100644 --- a/runner-package/lib/matchers.ts +++ b/runner-package/lib/matchers.ts @@ -72,12 +72,25 @@ export class RunnerMatchers extends Matchers { return strict ? msg === expectedMessage : msg.includes(expectedMessage); }; + const session = (this.actual as PlayerWrapper | ServerWrapper).session; + + // Reading the server log needs a console that streams everything. A console that only + // answers the commands it is given (RCON) leaves the buffer empty, and the assertion + // would fail after a full timeout with nothing explaining why. + if (!(this.actual instanceof PlayerWrapper) && session.env.capabilities.consoleOutput !== 'full') { + throw new Error( + `Cannot read the server log on environment "${session.env.id}": its console output level is ` + + `"${session.env.capabilities.consoleOutput}". Mark the test with requires: ['consoleOutput:full'] ` + + 'to have it skipped there instead.' + ); + } + // 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; + : session.consoleLog; const view = (): string[] => buffer.slice(since); await this.pollAssertion( diff --git a/runner-package/runner.ts b/runner-package/runner.ts index c6851e5..458b4d0 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -80,10 +80,18 @@ async function resolveEnvironment(cfg: EnvironmentConfig): Promise } /** Capability keys from `testCase.requires` that `env` does not actually satisfy. A - * value of `false`, `'none'`, or an absent key all count as unmet. */ + * value of `false`, `'none'`, or an absent key all count as unmet. + * + * `'key:value'` demands one specific value instead — `'consoleOutput:full'` for a test that + * reads the server log, which a console answering only its own commands cannot provide even + * though it satisfies plain `'console'`. */ function missingCapabilities(env: Environment, required: string[]): string[] { const capabilities = env.capabilities as unknown as Record; return required.filter(key => { + const separator = key.indexOf(':'); + if (separator !== -1) { + return String(capabilities[key.slice(0, separator)]) !== key.slice(separator + 1); + } const value = capabilities[key]; return value === false || value === 'none' || value === undefined; }); From 71d68eff279bb2ac174f2fe1995fa536f0d757d0 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 00:21:56 +0300 Subject: [PATCH 026/125] chore(runner): expose the CLI as a bin entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner takes a --config file and needs no Gradle, but there was no bin entry, so `npx plugwright --config …` did not resolve to anything. --- runner-package/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/runner-package/package.json b/runner-package/package.json index d5a1305..7032c9a 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -5,6 +5,9 @@ "type": "module", "main": "dist/runner.js", "types": "dist/runner.d.ts", + "bin": { + "plugwright": "dist/cli.js" + }, "scripts": { "build": "rimraf dist && tsc", "prepare": "npm run build", From 276692f133490e3ab8d96f3c10da4e942e63994d Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 00:22:06 +0300 Subject: [PATCH 027/125] feat(auth-authme): answer the prompt the server actually sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit account.justCreated said whether to register or log in. It is a hint from the account pool and it is wrong the moment a pool account outlives the run that created it, which is the second run against any stand: the plugin sent /register for an account the server already knew. Wait for either prompt and answer the one that arrived, register first since AuthMe's register prompt mentions the password too. Match only messages newer than the step they belong to — a greeting with "welcome" in it was passing for a login confirmation, and tests started before the player could run a command. After registering, wait for the login AuthMe performs itself, or send it when it doesn't. Adds a `password` option for accounts an environment invents rather than leases, which is how the local mode names its throwaway bots. --- auth-authme-package/README.md | 69 ++++++++++++++++++++-------- auth-authme-package/index.ts | 84 +++++++++++++++++++++++++++++------ 2 files changed, 120 insertions(+), 33 deletions(-) diff --git a/auth-authme-package/README.md b/auth-authme-package/README.md index 2559e4d..07483ec 100644 --- a/auth-authme-package/README.md +++ b/auth-authme-package/README.md @@ -1,13 +1,8 @@ # @plugwright/auth-authme -Reference [plugwright](https://github.com/Drownek/plugwright) authentication plugin for a -server running AuthMe (or anything else with the same login/register-by-chat flow). +Reference [plugwright](https://github.com/Drownek/plugwright) authentication plugin for a server running AuthMe, or anything else that asks for a password in chat. -On every bot connection — the initial join, a `player.rejoin()`, and the `external` mode's -admin-bot console — it waits for the login or register prompt and answers it: - -- `account.justCreated` → `/register ` -- otherwise → `/login ` +On every bot connection — the first bot of a test, a second bot from `createPlayer()`, every `player.rejoin()`, and the `external` mode's admin-bot console — it waits for the server's prompt and answers it. Registration is followed through to the login it triggers, because a command sent between the two is still rejected as unauthenticated. Microsoft (online-mode) accounts are left alone; AuthMe never prompts them. @@ -15,7 +10,14 @@ Microsoft (online-mode) accounts are left alone; AuthMe never prompts them. ```kotlin environments { - create("gtamine", ExternalMode) { + create("staging", ExternalMode) { + accounts { + autoRegister { + usernamePattern.set("pw_%04d") + password.set(secret.env("BOT_PASSWORD")) + max.set(4) + } + } plugins { npm("@plugwright/auth-authme") { options["loginCommand"] = "/log" @@ -25,21 +27,50 @@ environments { } ``` +The same block works on a `LocalMode` environment. A local server running AuthMe puts up the same wall as a remote one. + +## Which command it sends + +The server decides, not the account. `account.justCreated` is a hint from the account pool, and it is wrong every time a pool account outlives the run that created it — that is the second run against any stand. So the plugin waits for either prompt and answers whichever arrived. The register pattern is tested first, since AuthMe's register prompt mentions the password too and would otherwise look like a login prompt. + ## Options | Option | Default | Meaning | |---|---|---| -| `loginCommand` | `/login` | Sent (with the password appended) for an existing account | -| `registerCommand` | `/register` | Sent (with the password twice) for a freshly generated account | -| `loginPromptPattern` | `log ?in\|password` | Regex matched against server messages to detect the login prompt | -| `registerPromptPattern` | `regist` | Regex matched against server messages to detect the register prompt | -| `successPattern` | `success\|welcome\|logged in\|authenticat` | Regex confirming the command worked | -| `timeoutMs` | `15000` | How long to wait for each prompt/confirmation | - -A `preflight` test ships alongside the plugin and runs before any user spec: if the -login/register handshake above ever fails, the very first bot connection already throws, so -the preflight test mostly exists to put a clear, named failure at the top of the report -instead of a buried stack trace. +| `loginCommand` | `/login` | Sent with the password appended | +| `registerCommand` | `/register` | Sent with the password twice | +| `loginPromptPattern` | `log ?in\|password` | Regex identifying the login prompt | +| `registerPromptPattern` | `regist` | Regex identifying the register prompt | +| `successPattern` | `success\|welcome\|logged in\|authenticat` | Regex confirming the command was accepted | +| `authenticatedPattern` | `logged in\|authenticat` | Narrower regex confirming the player is actually authenticated | +| `timeoutMs` | `15000` | How long to wait for each prompt or confirmation | +| `password` | — | Fallback password for accounts that carry none | + +All patterns are matched case-insensitively, and only against messages that arrived after the step they belong to. A greeting containing the word "welcome" would otherwise pass for a login confirmation, and the test would start before the player could run a single command. + +`password` covers accounts an environment invents rather than leases: `LocalMode` hands every test a throwaway `Test_` with no password of its own. Plugin options travel as plain strings, so use it only where the password protects nothing — a local server that is deleted after the run. Anywhere else, put the accounts in `accounts { }`, where the password stays a secret reference until the runner reads it. + +## Preflight test + +A `preflight` test ships with the plugin and runs before any user spec. The handshake above already throws on the first connection if it fails, so the test mostly exists to put a named failure at the top of the report instead of a stack trace buried in someone else's test. + +## Server-side settings that matter + +A stock AuthMe config is tuned for humans and rejects a test suite in three specific ways. On a disposable local server: + +```yaml +settings: + registration: + dialog: + preJoin: { enable: false } # bots cannot answer a dialog + postJoin: { enable: false } + restrictions: + maxRegPerIp: 0 # every test registers from 127.0.0.1 +Protection: + enableAntiBot: false # a test suite looks exactly like a bot attack +``` + +The `example_plugin` in this repository writes that file through `writeFiles { }` and runs its full suite against it. ## License diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index 1fe5b28..dad675b 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -15,25 +15,40 @@ export interface AuthAuthmeOptions { loginPromptPattern?: string; /** Regex matched against server messages to detect the register prompt. */ registerPromptPattern?: string; - /** Regex matched against server messages to confirm login/registration succeeded. */ + /** Regex matched against server messages to confirm the command was accepted. */ successPattern?: string; + /** Regex matched against server messages to confirm the player is now authenticated. + * Narrower than [successPattern]: a registration is acknowledged before the login that + * follows it, and commands sent in between are still rejected. Deliberately excludes + * "success" and "welcome" — both fire on AuthMe's own "Successfully registered!" line, + * which would otherwise pass for the login that hasn't happened yet. Also avoids a bare + * "login" alternative: this pattern also gates the redundant-login fallback below, and a + * bare "login" would match AuthMe's login prompt ("Please, login with the command: + * /login ") too, turning a failed retry into a false "authenticated". */ + authenticatedPattern?: string; /** How long to wait for each prompt/confirmation before giving up. */ timeoutMs?: number; + /** Password used for accounts that carry none of their own — the throwaway identities an + * environment without an account pool generates per bot. Plugin options travel as plain + * values, so only use this where the password is worth nothing: a local, disposable + * server. Anywhere else, put the accounts in the pool and let the password be a secret. */ + password?: string; } -const DEFAULTS: Required = { +const DEFAULTS: Required> = { loginCommand: '/login', registerCommand: '/register', loginPromptPattern: 'log ?in|password', registerPromptPattern: 'regist', successPattern: 'success|welcome|logged in|authenticat', + authenticatedPattern: 'success(ful)? login|logged in|authenticat', timeoutMs: 15000, }; // `onPlayerCreate` doesn't receive the plugin's options — only `setup()` does — so the // resolved settings live here, captured once when the session starts. Safe because a runner // process only ever runs one session at a time (see Session's own module-level caveats). -let resolved: Required = DEFAULTS; +let resolved: Required> & { password?: string } = DEFAULTS; /** * Reference authentication plugin for a server running AuthMe (or anything with the same @@ -54,27 +69,68 @@ export default definePlugin({ // Online-mode (Microsoft) accounts never see AuthMe's offline-mode login wall. if (account.auth === 'microsoft') return; - const password = account.password; + const password = account.password ?? resolved.password; if (!password) { - throw new Error(`authme: account "${account.username}" has no password to log in with`); + throw new Error( + `authme: account "${account.username}" has no password to log in with. ` + + 'Give the environment an accounts pool, or set the plugin\'s "password" option ' + + 'for a throwaway local server.' + ); } - const command = account.justCreated - ? `${resolved.registerCommand} ${password} ${password}` - : `${resolved.loginCommand} ${password}`; - const promptPattern = new RegExp(account.justCreated ? resolved.registerPromptPattern : resolved.loginPromptPattern, 'i'); + const registerPrompt = new RegExp(resolved.registerPromptPattern, 'i'); + const loginPrompt = new RegExp(resolved.loginPromptPattern, 'i'); const successPattern = new RegExp(resolved.successPattern, 'i'); - await poll(() => player.session.messages.find((m) => promptPattern.test(m)), { + // Which of the two the server asks for is the server's decision, not ours: + // `account.justCreated` is a hint from the account pool, and it is wrong whenever a + // pool account outlives the run that created it. So wait for either prompt and answer + // the one that actually arrived. Register is tested first because AuthMe's register + // prompt names the password too, and would otherwise match the login pattern. + const joinIndex = player.getMessageBufferIndex(); + const since = (index: number, pattern: RegExp): string | undefined => + player.messageBuffer.slice(index).find((m: string) => pattern.test(m)); + + const isRegistration = await poll( + () => { + if (since(joinIndex, registerPrompt)) return true; + if (since(joinIndex, loginPrompt)) return false; + return undefined; + }, + { + timeout: resolved.timeoutMs, + message: `authme: never saw a login or register prompt for "${account.username}"`, + }, + ); + + // Everything below only looks at messages newer than the command. A server's greeting + // often carries a word like "welcome", which would otherwise pass for confirmation + // and let the test start before the player is actually authenticated. + const commandIndex = player.getMessageBufferIndex(); + player.chat(isRegistration + ? `${resolved.registerCommand} ${password} ${password}` + : `${resolved.loginCommand} ${password}`); + + await poll(() => since(commandIndex, successPattern), { timeout: resolved.timeoutMs, - message: `authme: never saw the ${account.justCreated ? 'register' : 'login'} prompt for "${account.username}"`, + message: `authme: "${account.username}" did not confirm ${isRegistration ? 'registration' : 'login'} in time`, }); - player.chat(command); + if (!isRegistration) return; + + // A registration is confirmed before the login it triggers, and a command sent in + // between is rejected as unauthenticated. AuthMe normally logs the player in itself; + // with forceLoginAfterRegister it does not, and the login has to be sent by hand. + const authenticated = new RegExp(resolved.authenticatedPattern, 'i'); + const autoLoggedIn = await poll(() => since(commandIndex, authenticated), { timeout: 3000 }) + .catch(() => null); + if (autoLoggedIn) return; - await poll(() => player.session.messages.find((m) => successPattern.test(m)), { + const loginIndex = player.getMessageBufferIndex(); + player.chat(`${resolved.loginCommand} ${password}`); + await poll(() => since(loginIndex, authenticated), { timeout: resolved.timeoutMs, - message: `authme: "${account.username}" did not confirm ${account.justCreated ? 'registration' : 'login'} in time`, + message: `authme: "${account.username}" registered but never logged in`, }); }, }); From 20057c6d592b6bc84b7b2be81898799b460e7344 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 00:22:14 +0300 Subject: [PATCH 028/125] docs: document environments, external servers, plugins and modes Five pages for what the last phases added: how environments and modes relate and what tasks each produces, what changes when the server isn't yours, the runner plugin contract, the report formats, and a guide for writing a mode of your own. Configuration keeps its flat-property reference and gains the environments block that supersedes it; test filtering gains the capability and environment filters. READMEs updated to match. --- README.md | 43 +++++++++ console-rcon-package/README.md | 30 ++++-- docs/configuration.mdx | 81 ++++++++++++++++ docs/custom-modes.mdx | 165 +++++++++++++++++++++++++++++++++ docs/docs.json | 10 ++ docs/environments.mdx | 133 ++++++++++++++++++++++++++ docs/examples.mdx | 2 + docs/external-servers.mdx | 147 +++++++++++++++++++++++++++++ docs/plugins.mdx | 154 ++++++++++++++++++++++++++++++ docs/reports.mdx | 84 +++++++++++++++++ docs/test-filtering.mdx | 95 ++++++++++++++----- runner-package/README.md | 12 ++- 12 files changed, 921 insertions(+), 35 deletions(-) create mode 100644 docs/custom-modes.mdx create mode 100644 docs/environments.mdx create mode 100644 docs/external-servers.mdx create mode 100644 docs/plugins.mdx create mode 100644 docs/reports.mdx diff --git a/README.md b/README.md index 870a497..4629518 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,49 @@ This command is interactive, so simply follow the prompts on your screen: > **💡 Want to see a working example?** Check out the [example_plugin](./example_plugin) directory in this repository. +## Testing against more than one server + +The block above describes a single local Paper server, which is all most projects need. When you also want to run the same suite against a staging server someone else keeps running, name the servers explicitly: + +```kotlin +import me.drownek.plugwright.api.secret +import me.drownek.plugwright.external.ExternalMode +import me.drownek.plugwright.local.LocalMode + +plugwright { + testsDir.set(file("src/test/e2e")) + + environments { + create("local", LocalMode) { + minecraftVersion.set("1.21.11") + acceptEula.set(true) + } + + create("staging", ExternalMode) { + host.set("mc.example.com") + minecraftVersion.set("1.20.4") + + console { rcon { port.set(25575); password.set(secret.env("RCON_PASSWORD")) } } + accounts { + autoRegister { + usernamePattern.set("pw_%04d") + password.set(secret.env("BOT_PASSWORD")) + max.set(4) + } + } + plugins { npm("@plugwright/auth-authme") } + } + } +} +``` + +`./gradlew plugwrightTest` runs the matrix and prints a summary per environment; `./gradlew plugwrightTestStaging` runs one. A server behind a login wall needs a runner plugin to get past it, and `@plugwright/auth-authme` is the reference implementation for AuthMe-style login. Writing your own kind of environment — a proxy, a Compose stack — is a Kotlin mode plus an npm package. + +- [Environments](https://plugwright.dev/environments) — modes, tasks, the matrix +- [External servers](https://plugwright.dev/external-servers) — console channels, account pools, cleanup +- [Runner plugins](https://plugwright.dev/plugins) — hooks, fixtures, matchers, inherited tests +- [Writing a mode](https://plugwright.dev/custom-modes) + ## Why Plugwright vs MockBukkit? | | **Plugwright** | **MockBukkit** | diff --git a/console-rcon-package/README.md b/console-rcon-package/README.md index c818d20..f3e6d77 100644 --- a/console-rcon-package/README.md +++ b/console-rcon-package/README.md @@ -2,30 +2,42 @@ RCON server console for [plugwright](https://github.com/Drownek/plugwright)'s `external` mode. -Implements the Source RCON protocol directly over Node's `net` module — no extra dependency. -Unlike the built-in `stdio` and `admin-bot` channels, RCON gives a synchronous response to -every command, so `executeAndWait` doesn't need any client-side sync trick. +A local server gives plugwright a console for free: it owns the process, so it reads stdout and writes stdin. A server someone else started gives it nothing. RCON is how tests reach that server's console instead. + +The Source RCON protocol is implemented directly over Node's `net` module, so this package has no dependencies of its own. Every command comes back with the server's answer, which means `executeAndWait` needs none of the client-side sync tricks a fire-and-forget channel does. ## Usage -Declared through the `external` environment's DSL, not imported directly: +Declared through the `external` environment's DSL rather than imported: ```kotlin environments { - create("gtamine", ExternalMode) { + create("staging", ExternalMode) { console { rcon { port.set(25575) - password.set(secret.env("RCON_PASS")) + password.set(secret.env("RCON_PASSWORD")) } } } } ``` -`plugwrightCompileTests` installs this package automatically once a build script declares an -`rcon` block. If it's missing from `node_modules`, the runner prints a message telling you to -run `npm install` in your tests directory rather than a stack trace. +The server has to be listening. In `server.properties`: + +```properties +enable-rcon=true +rcon.port=25575 +rcon.password=… +``` + +`plugwrightCompileTests` installs this package once a build script declares an `rcon` block. If it is missing from `node_modules` anyway, the runner says which package to install and where, rather than printing a stack trace. + +## What tests can do with it + +Commands and their answers, which covers `server.execute(...)`, `server.executeAndWait(...)`, `player.makeOp()` and everything built on them. + +What it cannot do is show a test the rest of the server log. RCON reports `output: 'responses'`, so `expect(server).toHaveReceivedMessage(...)` fails fast with an explanation instead of timing out. Mark those tests `requires: ['consoleOutput:full']` and they skip on an RCON-only environment. ## License diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 2ae3768..bbe5ac4 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -3,6 +3,34 @@ title: "Configuration" description: "Complete reference for Gradle plugin configuration options." --- +There are two ways to write this block, and they describe the same thing. + +The short one is everything below: flat properties on `plugwright { }`, describing a single local Paper server. Nothing about it has changed, and builds that use it keep working. + +The long one names its servers explicitly, which is what you want as soon as there is more than one: + +```kotlin +import me.drownek.plugwright.local.LocalMode + +plugwright { + testsDir.set(file("src/test/e2e")) + + environments { + create("local", LocalMode) { + minecraftVersion.set("1.21.11") + runDir.set(file("run")) + acceptEula.set(true) + } + } +} +``` + +Inside `create("local", LocalMode) { }` you get the same properties documented on this page, plus `includeInMatrix`, `allowFailure`, `excludeTests` and `plugins { }`. Adding a second environment — a staging server, a proxy, anything — is a second `create` call. See [Environments](/environments) and [External Servers](/external-servers). + + +Without an `environments { }` block, the flat properties below describe one implicit environment named `local`. They are deprecated and will be removed in 4.0. + + ## Example Configuration In your `build.gradle.kts`: @@ -167,6 +195,59 @@ downloadNode.set(true) // no local Node.js required - download it automatically nodeVersion.set("22.14.0") ``` +## Multi-environment options + +These live on `plugwright { }` itself, next to `testsDir`. + + + Environment the unsuffixed task aliases point at. `plugwrightRunServer` means `plugwrightRunServerLocal` when this is `"local"`. Default is `"local"`. It does not mean "the only environment that runs" — the matrix runs all of them. + + +```kotlin +primaryEnvironment.set("local") +``` + + + Settings for the `plugwrightTest` matrix run. `parallel` runs environments concurrently (off by default), `maxParallel` caps how many at once (default `2`). + + +```kotlin +matrix { + parallel.set(true) + maxParallel.set(2) +} +``` + +Per-environment, inside `create(...) { }`: + + + Whether `plugwrightTest` includes this environment. `true` for `LocalMode`, `false` for `ExternalMode`. Ignored when the per-environment task is called directly. + + + + Whether failures here fail the matrix build. Failures are still reported as failures. Default `false`, and ignored when the per-environment task is called directly. + + + + Test name substrings to skip in this environment. Skipped tests appear in the report with the reason. + + + + Port the local server binds and bots connect on. Default `25565`. + + + + Runner plugins this environment loads: `npm("@scope/name") { options["key"] = "value" }` for a published package, `local(file("…"))` for a compiled file in your test project. See [Runner Plugins](/plugins). + + +```kotlin +plugins { + npm("@plugwright/auth-authme") { + options["loginCommand"] = "/login" + } +} +``` + ## Environment Variables diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx new file mode 100644 index 0000000..b422cc2 --- /dev/null +++ b/docs/custom-modes.mdx @@ -0,0 +1,165 @@ +--- +title: "Writing a Mode" +description: "Teach Plugwright about a kind of server it doesn't ship support for." +--- + +`local` and `external` cover the two common cases: a server Plugwright owns, and one it doesn't. A mode of your own is for the cases in between — a Velocity proxy with backend servers, a Docker Compose stack, a server your company provisions through an internal API. + +A mode has two halves that version independently: + +- **Kotlin**, in the build: how the environment is declared and what has to happen before tests run. +- **JavaScript**, in the runner: where the bots connect and what the environment can do. + +The build writes a config file; the runner reads it. Nothing else passes between them. + +## The Kotlin half + +Your module compiles against the API classes, which ship inside the published plugin jar: + +```kotlin +// buildSrc, or a separate published module +plugins { `kotlin-dsl` } + +dependencies { + compileOnly("io.github.drownek:plugwright-bundle:3.0.0") +} +``` + +`compileOnly` on purpose. The plugin is already on the build's classpath at runtime, and a second copy is how you get a `NoSuchMethodError` that takes an afternoon to read. + +### The spec + +The spec is what a build script fills in. Use Gradle property types so laziness and the configuration cache keep working: + +```kotlin +class VelocityEnvironmentSpec( + private val environmentName: String, + objects: ObjectFactory, +) : EnvironmentSpec { + + override fun getName() = environmentName + + 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 composeFile: RegularFileProperty = objects.fileProperty() + val proxyPort: Property = objects.property(Int::class.java).convention(25577) +} +``` + +### The mode + +```kotlin +object VelocityMode : PlugwrightMode { + override val id = "velocity" + override val specType = VelocityEnvironmentSpec::class.java + + override fun createSpec(name: String, objects: ObjectFactory) = + VelocityEnvironmentSpec(name, objects) + + override fun runnerPackages(spec: VelocityEnvironmentSpec) = listOf( + RunnerPackageRef("@acme/plugwright-velocity", "^1.0.0", export = "velocityEnvironment") + ) + + override fun validate(spec: VelocityEnvironmentSpec, ctx: ValidationContext) { + if (!spec.composeFile.isPresent) ctx.error("composeFile must be set") + } + + override fun serialize(spec: VelocityEnvironmentSpec, node: ConfigNodeBuilder) { + node.put("proxyPort", spec.proxyPort.get()) + node.put("composeFile", spec.composeFile.get().asFile.absolutePath) + } + + override fun registerTasks(spec: VelocityEnvironmentSpec, ctx: TaskRegistrationContext) { + val up = ctx.register("Up", ComposeUpTask::class.java) { + composeFile.set(spec.composeFile) + pluginJar.set(ctx.projectPluginJar) + } + ctx.register("Down", ComposeDownTask::class.java) { composeFile.set(spec.composeFile) } + ctx.prepareTask(up) + } +} +``` + +What each piece is for: + +- `id` lands in the config as `environment.mode` and names the mode in error messages. +- `runnerPackages` is installed by `plugwrightCompileTests`, merged with every other environment's packages into one `npm install`. The first entry with an `export` becomes the runtime reference the runner loads the environment from, so name it there. +- `validate` reports problems through the context instead of throwing. Every environment is validated before the build fails, so a script with three mistakes reports three, not the first. +- `serialize` writes `environment.config` at configuration time. Secrets stay `SecretRef`s here — `node.put("password", spec.password.get())` writes a reference, not a password. +- `registerTasks` adds tasks named `plugwright`, so `register("Up", ...)` in an environment called `proxy` gives `plugwrightUpProxy`. `prepareTask` marks the one that has to run before the tests do. + +Preparation belongs in a task rather than a callback. A callback executed inside someone else's `@TaskAction` drags your mode object into that task's state, breaks the configuration cache, and can never be run on its own. A task with declared inputs and outputs gets up-to-date checks and a name someone can type. + +If a config value needs something only a task can reach — the Java toolchain, a Gradle service — set it from `registerTasks` with `ctx.environmentConfig(provider)` instead of from `serialize`. That is what `LocalMode` does for the Java executable path. + +### Registering it + +```kotlin +buildscript { + dependencies { classpath("com.acme:plugwright-velocity:1.0.0") } +} + +plugwright { + registerMode(com.acme.VelocityMode) + + environments { + create("proxy", com.acme.VelocityMode) { + composeFile.set(file("docker/compose.yml")) + proxyPort.set(25577) + } + } +} +``` + +`create` is generic over the mode, so the block has your spec type as its receiver with no cast. + +## The JavaScript half + +The npm package named in `runnerPackages` exports a factory. It takes the `environment.config` object your `serialize` wrote and returns an `Environment`: + +```ts +import type { Environment, EnvironmentCapabilities, BotConnectionOptions } from '@drownek/plugwright'; + +export function velocityEnvironment(config: VelocityConfig): Environment { + return new VelocityEnvironment(config); +} + +class VelocityEnvironment implements Environment { + readonly id = 'velocity'; + readonly capabilities: EnvironmentCapabilities = { + console: true, + consoleOutput: 'responses', + op: true, + freshState: false, + arbitraryUsernames: true, + lifecycle: true, + cleanupStrategy: 'compensating', + }; + + async setup(session: Session): Promise { /* connect, probe, warm up */ } + connection(): BotConnectionOptions { /* host, port, version, auth */ } + console(): ServerConsole | null { /* the channel tests run commands through */ } + accounts(): AccountPool | null { return null; } // optional + async beforeJoin(): Promise { /* throttle, if the server needs it */ } + async teardown(): Promise { /* disconnect, stop what you started */ } +} +``` + +Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. + +`accounts()` and `beforeJoin()` are optional. Returning no pool means every bot gets a throwaway `Test_` username, which is what `local` does. + +## Checking it works + +```bash +./gradlew plugwrightTestProxy --info +cat build/tmp/plugwright/proxy.json +``` + +The config file is the contract between the two halves, and reading it answers most of the questions that come up while a mode is half-written. If the runner says the mode is one it "cannot run yet", the runtime reference is missing — check that a `RunnerPackageRef` in `runnerPackages` names an `export`. + +## Versioning + +`PlugwrightMode.apiVersion` defaults to the API version your module compiled against, and Plugwright refuses to load a mode whose version it doesn't understand. On the runner side, `RunnerPackageRef` carries an npm range for the same reason: the Kotlin module and the npm package are released separately, and the pair has to agree. diff --git a/docs/docs.json b/docs/docs.json index 792fdee..55dccec 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -35,6 +35,16 @@ "api-reference", "examples" ] + }, + { + "group": "Environments", + "pages": [ + "environments", + "external-servers", + "plugins", + "reports", + "custom-modes" + ] } ] } diff --git a/docs/environments.mdx b/docs/environments.mdx new file mode 100644 index 0000000..d9f8251 --- /dev/null +++ b/docs/environments.mdx @@ -0,0 +1,133 @@ +--- +title: "Environments" +description: "Declare the servers your tests run against, and run the same suite on all of them." +--- + +An environment is one server your tests can run against. Every environment is backed by a **mode**, which decides where that server comes from: + +| Mode | Where the server comes from | +|---|---| +| `LocalMode` | Plugwright downloads Paper, patches the configs, starts it, and kills it afterwards | +| `ExternalMode` | Someone else started it. Plugwright connects and leaves it running | + +Both ship with the plugin. A third mode is something you write yourself — see [Writing a mode](/custom-modes). + +## Declaring environments + +```kotlin +import me.drownek.plugwright.local.LocalMode +import me.drownek.plugwright.external.ExternalMode + +plugwright { + testsDir.set(file("src/test/e2e")) + primaryEnvironment.set("local") + + environments { + create("local", LocalMode) { + minecraftVersion.set("1.21.11") + acceptEula.set(true) + runDir.set(file("run")) + } + + create("staging", ExternalMode) { + host.set("mc.example.com") + port.set(25565) + minecraftVersion.set("1.20.4") + } + } +} +``` + +The name you pass to `create` becomes the task suffix and the report file name: `local` gives you `plugwrightTestLocal` and `build/reports/plugwright/local.json`. + + +A build script with no `environments { }` block still works. The flat properties (`minecraftVersion`, `runDir`, `downloadPlugins`, and the rest) describe one implicit `local` environment, exactly as they did before. See [Configuration](/configuration). + + +## Tasks + +``` +plugwrightCompileTests npm install + tsc, shared by every environment +plugwrightProvisionLocal download Paper, patch configs, copy the plugin jar +plugwrightCleanLocal wipe the run directory +plugwrightRunServerLocal start the server interactively, no tests +plugwrightPingStaging check that an external stand answers, no tests +plugwrightCleanStaging compensating cleanup on an external stand +plugwrightTestLocal run the suite against one environment +plugwrightTestStaging +plugwrightTest the matrix: every environment with includeInMatrix +``` + +Which tasks exist depends on the mode. `LocalMode` contributes provisioning, cleaning and a server-run task; `ExternalMode` contributes ping and cleanup, and nothing that touches files. + +Tasks for the `primaryEnvironment` also get an unsuffixed alias, so `plugwrightRunServer` still means what it used to. `plugwrightTest` is the exception: it belongs to the matrix. + +## The matrix + +`plugwrightTest` runs every environment whose `includeInMatrix` is true, one runner process each, and prints a summary: + +``` +Environment summaries: + local 42 passed, 0 failed, 0 skipped (1m 12s) + staging 31 passed, 2 failed, 9 skipped (2m 03s) [allowFailure] +``` + +It launches the runner itself rather than depending on the per-environment tasks. A `dependsOn` chain would stop at the first failing environment and hide the results of the rest. + +Defaults differ by mode on purpose. `LocalMode` sets `includeInMatrix` to true — a server that only exists during the run belongs in every run. `ExternalMode` sets it to false, because a shared stand should not be pulled into someone's local `plugwrightTest` unasked. + +```kotlin +create("staging", ExternalMode) { + // Only in CI, and never fail the build when the stand is flaky + includeInMatrix.set(providers.environmentVariable("CI").map { it == "true" }.orElse(false)) + allowFailure.set(true) +} +``` + +`allowFailure` keeps a failing environment from failing the matrix build. The failures are still reported as failures. Calling `plugwrightTestStaging` directly ignores both flags: an explicit request deserves an honest exit code. + +### Narrowing the matrix + +```bash +./gradlew plugwrightTest -Pplugwright.env=local,staging +``` + +### Running environments in parallel + +```kotlin +plugwright { + matrix { + parallel.set(true) + maxParallel.set(2) + } +} +``` + +Off by default, and worth thinking about before you turn it on. Two local Paper servers means twice the `-Xmx`. Several environments sharing one outbound IP means more join throttling and more ban risk on a public stand. Account pools must not overlap. Output is interleaved, so each environment's log is also written separately to `build/reports/plugwright/.log`. + +## Per-environment test selection + +`excludeTests` skips tests whose name contains any of the given substrings. It is matched against the test name, not the file name: + +```kotlin +create("staging", ExternalMode) { + excludeTests.set(listOf("balance", "kit", "arena")) +} +``` + +Skipped tests appear in the report with the reason. Silence would be worse than a failure here: a test that quietly disappears on one environment looks like coverage you don't have. + +Tests can also select environments themselves, either by capability or by name. See [Test Filtering](/test-filtering). + +## Secrets + +Passwords never belong in the config file Gradle writes into `build/`. Declare them as references instead: + +```kotlin +import me.drownek.plugwright.api.secret + +password.set(secret.env("BOT_PASSWORD")) +password.set(secret.file(file("/etc/plugwright/bot.pass"))) +``` + +`secret.env` reads an environment variable, `secret.file` the first line of a file. Both are resolved by the runner at run time, so the value stays out of the configuration cache and out of build artifacts. `secret.systemProperty` exists for symmetry but fails at run time — the runner is a separate Node process and cannot see JVM system properties. diff --git a/docs/examples.mdx b/docs/examples.mdx index 5eb5512..3b28ab9 100644 --- a/docs/examples.mdx +++ b/docs/examples.mdx @@ -14,6 +14,8 @@ The **[`example_plugin`](https://github.com/Drownek/plugwright/tree/main/example - **Events**: Testing actions triggered by player joins or scheduled server tasks. - **Teleportation**: Warps, commands, and movement. +Its `build.gradle.kts` also declares two environments for the same suite. `local` downloads Paper, installs AuthMe next to the plugin under test, writes an AuthMe config a bot can actually get through, and logs every bot in with `@plugwright/auth-authme`. `stand` connects to a server started by hand from the same run directory, leasing accounts from a pool, reaching the console over RCON, and resetting op and inventory between tests through a small local plugin. Reading the two side by side is the shortest way to see what changes when Plugwright stops owning the server. + Explore the Java source code and TypeScript test specs to see how to implement robust E2E tests for your own plugins. diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx new file mode 100644 index 0000000..f8dc16e --- /dev/null +++ b/docs/external-servers.mdx @@ -0,0 +1,147 @@ +--- +title: "External Servers" +description: "Run the same suite against a server Plugwright does not own." +--- + +`ExternalMode` points bots at a server that is already running: a staging stand, a colleague's box, the production copy someone keeps for QA. Plugwright starts nothing, patches nothing and shuts nothing down. + +That changes what the suite can assume. A local server hands every test a fresh world and a brand new username. A stand hands you whatever the last test left behind, on an account you have to log in as, and the plugin under test is already installed there — deploying it is out of scope for this mode by design. + +```kotlin +import me.drownek.plugwright.api.secret +import me.drownek.plugwright.external.ExternalMode + +environments { + create("staging", ExternalMode) { + host.set("mc.example.com") + port.set(25565) + minecraftVersion.set("1.20.4") + joinThrottleMs.set(3000) + excludeTests.set(listOf("arena", "kit")) + + console { + rcon { port.set(25575); password.set(secret.env("RCON_PASSWORD")) } + adminBot("StaffBot") { password.set(secret.env("STAFF_PASSWORD")) } + } + + accounts { + pool { + account("TestBot1") { password.set(secret.env("BOT1_PASSWORD")) } + account("TestBot2") { password.set(secret.env("BOT2_PASSWORD")) } + } + autoRegister { + usernamePattern.set("pw_%04d") + password.set(secret.env("BOT_PASSWORD")) + max.set(4) + } + } + + plugins { + npm("@plugwright/auth-authme") + } + } +} +``` + +`minecraftVersion` is required here, unlike in `LocalMode` where the version is what Plugwright downloaded. A proxy in front of the stand (ViaVersion and friends) defeats protocol autodetection, so guessing would produce a confusing connection failure instead of a clear one. + +`joinThrottleMs` is the minimum delay between two bot connections. Public servers treat a burst of logins as an attack; a few seconds of spacing is cheaper than getting the CI runner's IP banned. + +## Console channels + +Without a process of its own, the mode has no stdout to read and no stdin to write. A console channel is how tests reach `server.execute(...)`, `player.makeOp()` and everything else that needs the server side. + +Channels are probed in declaration order, and the first one that answers becomes the session's console. The chosen channel is printed in the run header. + +| Channel | Output level | Notes | +|---|---|---| +| `rcon { }` | `responses` | Needs `enable-rcon=true` on the server. Installs `@plugwright/console-rcon` | +| `adminBot("Name") { }` | `responses` | A second bot with staff rights that sends commands through chat | +| stdio | `full` | `LocalMode` only — Plugwright owns the process | + +The output level matters more than it looks. `full` means the whole server log is readable, so `expect(server).toHaveReceivedMessage(...)` works. `responses` means you get back what the command printed and nothing else. A test that reads the server log should say so: + +```ts +test('command is logged', { requires: ['consoleOutput:full'] }, async ({ server }) => { + server.execute('say hello'); + await expect(server).toHaveReceivedMessage('hello'); +}); +``` + +Declaring no channel at all is valid. The environment runs without a console, and every test that requires one is skipped and reported as skipped. + +The admin bot connects through the same code path as a test bot, which means it goes through your authentication plugin too, and it connects before any test bot does. + +## Accounts + +A local server accepts any username; a stand usually does not. `accounts { }` builds a pool that tests lease from and return to, merged from three sources: + +- **`pool`** — accounts that already exist, with their passwords. +- **`autoRegister`** — generated names from a pattern, marked `justCreated` on their first lease so an authentication plugin registers them instead of logging in. The pattern must start with `pw_`, so test accounts stay recognizable on a server full of real players. +- **`microsoft`** — online-mode accounts. No password; mineflayer authenticates with a cached device-code token. Point `cacheDir` somewhere outside `build/`, and warm the cache before CI ever needs it, because the device-code flow is interactive. + +One account is leased per bot and returned in a `finally`, whatever the test did. When the pool is empty and `autoRegister` has hit `max`, `lease()` throws rather than hand the same identity to two connected bots. + +An explicitly named bot bypasses the pool entirely: + +```ts +const friend = await createPlayer({ username: 'FriendBot' }); +``` + +That is a request for a specific identity, not for whatever is free — so nothing knows its password. On a stand behind a login wall, either leave those tests to the local environment or give the account a password some other way. + + +A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, exclude what you can't, and treat `capabilities.freshState = false` as the honest description it is. + + +## Checking the stand before you test + +```bash +./gradlew plugwrightPingStaging +``` + +Connects, probes the console channels, leases one account and authenticates with it, then disconnects. No tests run. When something is wrong with the stand — RCON password rotated, login plugin changed its messages, account pool exhausted — this fails in seconds with a specific message instead of failing test after test five minutes into a run. + +## Cleaning up + +`plugwrightClean` means something different per mode. For `LocalMode` it wipes the run directory. For `ExternalMode` there is nothing to wipe: it starts the runner in cleanup mode, which connects, loads the plugins and calls their `cleanup({ scope: 'manual' })` handlers. No files are touched. + +```kotlin +// in a runner plugin +definePlugin({ + name: 'staging', + async cleanup({ session, scope }) { + // scope: 'session' after a run, 'manual' from plugwrightCleanStaging + await session.console?.executeAndWait('/pw purge-test-data'); + }, +}); +``` + +### The journal + +Finalizers registered with `TestContext.cleanup()` run in a `finally`. A `SIGKILL` skips `finally` blocks, and on a real server the leftovers accumulate — a hundred junk warps a month later. + +For obligations that must survive that, record a typed entry in `build/plugwright/-journal.jsonl`: + +```ts +test('creating a warp', async ({ player, server, cleanup }) => { + const warpName = `pw_${crypto.randomUUID().slice(0, 8)}`; + const id = warpName; + + player.chat(`/setwarp ${warpName}`); + server.session.journal.record(id, { kind: 'warp', name: warpName }); + + cleanup(() => { + server.execute(`/delwarp ${warpName}`); + server.session.journal.forget(id); + }); + + await expect(player).toHaveReceivedMessage('Warp created'); +}); +``` + +Entries are typed records interpreted by a plugin's `cleanup` handler, never raw command strings. A file that replays raw commands against a live server is a way to run arbitrary commands on it. Whatever is still in the journal when the next run starts is what a crash left behind; `plugwrightClean` prints anything a cleanup pass could not resolve. + +## What the runner reports as skipped + +After `setup()`, the environment reports what it actually supports. For `ExternalMode` that is: no fresh state, no server lifecycle, compensating cleanup, arbitrary usernames, and console plus op only if a console channel answered. Tests that declare `requires` are skipped against that list, with the reason in the report. See [Test Filtering](/test-filtering). diff --git a/docs/plugins.mdx b/docs/plugins.mdx new file mode 100644 index 0000000..9e33281 --- /dev/null +++ b/docs/plugins.mdx @@ -0,0 +1,154 @@ +--- +title: "Runner Plugins" +description: "Hooks, fixtures, matchers and inherited tests, without touching the test engine." +--- + +A runner plugin extends what happens around your tests. Logging in through AuthMe, adding an `expect(player).toHaveBalance(100)` matcher, resetting state between tests on a shared stand, shipping a suite of tests that any server running your plugin should pass — all of that is a plugin, and none of it requires the test engine to know about it. + +Plugins are declared per environment: + +```kotlin +create("staging", ExternalMode) { + plugins { + npm("@plugwright/auth-authme") { + options["loginCommand"] = "/log" + } + local(file("src/test/e2e/dist/plugins/staging.js")) { + inheritTests = false + } + } +} +``` + +`npm(...)` names a published package, installed by `plugwrightCompileTests` along with the rest of the environment's packages. `local(...)` points at a compiled file in your own test project. Options are plain strings — anything secret belongs in `accounts { }`, where it stays a secret reference. + +`LocalMode` takes the same block. A local server running an authentication plugin needs the login hook exactly as much as a remote one does. + +## What a plugin can do + +```ts +export interface PlugwrightPlugin { + name: string; + apiVersion?: number; + setup?(ctx: { session, env, options: O }): Promise | void; + onPlayerCreate?(player, ctx: { account, env }): Promise | void; + beforeEach?(ctx: TestContext): Promise | void; + afterEach?(ctx: TestContext): Promise | void; + extendContext?(ctx: TestContext): Record | void; + matchers?: Record; + tests?: Array<{ file: string; mode: 'preflight' | 'suite' }>; + cleanup?(ctx: { session, scope: 'session' | 'manual' }): Promise | void; + teardown?(): Promise | void; +} +``` + +Order over one run: + +``` +env.setup() → console probe → load plugins → register matchers → plugins.setup() + → preflight tests (a failure here aborts the run) + → user specs + suite tests + per test: lease account → connect → onPlayerCreate → beforeEach + → body → cleanup finalizers → afterEach → return account + → reports → cleanup('session') → teardown() → env.teardown() +``` + +Matchers are merged into the shared prototype before the first spec file is imported. That ordering is not incidental: `expect(x).toHaveBalance()` looks the matcher up when it is called, but the spec file has to typecheck and import first. + +## Authentication is a hook, not a test + +```ts +import { definePlugin, poll } from '@drownek/plugwright'; + +export default definePlugin({ + name: 'authme', + async onPlayerCreate(player, { account }) { + if (account.auth === 'microsoft') return; + // wait for the prompt, answer it, wait for the confirmation + }, +}); +``` + +`onPlayerCreate` fires on every connection: the first bot of a test, a second bot from `createPlayer()`, every `player.rejoin()`, and the admin-bot console channel. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook. + +## Inherited tests + +```ts +tests: [ + { file: join(__dirname, 'auth.spec.js'), mode: 'preflight' }, + { file: join(__dirname, 'economy.spec.js'), mode: 'suite' }, +] +``` + +`preflight` tests run before any user spec and abort the run when they fail — there is no point testing a shop when nobody can log in. `suite` tests run alongside your own and are tagged with the plugin's name in the report. + +Spec discovery skips `node_modules`, so this is the only way a packaged test ever runs. Per-plugin, `inheritTests = false` loads the hooks and matchers without the tests. + +## Fixtures + +`extendContext` adds fields to the object every test destructures: + +```ts +extendContext(ctx) { + return { auth: new AuthApi(ctx.player) }; +} +``` + +```ts +declare module '@drownek/plugwright' { + interface TestContext { + auth: AuthApi; + } +} +``` + +The declaration merging block is what gives you types and autocompletion at the call site. Without it the fixture still works, and TypeScript still complains. + +## Matchers + +```ts +matchers: { + async toHaveBalance(this: any, expected: number) { + await this.pollAssertion( + () => currentBalance(this.actual) === expected, + () => `Expected NOT to have balance ${expected}`, + () => `Expected balance ${expected}, got ${currentBalance(this.actual)}`, + ); + }, +} +``` + +Anything that reads the server log has to check the console output level first, because a console that only answers its own commands leaves that buffer empty. See [External Servers](/external-servers). + +## Versioning + +```ts +export default definePlugin({ name: 'authme', apiVersion: 1 }); +``` + +A plugin built against a newer contract than the runner supports fails to load with a message saying so. Leaving `apiVersion` unset skips the check. + +## Writing one + +A plugin is an npm package (or a single compiled file) whose default export implements the interface: + +```ts +import { definePlugin } from '@drownek/plugwright'; + +export default definePlugin<{ resetCommand?: string }>({ + name: 'staging-reset', + + setup({ options }) { + resetCommand = options.resetCommand ?? '/pw reset'; + }, + + async beforeEach({ player, server }) { + if (!server.session.env.capabilities.console) return; + await server.executeAndWait(`minecraft:clear ${player.username}`); + }, +}); +``` + +`definePlugin` is an identity function; it exists so TypeScript infers your options type at the definition site. Depend on `@drownek/plugwright` as a peer dependency, ship compiled JavaScript, and point `main` at it. + +`@plugwright/auth-authme` in this repository is a complete, working example: a hook, an options interface, a preflight test, and a README. diff --git a/docs/reports.mdx b/docs/reports.mdx new file mode 100644 index 0000000..a125849 --- /dev/null +++ b/docs/reports.mdx @@ -0,0 +1,84 @@ +--- +title: "Reports" +description: "JSON and JUnit XML output, per environment." +--- + +Every run writes two report files, whether it was started by `plugwrightTest` or by the matrix: + +``` +build/reports/plugwright/.json machine-readable, what the matrix aggregates +build/reports/plugwright/junit/.xml JUnit XML for CI +build/reports/plugwright/.log per-environment output, matrix runs only +``` + +## JSON + +```json +{ + "environment": "staging", + "summary": { "total": 47, "passed": 33, "failed": 0, "skipped": 14, "durationMs": 152340 }, + "tests": [ + { + "file": "…/dist/commands.spec.js", + "name": "help command shows available commands", + "status": "pass", + "durationMs": 63, + "error": null, + "skipReason": null, + "plugin": null + }, + { + "file": "…/dist/simple-ts.spec.js", + "name": "server logs command execution", + "status": "skip", + "durationMs": 0, + "error": null, + "skipReason": "requires capability [consoleOutput:full], unavailable on \"staging\"", + "plugin": null + } + ] +} +``` + +`status` is `pass`, `fail` or `skip`. `plugin` names the plugin a test came from when it was inherited rather than found in your test directory. + +Every skip carries its reason: excluded by name, wrong environment, or a capability the environment doesn't have. A skipped test that doesn't say why is worse than a failing one, because it reads as coverage. + +## JUnit XML + +```xml + + + + + + +``` + +The suite name is `plugwright.`, so a matrix run produces one suite per environment and CI keeps them apart. `classname` is the spec file, `name` is the full test name including its `describe` chain. Failures carry the message as the attribute and the stack as the body. + +Most CI systems pick these up with a glob: + +```yaml +- uses: actions/upload-artifact@v4 + if: always() + with: + name: plugwright-reports + path: build/reports/plugwright/ +``` + +## Matrix summary + +``` +Environment summaries: + local 47 passed, 0 failed, 0 skipped (4m 09s) + staging 33 passed, 2 failed, 14 skipped (2m 35s) [allowFailure] +``` + +An environment that produced no report at all gets an `ERROR:` line instead of counts: + +``` + staging ERROR: Command '…cli.js --config …' failed with exit code: 1 [allowFailure] +``` + +Failed tests and an unreachable server are different problems, and the summary keeps them apart so you know whether to read the diff or fix the stand. diff --git a/docs/test-filtering.mdx b/docs/test-filtering.mdx index 12ede14..f3ebc12 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -1,43 +1,88 @@ --- title: "Test Filtering" -description: "Run specific tests using `-PtestFiles` and `-PtestNames`." +description: "Pick tests by file, by name, by environment, or by what the environment can do." --- -### Syntax Rules -* **Matching:** Case-sensitive substring matching. -* **Multiple Patterns:** Comma-separated (no spaces). -* **Extensions:** No need to include `.spec.js` or `.spec.ts`. +## From the command line -## Filter by File -Run specific test files. +Matching is case-sensitive substring matching. Multiple patterns are comma-separated with no spaces, and file patterns don't need the `.spec.js` / `.spec.ts` suffix. ```bash -# Run basic.spec.js +# One file, or several ./gradlew plugwrightTest -PtestFiles="basic" - -# Run files matching "basic" OR "commands" ./gradlew plugwrightTest -PtestFiles="basic,commands" + +# By test name +./gradlew plugwrightTest -PtestNames="should connect" + +# Both: "purchase" tests inside "shop" files +./gradlew plugwrightTest -PtestFiles="shop" -PtestNames="purchase" + +# Narrow the matrix to specific environments +./gradlew plugwrightTest -Pplugwright.env=local,staging ``` -## Filter by Test Name -Run specific test cases. +Running `./gradlew plugwrightTest` with no arguments runs everything, on every environment in the matrix. -```bash -# Run tests containing "should connect" -./gradlew plugwrightTest -PtestNames="should connect" +## From the build script + +`excludeTests` skips tests whose name contains one of the substrings, for one environment only: -# Run tests matching "teleport" OR "spawn" -./gradlew plugwrightTest -PtestNames="teleport,spawn" +```kotlin +create("staging", ExternalMode) { + excludeTests.set(listOf("balance", "kit", "arena")) +} ``` -## Combine Filters -Run tests that match **both** the file and the name criteria. +## From the test itself -```bash -# Run "purchase" tests, but only inside "shop" files -./gradlew plugwrightTest -PtestFiles="shop" -PtestNames="purchase" +Two filters, meant for different problems. + +**By capability** — for a test that needs something the environment might not have. This travels with the test and doesn't care what the environments are called: + +```ts +test('give command hands over the item', { requires: ['console', 'op'] }, async ({ player }) => { + await player.giveItem('diamond', 1); +}); +``` + +Capability keys come from the environment's own report, after it has connected: + +| Key | Values | Meaning | +|---|---|---| +| `console` | boolean | Commands can be run at all | +| `consoleOutput` | `full` / `responses` / `none` | Whole server log, only command answers, or nothing | +| `op` | boolean | The environment can grant operator status | +| `freshState` | boolean | Each test gets a clean world and a clean player | +| `arbitraryUsernames` | boolean | Bots may pick their own names | +| `lifecycle` | boolean | The server can be restarted or stopped | +| `cleanupStrategy` | `wipe` / `compensating` / `none` | How cleanup happens after the run | + +A bare key is satisfied by anything other than `false`, `'none'` or an absent value. To demand one specific value, use `key:value`: + +```ts +test('command is logged', { requires: ['consoleOutput:full'] }, async ({ server }) => { + server.execute('say hello'); + await expect(server).toHaveReceivedMessage('hello'); +}); +``` + +That form exists because `requires: ['console']` is satisfied by an RCON console that answers its own commands, while reading the server log needs a console that streams all of it. Without the distinction you get tests that neither skip nor work. + +**By environment name** — for when the difference isn't a capability but what's installed on that particular server: + +```ts +test('the /debug dev command', { environments: ['local'] }, async ({ player }) => { + player.chat('/debug'); +}); +``` + +## Skips are reported + +Every skipped test lands in the report with its reason: + +``` + Test: server logs command execution - SKIPPED (requires capability [consoleOutput:full], unavailable on "staging") ``` - -**Note:** Running `./gradlew plugwrightTest` without arguments runs all tests. - +The reason is in the JSON report and in the `` element of the JUnit XML too. See [Reports](/reports). diff --git a/runner-package/README.md b/runner-package/README.md index a819a63..540433b 100644 --- a/runner-package/README.md +++ b/runner-package/README.md @@ -33,9 +33,19 @@ test('player can interact with GUI', async ({ player }) => { }); ``` +## Running against something other than a local server + +The runner takes a config file describing one environment: + +```bash +npx plugwright --config build/tmp/plugwright/local.json +``` + +The Gradle plugin writes that file, but nothing stops you from writing it yourself. `local` starts and stops its own Paper server; `external` connects to one that is already running, with an account pool, a console channel and authentication handled by a plugin. Two service modes exist for the second case: `--ping` checks that the server answers without running tests, and `--cleanup` replays outstanding cleanup work. + ## Documentation -Full documentation is available in the [GitHub repository Wiki](https://github.com/Drownek/plugwright/wiki). +Full documentation is at [plugwright.dev](https://plugwright.dev). Start with [Environments](https://plugwright.dev/environments) for multi-server setups, and [Runner Plugins](https://plugwright.dev/plugins) for hooks, fixtures and custom matchers. ## License From fdcdef4f2fce54baef30d91685c50d6314dd1407 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 00:22:24 +0300 Subject: [PATCH 029/125] test(example): run the suite against a second, hand-started server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example described one implicit local server. It now declares two environments explicitly: `local`, which downloads Paper and installs AuthMe next to the plugin under test, and `stand`, which connects to a server started by hand from the same run directory and leaves it running. `local` writes an AuthMe config a bot can get through — the stock one asks for the password in a dialog, allows one registration per IP, and treats a test suite as a bot attack — and logs every bot in through @plugwright/auth-authme. `stand` leases four accounts from a pool, reaches the console over RCON, and resets op and inventory between tests with a local plugin, since a leased account carries over whatever the last test left on it. What it cannot reset is excluded by name; the one test that reads the server log now says requires: ['consoleOutput:full'] and skips there instead. 47 pass on local, 33 pass and 14 skip on the stand. --- example_plugin/README.md | 60 ++++++++ example_plugin/build.gradle.kts | 131 +++++++++++++++++- example_plugin/src/test/e2e/package-lock.json | 49 ++++++- example_plugin/src/test/e2e/package.json | 4 +- .../src/test/e2e/plugins/stand-reset.ts | 24 ++++ example_plugin/src/test/e2e/simple-ts.spec.ts | 4 +- example_plugin/src/test/e2e/tsconfig.json | 2 +- 7 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 example_plugin/README.md create mode 100644 example_plugin/src/test/e2e/plugins/stand-reset.ts diff --git a/example_plugin/README.md b/example_plugin/README.md new file mode 100644 index 0000000..f5b2c0f --- /dev/null +++ b/example_plugin/README.md @@ -0,0 +1,60 @@ +# example_plugin + +A small Bukkit plugin and the E2E suite that tests it. Everything here runs against the plugwright build in this repository through `includeBuild("../gradle-plugin")`, so changes to the plugin or the runner show up without publishing anything. + +The same 47 tests run against two environments, declared in `build.gradle.kts`. + +## `local` — plugwright owns the server + +```bash +./gradlew plugwrightTest +``` + +Downloads Paper into `run/`, installs PlaceholderAPI and AuthMe next to the plugin under test, writes an AuthMe config a bot can get through, starts the server, runs everything, and shuts it down. Every test gets a fresh username, which AuthMe treats as a fresh registration, which `@plugwright/auth-authme` answers. + +## `stand` — someone else owns the server + +This one connects to a server that is already running and leaves it running. Provision it once, start it by hand, then point the tests at it. + +```bash +# 1. Prepare run/ (Paper, plugins, server.properties with RCON enabled) +./gradlew plugwrightProvisionLocal + +# 2. Start the server yourself, from the run directory +cd run && ./start.sh +``` + +`run/` is not in version control, so `start.sh` is yours to write. Anything that starts the jar with Java 21 will do: + +```sh +#!/usr/bin/env sh +set -e +cd "$(dirname "$0")" +JAVA_BIN="${JAVA_BIN:-java}" +JVM_ARGS="${JVM_ARGS:--Xmx2G}" +exec "$JAVA_BIN" $JVM_ARGS -Dcom.mojang.eula.agree=true -jar server.jar --nogui +``` + +`start.sh` is listed in the `local` environment's `cleanExcludePatterns`, so provisioning again won't delete it. + +```bash +# 3. In another terminal +export PLUGWRIGHT_BOT_PASSWORD=plugwright +export PLUGWRIGHT_RCON_PASSWORD=plugwright + +./gradlew plugwrightPingStand # connects, probes RCON, logs one bot in +./gradlew plugwrightTestStand +``` + +Expect skips. The stand leases four accounts from a pool instead of inventing a name per test, so anything that assumes a clean balance, an unclaimed kit or an empty arena is excluded, and anything that reads the whole server log is skipped — RCON answers commands, it doesn't stream the log. + +`plugins/stand-reset.ts` handles what can be reset: it deops the leased account and clears its inventory before each test. It is loaded for the `stand` environment only, through `plugins { local(...) }`. + +## Layout + +``` +src/main/java/…/ExamplePlugin.java the plugin under test +src/test/e2e/*.spec.ts the suite, run against both environments +src/test/e2e/plugins/stand-reset.ts a local runner plugin, stand only +build.gradle.kts both environment declarations +``` diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index a302baa..22e402d 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -1,3 +1,7 @@ +import me.drownek.plugwright.api.secret +import me.drownek.plugwright.external.ExternalMode +import me.drownek.plugwright.local.LocalMode + plugins { `java-library` id("de.eldoria.plugin-yml.bukkit") version "0.8.0" @@ -5,14 +9,131 @@ plugins { id("io.github.drownek.plugwright") version "3.0.0-dev.0" } +// Password every bot on the local server registers with. It guards a server that lives for +// the length of one test run, so it is a literal here; on a real stand the password belongs +// in an account pool, where it stays a secret reference until the runner reads it. +val localBotPassword = "plugwright" + +// RCON password shared by the server the "stand" environment connects to and by the console +// channel that connects back to it. The literal is the fallback for a server started without +// the variable set; the console channel reads the variable itself, at run time. +val standRconPassword: String = providers.environmentVariable("PLUGWRIGHT_RCON_PASSWORD").getOrElse("plugwright") + plugwright { - minecraftVersion.set("1.21.11") - acceptEula.set(true) testsDir.set(file("src/test/e2e")) - downloadPlugins { - url("https://hangarcdn.papermc.io/plugins/HelpChat/PlaceholderAPI/versions/2.11.6/PAPER/PlaceholderAPI-2.11.6.jar") - } downloadNode.set(System.getenv("CI") != "true") + primaryEnvironment.set("local") + + environments { + // Paper downloaded, patched, started and killed by plugwright itself. + create("local", LocalMode) { + minecraftVersion.set("1.21.11") + acceptEula.set(true) + runDir.set(file("run")) + + // start.sh is the hand-written launcher the "stand" environment connects to; it + // lives in the run directory and has to survive the clean that precedes each run. + cleanExcludePatterns.set(listOf("server.jar", "cache", "libraries", "start.sh")) + + downloadPlugins { + url("https://hangarcdn.papermc.io/plugins/HelpChat/PlaceholderAPI/versions/2.11.6/PAPER/PlaceholderAPI-2.11.6.jar") + url("https://github.com/AuthMe/AuthMeReloaded/releases/download/6.0.0/AuthMe-6.0.0-Paper.jar") + } + + // Two things the stock AuthMe config does that no bot can answer: it asks for the + // password through Paper's dialog UI, and it allows one registration per IP, while + // a fresh bot name per test means a fresh registration per test from 127.0.0.1. + writeFiles { + // RCON is off in a stock server.properties. The local environment talks to the + // server through its own stdout and never needs it; the "stand" environment, + // which owns no process, has no other way to reach the console. + file("server.properties", """ + enable-rcon=true + rcon.port=25575 + rcon.password=$standRconPassword + """.trimIndent()) + + file("plugins/AuthMe/config.yml", """ + settings: + sessions: + enabled: false + registration: + dialog: + preJoin: + enable: false + postJoin: + enable: false + restrictions: + maxRegPerIp: 0 + maxJoinPerIp: 0 + maxLoginPerIp: 0 + timeout: 60 + allowedNicknameCharacters: '[a-zA-Z0-9_]*' + security: + minPasswordLength: 5 + Protection: + # A test suite is a stream of short-lived logins from one address, + # which is exactly what AuthMe's antibot heuristic exists to stop. + enableAntiBot: false + quickCommands: + # A test sends its first command the moment it is logged in, which + # the stock one-second grace period treats as bot behavior. + denyCommandsBeforeMilliseconds: 0 + """.trimIndent()) + } + + plugins { + npm("@plugwright/auth-authme") { + options["password"] = localBotPassword + } + } + } + + // The same tests against a server plugwright does not own: started by hand from + // ./run, still up when the tests connect, still up after they finish. Out of the + // default matrix because it needs that server to be running. + create("stand", ExternalMode) { + host.set("localhost") + port.set(25565) + minecraftVersion.set("1.21.11") + includeInMatrix.set(false) + joinThrottleMs.set(500) + + // The stand's own console, over the port the local environment enabled in + // server.properties. Without it there is no way to op a bot or read server output. + console { + rcon { + port.set(25575) + password.set(secret.env("PLUGWRIGHT_RCON_PASSWORD")) + } + } + + // Four accounts, leased per test and returned afterwards. They outlive the run, + // so from the second run on they log in instead of registering. + accounts { + autoRegister { + usernamePattern.set("pw_%04d") + password.set(secret.env("PLUGWRIGHT_BOT_PASSWORD")) + max.set(4) + } + } + + plugins { + npm("@plugwright/auth-authme") + // Compiled output of src/test/e2e/plugins/stand-reset.ts. + local(file("src/test/e2e/dist/plugins/stand-reset.js")) + } + + // Matched against test names. What is left out here is what the stand cannot give + // back: a balance, a kit or an arena slot that is spent once and stays spent. Op + // and inventory are reset per test by the stand-reset plugin instead. multi-bot is + // out for a different reason — it names its second bot, and a named bot is not a + // pool account, so nothing knows its password. + excludeTests.set(listOf( + "balance", "send money", "kit", "arena", "shop", "buy", "first join", "multi-bot" + )) + } + } } group = "me.drownek" diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index 5559adb..a039403 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -5,7 +5,9 @@ "packages": { "": { "dependencies": { - "@drownek/plugwright": "file:../../../../runner-package" + "@drownek/plugwright": "file:../../../../runner-package", + "@plugwright/auth-authme": "file:../../../../auth-authme-package", + "@plugwright/console-rcon": "file:../../../../console-rcon-package" }, "devDependencies": { "@types/node": "^22.10.5", @@ -13,6 +15,40 @@ "typescript": "^5.7.3" } }, + "../../../../auth-authme-package": { + "name": "@plugwright/auth-authme", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@drownek/plugwright": ">=2.0.0" + } + }, + "../../../../console-rcon-package": { + "name": "@plugwright/console-rcon", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@drownek/plugwright": ">=2.0.0" + } + }, "../../../../runner-package": { "name": "@drownek/plugwright", "version": "3.0.0-dev.0", @@ -23,6 +59,9 @@ "picocolors": "^1.1.1", "source-map-support": "^0.5.21" }, + "bin": { + "plugwright": "dist/cli.js" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.5", @@ -38,6 +77,14 @@ "resolved": "../../../../runner-package", "link": true }, + "node_modules/@plugwright/auth-authme": { + "resolved": "../../../../auth-authme-package", + "link": true + }, + "node_modules/@plugwright/console-rcon": { + "resolved": "../../../../console-rcon-package", + "link": true + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", diff --git a/example_plugin/src/test/e2e/package.json b/example_plugin/src/test/e2e/package.json index fd63e49..05dd568 100644 --- a/example_plugin/src/test/e2e/package.json +++ b/example_plugin/src/test/e2e/package.json @@ -4,7 +4,9 @@ "build": "rimraf dist && tsc" }, "dependencies": { - "@drownek/plugwright": "file:../../../../runner-package" + "@drownek/plugwright": "file:../../../../runner-package", + "@plugwright/auth-authme": "file:../../../../auth-authme-package", + "@plugwright/console-rcon": "file:../../../../console-rcon-package" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/example_plugin/src/test/e2e/plugins/stand-reset.ts b/example_plugin/src/test/e2e/plugins/stand-reset.ts new file mode 100644 index 0000000..cc0877c --- /dev/null +++ b/example_plugin/src/test/e2e/plugins/stand-reset.ts @@ -0,0 +1,24 @@ +import { definePlugin } from '@drownek/plugwright'; + +/** + * Undoes what one test leaves on a leased account before the next test gets it. + * + * The local environment never needs this: it hands every test a brand new username on a + * server it just created. An external stand has neither — the same four accounts come back + * around all run, still opped and still holding whatever the last test gave them. + * + * Loaded through `plugins { local(...) }` in build.gradle.kts, for the "stand" environment + * only. + */ +export default definePlugin({ + name: 'stand-reset', + + async beforeEach({ player, server }) { + // Nothing to reset with: an environment without a console cannot run commands at all, + // and the tests that depend on this reset are excluded there anyway. + if (!server.session.env.capabilities.console) return; + + await server.executeAndWait(`minecraft:deop ${player.username}`); + await server.executeAndWait(`minecraft:clear ${player.username}`); + }, +}); diff --git a/example_plugin/src/test/e2e/simple-ts.spec.ts b/example_plugin/src/test/e2e/simple-ts.spec.ts index aa59fbd..5c02d35 100644 --- a/example_plugin/src/test/e2e/simple-ts.spec.ts +++ b/example_plugin/src/test/e2e/simple-ts.spec.ts @@ -25,7 +25,9 @@ test('help displays message', async ({ player }) => { await expect(player).toHaveReceivedMessage('Help'); }); -test('server logs command execution', async ({ server }) => { +// Reading the server log needs a console that streams all of it. An environment whose +// console only answers its own commands skips this test instead of failing it. +test('server logs command execution', { requires: ['consoleOutput:full'] }, async ({ server }) => { server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); \ No newline at end of file diff --git a/example_plugin/src/test/e2e/tsconfig.json b/example_plugin/src/test/e2e/tsconfig.json index 7c07169..ca67432 100644 --- a/example_plugin/src/test/e2e/tsconfig.json +++ b/example_plugin/src/test/e2e/tsconfig.json @@ -10,5 +10,5 @@ "sourceMap": true, "inlineSources": true }, - "include": ["*.spec.ts"] + "include": ["*.spec.ts", "plugins/*.ts"] } \ No newline at end of file From 948955726c17d0db5e70f3d967d5dbdd87c57a89 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 20 Aug 2026 23:12:49 +0300 Subject: [PATCH 030/125] feat(runner): let a caller keep part of a chat message out of the log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `chat` logs every message it sends, which is what makes a run readable — until a plugin sends a credential. An authentication plugin has no choice but to put the password in a chat command, and the log then carries it in the clear. `chat(message, { secrets: [...] })` redacts the listed values from the logged copy only; the server still receives the message unchanged. The list comes from the caller because the caller is the only one who knows: `chat` would otherwise have to recognise every plugin's command shapes, and a guess that misses fails open — it prints the secret. An empty list, which is the default, redacts nothing and leaves existing calls as they were. --- runner-package/lib/player.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 4fc1052..2b9bdfb 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -197,8 +197,26 @@ export class PlayerWrapper { return currentWindow ? new GuiWrapper(this.bot, currentWindow as Window) : null; } - chat(message: string): void { - console.log(`${pc.cyan('[Bot]')} ${pc.dim(`Chatting: ${message}`)}`); + /** + * Sends a chat message as this bot. + * + * `options.secrets` lists values that must not appear in the line this call logs — a + * password, a token, anything the caller already holds and knows is sensitive. Each + * occurrence of a listed value is replaced in the *logged* copy of `message`; what goes + * to the server is untouched. + * + * The list is the caller's to supply, and an empty one redacts nothing. Guessing which + * argument of an arbitrary command is a password would mean this method knowing every + * plugin's command shapes, and a guess that misses fails open — it prints the secret. The + * caller is the only one who knows, so the caller says so. + */ + chat(message: string, options: { secrets?: string[] } = {}): void { + const { secrets = [] } = options; + const logged = secrets.reduce( + (text, secret) => (secret ? text.split(secret).join('[REDACTED]') : text), + message, + ); + console.log(`${pc.cyan('[Bot]')} ${pc.dim(`Chatting: ${logged}`)}`); this.bot.chat(message); } From 90213fb62f6c05e519259459163864f88b973872 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 20 Aug 2026 23:13:04 +0300 Subject: [PATCH 031/125] fix(auth-authme): keep the password out of the run log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both chat calls put the password on the wire in the clear, because AuthMe's `/register` and `/login` take it as an argument and there is no other way to answer them. What was avoidable is the log: every run printed `Chatting: /register hunter2 hunter2` verbatim, and the reports and CI output kept it. The calls now declare the password as a secret, so the logged copy reads `/register [REDACTED] [REDACTED]` while the server still receives the real command. This does not make the password a secret in any wider sense — a bot has to send it as plain text over the protocol, and a plugin option still travels as a plain value into the generated config. Accounts whose password is worth protecting belong in the environment's pool, where it travels as a reference and is resolved only when a bot leases it. --- auth-authme-package/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index dad675b..76ff2ed 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -109,7 +109,7 @@ export default definePlugin({ const commandIndex = player.getMessageBufferIndex(); player.chat(isRegistration ? `${resolved.registerCommand} ${password} ${password}` - : `${resolved.loginCommand} ${password}`); + : `${resolved.loginCommand} ${password}`, { secrets: [password] }); await poll(() => since(commandIndex, successPattern), { timeout: resolved.timeoutMs, @@ -127,7 +127,7 @@ export default definePlugin({ if (autoLoggedIn) return; const loginIndex = player.getMessageBufferIndex(); - player.chat(`${resolved.loginCommand} ${password}`); + player.chat(`${resolved.loginCommand} ${password}`, { secrets: [password] }); await poll(() => since(loginIndex, authenticated), { timeout: resolved.timeoutMs, message: `authme: "${account.username}" registered but never logged in`, From 7e5204ae3167f8192c5686c07a3c7b0a286d3f32 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 23 Aug 2026 16:08:20 +0300 Subject: [PATCH 032/125] fix(external): stop pinning the RCON console package to a range it cannot match ExternalMode asked npm for "@plugwright/console-rcon@^1.0.0", written before the package existed. The package ships at the project version, so that range never resolves: the install fails, the task logs a warning, and the environment reports a missing package at run time. Leaving the version off means "whatever the test project already has", which is what the two @drownek/plugwright refs beside it already do. --- .../main/kotlin/me/drownek/plugwright/external/ExternalMode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1efa034..cbf5573 100644 --- 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 @@ -23,7 +23,7 @@ object ExternalMode : PlugwrightMode { 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")) + add(RunnerPackageRef("@plugwright/console-rcon", export = "rconConsole")) } } From bd5ce7de7ed5303ed6ed6402ad0aee347380537c Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 14:37:20 +0300 Subject: [PATCH 033/125] feat(gradle): give the workspace a fixed directory layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specs live in tests/, runner plugins in plugins/, and everything an environment writes at run time goes to generated// — so a local server lands under the workspace instead of a run/ directory next to build.gradle.kts, and two local environments in one matrix stop sharing a server directory. A workspace still holding its specs at the root is moved into tests/ the first time plugwrightCompileTests runs, tsconfig.json included: its include still points at where the specs used to be. plugins { local("stand-reset") } names a plugin instead of spelling out the path its compiled form ends up at. --- .../me/drownek/plugwright/api/PluginRef.kt | 8 + .../me/drownek/plugwright/api/PluginsSpec.kt | 12 +- .../plugwright/api/PlugwrightLayout.kt | 78 +++++++++ .../drownek/plugwright/api/PlugwrightMode.kt | 10 ++ .../plugwright/api/TaskRegistrationContext.kt | 7 +- .../plugwright/PlugwrightCompileTestsTask.kt | 157 ++++++++++++++++-- .../plugwright/PlugwrightCorePlugin.kt | 22 ++- .../drownek/plugwright/PlugwrightExtension.kt | 9 +- .../plugwright/PlugwrightMatrixTask.kt | 8 +- .../drownek/plugwright/PlugwrightTestTask.kt | 22 ++- .../me/drownek/plugwright/RunnerLauncher.kt | 17 +- .../plugwright/TaskRegistrationContextImpl.kt | 2 + .../external/PlugwrightCleanupTask.kt | 2 +- .../plugwright/external/PlugwrightPingTask.kt | 2 +- .../me/drownek/plugwright/local/LocalMode.kt | 9 + 15 files changed, 319 insertions(+), 46 deletions(-) create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.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 index 6e216bf..a3a40a3 100644 --- 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 @@ -16,5 +16,13 @@ data class PluginRef @JvmOverloads constructor( ) : Serializable { companion object { private const val serialVersionUID: Long = 1L + + /** + * Marks a [specifier] that names a plugin in the workspace's `plugins` directory + * rather than an npm package or a path — what `plugins { local("stand-reset") }` + * produces. The build resolves it against [PlugwrightLayout.compiledPluginsDir] + * before the config is written, so the runner only ever sees a real path. + */ + const val WORKSPACE_SCHEME: String = "plugwright-workspace:" } } diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt index f4274dd..37f86b1 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt @@ -32,7 +32,17 @@ class PluginsSpec { entries.add(PluginRef(specifier, spec.options, spec.inheritTests)) } - /** A plugin living as a file in the test project, e.g. under `src/test/e2e`. */ + /** + * A plugin written in the workspace's `plugins` directory, named without its extension: + * `local("stand-reset")` loads what `plugins/stand-reset.ts` compiles into. + */ + fun local(name: String, action: PluginRefSpec.() -> Unit = {}) { + val spec = PluginRefSpec().apply(action) + entries.add(PluginRef(PluginRef.WORKSPACE_SCHEME + name, spec.options, spec.inheritTests)) + } + + /** A plugin at a path of your own choosing. Prefer [local] with a name: it follows the + * workspace layout, so the path stops being something the build script has to know. */ 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-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.kt new file mode 100644 index 0000000..625eb12 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.kt @@ -0,0 +1,78 @@ +package me.drownek.plugwright.api + +import java.io.File + +/** + * The directories inside a plugwright workspace — the directory `plugwright.testsDir` points + * at, `src/test/e2e` by default. + * + * ``` + * src/test/e2e/ + * tests/ spec sources + * plugins/ runner plugin sources + * dist/ compiled output, mirroring the two directories above + * generated// whatever an environment writes while it runs + * ``` + * + * Everything under `dist`, `generated` and `node_modules` is disposable: the build recreates + * it, and `plugwrightInit` writes a `.gitignore` that keeps all three out of version control. + * + * A mode reads the layout through [TaskRegistrationContext.layout], and gets a chance to seed + * spec defaults from it in [PlugwrightMode.applyLayoutDefaults]. + */ +interface PlugwrightLayout { + + /** Root of the npm project: the value of `plugwright.testsDir`. */ + val workspaceDir: File + + /** Spec sources, `/tests`. */ + val testsDir: File + + /** Runner plugin sources, `/plugins`. */ + val pluginsDir: File + + /** Compiled output root, `/dist`. */ + val compiledDir: File + + /** Compiled specs, `/dist/tests`. */ + val compiledTestsDir: File + + /** Compiled runner plugins, `/dist/plugins`. */ + val compiledPluginsDir: File + + /** Root of the per-environment scratch space, `/generated`. */ + val generatedRootDir: File + + /** Where environment [environmentName] writes what it generates: `/generated/`. + * The local mode puts its server here; nothing else may write outside its own directory. */ + fun generatedDir(environmentName: String): File + + /** + * The directory the runner scans for `.spec.js`: the compiled one once it exists, and the + * sources otherwise — a workspace of plain JavaScript specs has nothing to compile. + */ + fun runnableTestsDir(): File + + companion object { + const val TESTS_DIR_NAME = "tests" + const val PLUGINS_DIR_NAME = "plugins" + const val COMPILED_DIR_NAME = "dist" + const val GENERATED_DIR_NAME = "generated" + + /** The layout of the workspace rooted at [workspaceDir]. */ + fun of(workspaceDir: File): PlugwrightLayout = DefaultPlugwrightLayout(workspaceDir) + } +} + +private class DefaultPlugwrightLayout(override val workspaceDir: File) : PlugwrightLayout { + override val testsDir: File get() = File(workspaceDir, PlugwrightLayout.TESTS_DIR_NAME) + override val pluginsDir: File get() = File(workspaceDir, PlugwrightLayout.PLUGINS_DIR_NAME) + override val compiledDir: File get() = File(workspaceDir, PlugwrightLayout.COMPILED_DIR_NAME) + override val compiledTestsDir: File get() = File(compiledDir, PlugwrightLayout.TESTS_DIR_NAME) + override val compiledPluginsDir: File get() = File(compiledDir, PlugwrightLayout.PLUGINS_DIR_NAME) + override val generatedRootDir: File get() = File(workspaceDir, PlugwrightLayout.GENERATED_DIR_NAME) + + override fun generatedDir(environmentName: String): File = File(generatedRootDir, environmentName) + + override fun runnableTestsDir(): File = if (compiledTestsDir.exists()) compiledTestsDir else testsDir +} 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 9763180..1e21937 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 @@ -34,6 +34,16 @@ interface PlugwrightMode { */ fun applyLegacyDefaults(spec: S, legacy: LegacyEnvironmentProperties) {} + /** + * Fills in whatever [spec] leaves unset that follows from the workspace layout, before + * validation and [registerTasks] run. The local mode places its server this way, so a + * build script that never mentions `runDir` still gets one, under + * `/generated/`. + * + * Only set properties the build script did not: an explicit value always wins. + */ + fun applyLayoutDefaults(spec: S, layout: PlugwrightLayout) {} + /** * 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/TaskRegistrationContext.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt index 01e8c03..8c6ffb9 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,9 +29,14 @@ interface TaskRegistrationContext { */ val projectPluginJar: Provider - /** Directory the runner scans for spec files, same value `plugwrightTest` uses. */ + /** Root of the npm project (`plugwright.testsDir`), same value `plugwrightTest` + * uses as its working directory. For the directories inside it, use [layout]. */ val testsDir: Provider + /** Directory conventions of the workspace, including where this environment may write + * what it generates: `layout.generatedDir(environmentName)`. */ + val layout: PlugwrightLayout + /** * Registers a task named `plugwright`, e.g. `plugwrightProvisionLocal` * for `register("Provision", …)` in the `local` environment. 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 index 253bd84..ef05a62 100644 --- 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 @@ -1,23 +1,39 @@ package me.drownek.plugwright +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.stream.JsonReader +import me.drownek.plugwright.api.PlugwrightLayout import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.TaskAction import java.io.File +import java.io.StringReader /** - * Installs the test project's npm dependencies and compiles its TypeScript. + * Installs the workspace's npm dependencies and compiles its TypeScript. + * + * Sources live in two directories — `tests` for specs, `plugins` for runner plugins — and + * `tsc` mirrors both into `dist`. A workspace still holding its specs at the root (the layout + * before `tests` existed) is moved into place the first time this task runs. * * 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 + /** + * Root of the npm project: `plugwright.testsDir`. + * + * Not declared as an input directory: the task never reports itself up to date, and the + * workspace holds `node_modules` and a running server's `generated` directory — fingerprinting + * either of those costs seconds and decides nothing. + */ + @get:Internal abstract val testsDir: DirectoryProperty /** @@ -39,45 +55,154 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { @TaskAction fun compile() { - val userTestsDirectory = if (testsDir.isPresent) { + val workspace = 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}") + if (!workspace.exists()) { + logger.warn("Tests directory does not exist: ${workspace.absolutePath}") return } + val layout = PlugwrightLayout.of(workspace) + migrateRootLevelSpecs(layout) + val nodePaths = resolveNode() val npmEnv = nodePathEnv(nodePaths) // Install dependencies if needed - if (!File(userTestsDirectory, "node_modules").exists()) { + if (!File(workspace, "node_modules").exists()) { logger.lifecycle("Installing Node.js dependencies...") - runCommand(userTestsDirectory, nodePaths.npm, "install", env = npmEnv) + runCommand(workspace, nodePaths.npm, "install", env = npmEnv) } - installMissingRunnerPackages(userTestsDirectory, nodePaths, npmEnv) + installMissingRunnerPackages(workspace, nodePaths, npmEnv) // Build TypeScript tests if tsconfig.json exists - val tsconfigFile = File(userTestsDirectory, "tsconfig.json") + val tsconfigFile = File(workspace, "tsconfig.json") if (tsconfigFile.exists()) { logger.lifecycle("TypeScript config found, compiling tests...") - runCommand(userTestsDirectory, nodePaths.npm, "run", "build", env = npmEnv) + runCommand(workspace, nodePaths.npm, "run", "build", env = npmEnv) } else { logger.lifecycle("No TypeScript config found, running JavaScript tests directly") } } + // ---- Migration ------------------------------------------------------------------- + + /** + * Moves a workspace laid out the old way — specs anywhere under the root — into `tests`. + * + * Runs only while there is no `tests` directory at all, so it happens once and never + * touches a workspace that already follows the layout. The `tsconfig.json` goes along + * with the files: its `include` still describes where the specs used to be. + */ + private fun migrateRootLevelSpecs(layout: PlugwrightLayout) { + if (layout.testsDir.exists()) return + + val strays = findSpecSources(layout.workspaceDir, layout) + if (strays.isEmpty()) return + + strays.forEach { source -> + val destination = File(layout.testsDir, source.relativeTo(layout.workspaceDir).path) + destination.parentFile.mkdirs() + if (!source.renameTo(destination)) { + source.copyTo(destination, overwrite = true) + source.delete() + } + } + removeEmptyDirectories(layout.workspaceDir, layout) + + logger.lifecycle( + "Moved ${strays.size} spec file(s) into ${layout.testsDir.absolutePath} — " + + "plugwright looks for specs under 'tests' now." + ) + retargetTsConfig(layout) + } + + /** Spec files outside the directories the layout owns; empty for a workspace that has + * already been migrated or was created by `plugwrightInit`. */ + private fun findSpecSources(directory: File, layout: PlugwrightLayout): List { + val children = directory.listFiles() ?: return emptyList() + return children.flatMap { child -> + when { + child.isDirectory && isIgnoredDirectory(child, layout) -> emptyList() + child.isDirectory -> findSpecSources(child, layout) + child.name.endsWith(".spec.ts") || child.name.endsWith(".spec.js") -> listOf(child) + else -> emptyList() + } + } + } + + private fun isIgnoredDirectory(directory: File, layout: PlugwrightLayout): Boolean = + directory.name == "node_modules" || directory.name == ".git" || + directory == layout.compiledDir || directory == layout.generatedRootDir || + directory == layout.pluginsDir || directory == layout.testsDir + + private fun removeEmptyDirectories(directory: File, layout: PlugwrightLayout) { + val children = directory.listFiles() ?: return + children.filter { it.isDirectory && !isIgnoredDirectory(it, layout) }.forEach { child -> + removeEmptyDirectories(child, layout) + if (child.list()?.isEmpty() == true) child.delete() + } + } + + /** + * Points a migrated workspace's `tsconfig.json` at the directories the sources now live + * in, and at the `dist` that mirrors them. + * + * A config the parser chokes on (comments are legal in `tsconfig.json`, and JSON says + * otherwise) is left alone with an explanation — a rewrite that drops the comments is a + * worse outcome than an edit by hand. + */ + private fun retargetTsConfig(layout: PlugwrightLayout) { + val tsconfigFile = File(layout.workspaceDir, "tsconfig.json") + if (!tsconfigFile.exists()) return + + val config = try { + JsonParser.parseReader(JsonReader(StringReader(tsconfigFile.readText())).apply { isLenient = true }) + .asJsonObject + } catch (e: Exception) { + logger.warn( + "Could not update ${tsconfigFile.absolutePath} (${e.message}). Point its \"include\" at " + + "\"tests/**/*.ts\" and \"plugins/**/*.ts\" by hand." + ) + return + } + + val compilerOptions = config.getAsJsonObject("compilerOptions") ?: JsonObject().also { + config.add("compilerOptions", it) + } + compilerOptions.addProperty("rootDir", ".") + compilerOptions.addProperty("outDir", "./${PlugwrightLayout.COMPILED_DIR_NAME}") + config.add("include", jsonArrayOf( + "${PlugwrightLayout.TESTS_DIR_NAME}/**/*.ts", + "${PlugwrightLayout.PLUGINS_DIR_NAME}/**/*.ts", + )) + config.add("exclude", jsonArrayOf( + "node_modules", + PlugwrightLayout.COMPILED_DIR_NAME, + PlugwrightLayout.GENERATED_DIR_NAME, + )) + + tsconfigFile.writeText(GsonBuilder().setPrettyPrinting().create().toJson(config) + "\n") + logger.lifecycle("Updated ${tsconfigFile.absolutePath} for the new layout") + } + + private fun jsonArrayOf(vararg values: String): JsonArray = + JsonArray().apply { values.forEach { add(it) } } + + // ---- npm ------------------------------------------------------------------------- + private fun installMissingRunnerPackages( - testsDirectory: File, + workspace: File, nodePaths: NodeManager.NodePaths, npmEnv: Map ) { - val nodeModules = File(testsDirectory, "node_modules") + val nodeModules = File(workspace, "node_modules") val missing = runnerPackages.get().filterNot { spec -> File(nodeModules, packageNameOf(spec)).exists() } @@ -87,7 +212,7 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { try { // --no-save: these come from the build script's environments, so the test project's // package.json shouldn't grow a second, drifting copy of the same decision. - runCommand(testsDirectory, nodePaths.npm, "install", "--no-save", *missing.toTypedArray(), env = npmEnv) + runCommand(workspace, nodePaths.npm, "install", "--no-save", *missing.toTypedArray(), env = npmEnv) } catch (e: Exception) { // A package that can't be installed is not a reason to stop compiling the tests: // only the environment that asked for it is affected, and the runner reports the 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 22c99b9..c6263ef 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 @@ -1,6 +1,8 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNodeBuilder +import me.drownek.plugwright.api.PluginRef +import me.drownek.plugwright.api.PlugwrightLayout import org.gradle.api.GradleException import org.gradle.api.plugins.ExtensionAware import org.gradle.api.Plugin @@ -132,6 +134,7 @@ class PlugwrightCorePlugin : Plugin { ) } + val layout = PlugwrightLayout.of(extension.testsDir.get().asFile) val projectPluginJarProvider = resolveProjectPluginJar(project, extension) val validationProblems = mutableListOf() val reportsDir = project.layout.buildDirectory.dir("reports/plugwright") @@ -147,9 +150,12 @@ class PlugwrightCorePlugin : Plugin { extension.environments.all.forEach { entry -> val envName = entry.spec.name val mode = entry.mode.erased() + // Before validation and registerTasks: a mode fills in what it can derive from + // the layout here, and both of those already expect a complete spec. + mode.applyLayoutDefaults(entry.spec, layout) val ctx = TaskRegistrationContextImpl( project, envName, envName == primaryName, projectPluginJarProvider, - extension.testsDir.map { it.asFile }, extension, defaultNodeInstallDir + extension.testsDir.map { it.asFile }, layout, extension, defaultNodeInstallDir ) val journalFilePath = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl").get().asFile val modePackages = mode.runnerPackages(entry.spec) @@ -201,8 +207,8 @@ class PlugwrightCorePlugin : Plugin { val environmentConfigProvider = ctx.environmentConfigProvider ?: project.provider { ConfigNodeBuilder().apply { mode.serialize(entry.spec, this) }.build() } - val pluginConfigsProvider = ctx.pluginConfigsProvider - ?: project.provider { emptyList() } + val pluginConfigsProvider = (ctx.pluginConfigsProvider ?: project.provider { emptyList() }) + .map { refs -> refs.map { resolveWorkspacePlugin(it, layout) } } // A plugin declared by npm name is installed alongside the environment's own // runner packages; a plugin given as a path is already in the project. @@ -223,7 +229,7 @@ class PlugwrightCorePlugin : Plugin { name = envName, modeId = mode.id, allowFailure = entry.spec.allowFailure.get(), - testsDir = extension.testsDir.get().asFile, + workspaceDir = layout.workspaceDir, configFile = project.layout.buildDirectory.file("tmp/plugwright/$envName.json").get().asFile, jsonReportFile = File(reportsDirFile, "$envName.json"), junitReportFile = File(File(reportsDirFile, "junit"), "$envName.xml"), @@ -262,6 +268,14 @@ class PlugwrightCorePlugin : Plugin { } } + /** Turns `plugins { local("stand-reset") }` into the path the compiler writes it to. + * Anything else — an npm name, a path the build script spelled out — passes through. */ + private fun resolveWorkspacePlugin(ref: PluginRef, layout: PlugwrightLayout): PluginRef { + if (!ref.specifier.startsWith(PluginRef.WORKSPACE_SCHEME)) return ref + val name = ref.specifier.removePrefix(PluginRef.WORKSPACE_SCHEME) + return ref.copy(specifier = File(layout.compiledPluginsDir, "$name.js").absolutePath) + } + /** Whether a plugin specifier names an npm package rather than a file in the project. * Paths are what `plugins { local(file(...)) }` produces; everything else is installable. */ private fun isNpmPackageName(specifier: String): Boolean { 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 5413963..7cf9d0d 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 @@ -73,10 +73,13 @@ abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperti @Deprecated("Use environments { create(\"local\", LocalMode) { acceptEula.set(...) } }") override val acceptEula: Property = project.objects.property(Boolean::class.java).convention(true) + /** + * Left unset on purpose: an absent value is what tells the local mode to place the server + * under `/generated//run`. Setting it here is still honoured, and + * still means "this exact directory". + */ @Deprecated("Use environments { create(\"local\", LocalMode) { runDir.set(...) } }") - override val runDir: DirectoryProperty = project.objects.directoryProperty().convention( - project.layout.projectDirectory.dir("run") - ) + override val runDir: DirectoryProperty = project.objects.directoryProperty() @Deprecated("Use environments { create(\"local\", LocalMode) { cleanExcludePatterns.set(...) } }") override val cleanExcludePatterns: ListProperty = project.objects.listProperty(String::class.java).convention( 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 895a219..ad3e9fa 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 @@ -19,7 +19,7 @@ internal data class MatrixEnvironmentInput( val name: String, val modeId: String, val allowFailure: Boolean, - val testsDir: File, + val workspaceDir: File, val configFile: File, val jsonReportFile: File, val junitReportFile: File, @@ -117,7 +117,7 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { environmentName = env.name, modeId = env.modeId, environmentConfig = env.environmentConfig.get(), - testsDir = env.testsDir, + workspaceDir = env.workspaceDir, configFile = env.configFile, testFiles = fileFilters, testNames = nameFilters, @@ -130,10 +130,10 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { runtimeExport = env.runtimeExport, ) RunnerLauncher.writeConfig(entry) - val cliJs = RunnerLauncher.resolveCliJs(env.testsDir) + val cliJs = RunnerLauncher.resolveCliJs(env.workspaceDir) runCommand( - env.testsDir, nodePaths.node, cliJs.absolutePath, "--config", entry.configFile.absolutePath, + env.workspaceDir, nodePaths.node, cliJs.absolutePath, "--config", entry.configFile.absolutePath, onStdoutLine = { line -> env.logFile.appendText(line + System.lineSeparator()) } ) Outcome(env, readSummary(env.jsonReportFile), null) 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 1cfb18e..0578217 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 @@ -18,8 +18,14 @@ import java.io.File */ abstract class PlugwrightTestTask : AbstractNodeTask() { - @get:InputDirectory - @get:Optional + /** + * Root of the npm project: `plugwright.testsDir`. The runner is pointed at the compiled + * specs inside it — see [RunnerLauncher.writeConfig]. + * + * Not an input directory, for the same reason as in [PlugwrightCompileTestsTask]: this + * task always runs, and the workspace now contains the server's own generated files. + */ + @get:Internal abstract val testsDir: DirectoryProperty @get:Input @@ -94,15 +100,15 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { fun runTests() { val nodePaths = resolveNode() - val userTestsDirectory = if (testsDir.isPresent) { + val workspace = 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}") + if (!workspace.exists()) { + logger.warn("Tests directory does not exist: ${workspace.absolutePath}") return } @@ -113,7 +119,7 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { environmentName = environmentName.get(), modeId = modeId.get(), environmentConfig = environmentConfig.get(), - testsDir = userTestsDirectory, + workspaceDir = workspace, configFile = configDestination, testFiles = with(RunnerLauncher) { testFiles.orNull.splitFilter() }, testNames = with(RunnerLauncher) { testNames.orNull.splitFilter() }, @@ -128,9 +134,9 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { RunnerLauncher.writeConfig(entry) logger.lifecycle("Runner config: ${configDestination.absolutePath}") - val cliJsFile = RunnerLauncher.resolveCliJs(userTestsDirectory) + val cliJsFile = RunnerLauncher.resolveCliJs(workspace) - runCommand(userTestsDirectory, nodePaths.node, cliJsFile.absolutePath, "--config", configDestination.absolutePath) + runCommand(workspace, nodePaths.node, cliJsFile.absolutePath, "--config", configDestination.absolutePath) logger.lifecycle("E2E tests completed successfully") } 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 36349ec..31abd16 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 @@ -3,6 +3,7 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNode import me.drownek.plugwright.api.ConfigNodeBuilder import me.drownek.plugwright.api.PluginRef +import me.drownek.plugwright.api.PlugwrightLayout import org.gradle.api.GradleException import java.io.File @@ -21,7 +22,9 @@ object RunnerLauncher { val environmentName: String, val modeId: String, val environmentConfig: ConfigNode, - val testsDir: File, + /** Root of the npm project. The directory the runner actually scans is derived from + * it — see [PlugwrightLayout.runnableTestsDir]. */ + val workspaceDir: File, val configFile: File, val testFiles: List?, val testNames: List?, @@ -57,7 +60,7 @@ object RunnerLauncher { put("config", entry.environmentConfig) } obj("tests") { - put("dir", entry.testsDir.absolutePath) + put("dir", PlugwrightLayout.of(entry.workspaceDir).runnableTestsDir().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") @@ -89,20 +92,20 @@ object RunnerLauncher { RunnerConfigWriter.write(entry.configFile, root) } - /** Resolves `cli.js` relative to a test project's `node_modules`, falling back to the + /** Resolves `cli.js` relative to the workspace'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") + fun resolveCliJs(workspaceDir: File): File { + val defaultCliJs = File(workspaceDir, "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") + File(workspaceDir, "../../../../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}?" + "Did 'npm install' succeed in ${workspaceDir.absolutePath}?" ) } 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 4fae8ff..e4bb5e3 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 @@ -2,6 +2,7 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNode import me.drownek.plugwright.api.PluginRef +import me.drownek.plugwright.api.PlugwrightLayout import me.drownek.plugwright.api.TaskRegistrationContext import org.gradle.api.Project import org.gradle.api.Task @@ -20,6 +21,7 @@ internal class TaskRegistrationContextImpl( private val isPrimary: Boolean, override val projectPluginJar: Provider, override val testsDir: Provider, + override val layout: PlugwrightLayout, private val extension: PlugwrightExtension, private val nodeInstallDir: File ) : TaskRegistrationContext { 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 index 9a01f8b..849f9b3 100644 --- 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 @@ -52,7 +52,7 @@ abstract class PlugwrightCleanupTask : AbstractNodeTask() { environmentName = environmentName.get(), modeId = modeId.get(), environmentConfig = environmentConfig.get(), - testsDir = userTestsDirectory, + workspaceDir = userTestsDirectory, configFile = configFile.get().asFile, testFiles = null, testNames = null, 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 index c141a80..85933e5 100644 --- 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 @@ -48,7 +48,7 @@ abstract class PlugwrightPingTask : AbstractNodeTask() { environmentName = environmentName.get(), modeId = modeId.get(), environmentConfig = environmentConfig.get(), - testsDir = userTestsDirectory, + workspaceDir = userTestsDirectory, configFile = configFile.get().asFile, testFiles = null, testNames = null, 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 index 861436b..d673876 100644 --- 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 @@ -3,6 +3,7 @@ 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.PlugwrightLayout import me.drownek.plugwright.api.PlugwrightMode import me.drownek.plugwright.api.RunnerPackageRef import me.drownek.plugwright.api.TaskRegistrationContext @@ -38,6 +39,14 @@ object LocalMode : PlugwrightMode { } } + /** The server lives under the workspace, in this environment's own generated directory, + * unless the build script named a directory itself. */ + override fun applyLayoutDefaults(spec: LocalEnvironmentSpec, layout: PlugwrightLayout) { + if (!spec.runDir.isPresent) { + spec.runDir.set(File(layout.generatedDir(spec.name), "run")) + } + } + override fun applyLegacyDefaults(spec: LocalEnvironmentSpec, legacy: LegacyEnvironmentProperties) { spec.minecraftVersion.set(legacy.minecraftVersion) spec.jvmArgs.set(legacy.jvmArgs) From 3b9fa83ced4ad664ffff5514329d97578bfc5c7c Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 14:41:34 +0300 Subject: [PATCH 034/125] feat(init): scaffold the layout, gitignore included plugwrightInit now creates tests/example.spec.ts, plugins/example-plugin.ts and a .gitignore covering node_modules, dist and generated. An existing .gitignore gets the missing lines appended rather than replaced. The scaffolded files live in resources as real .ts/.json files instead of Kotlin string literals, so an editor checks them and nothing needs escaping. The runner dependency follows the plugin's own version instead of a range last updated by hand. --- .../plugwright/PlugwrightCorePlugin.kt | 139 +++++++++--------- .../plugwright-init/example-plugin.ts | 34 +++++ .../resources/plugwright-init/example.spec.ts | 6 + .../main/resources/plugwright-init/gitignore | 6 + .../resources/plugwright-init/package.json | 14 ++ .../resources/plugwright-init/tsconfig.json | 26 ++++ 6 files changed, 155 insertions(+), 70 deletions(-) create mode 100644 gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts create mode 100644 gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts create mode 100644 gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore create mode 100644 gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json create mode 100644 gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json 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 c6263ef..60c47cd 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 @@ -336,76 +336,12 @@ class PlugwrightCorePlugin : Plugin { throw GradleException("IO ERROR: Failed to create target directory: ${targetDir.absolutePath}. Check your file permissions.") } - val packageJson = targetDir.resolve("package.json") - if (!packageJson.exists()) { - packageJson.writeText( - """ - { - "type": "module", - "scripts": { - "build": "rimraf dist && tsc" - }, - "dependencies": { - "@drownek/plugwright": "^2.0.3" - }, - "devDependencies": { - "@types/node": "^22.10.5", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - } - } - """.trimIndent() - ) - project.logger.lifecycle("Created: ${packageJson.absolutePath}") - } - - val tsconfigJson = targetDir.resolve("tsconfig.json") - if (!tsconfigJson.exists()) { - tsconfigJson.writeText( - """ - { - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "node", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": ".", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": false, - "sourceMap": true - }, - "include": [ - "*.spec.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] - } - """.trimIndent() - ) - project.logger.lifecycle("Created: ${tsconfigJson.absolutePath}") - } - - val testFile = targetDir.resolve("example.spec.ts") - if (!testFile.exists()) { - testFile.writeText( - """ - import {expect, test} from '@drownek/plugwright'; - - test('help displays message', async ({ player, server }) => { - player.chat('/help'); - await expect(player).toHaveReceivedMessage('Help'); - }); - """.trimIndent() - ) - project.logger.lifecycle("Created: ${testFile.absolutePath}") - } + val layout = PlugwrightLayout.of(targetDir) + writeGitignore(project, targetDir) + writeIfAbsent(project, targetDir.resolve("package.json"), initTemplate("package.json")) + writeIfAbsent(project, targetDir.resolve("tsconfig.json"), initTemplate("tsconfig.json")) + writeIfAbsent(project, layout.testsDir.resolve("example.spec.ts"), initTemplate("example.spec.ts")) + writeIfAbsent(project, layout.pluginsDir.resolve("example-plugin.ts"), initTemplate("example-plugin.ts")) project.logger.lifecycle("Executing 'npm install' in ${targetDir.absolutePath}...") val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get()) @@ -431,6 +367,9 @@ class PlugwrightCorePlugin : Plugin { } project.logger.lifecycle("Dependencies installed successfully.") project.logger.lifecycle("\nYou're all set! Run tests with: ./gradlew plugwrightTest") + project.logger.lifecycle( + "To load the example plugin, add plugins { local(\"example-plugin\") } to an environment." + ) } catch (e: Exception) { if (e is GradleException) throw e throw GradleException("EXEC FATAL: Failed to launch npm process. Original error: ${e.message}", e) @@ -438,4 +377,64 @@ class PlugwrightCorePlugin : Plugin { } } } + + private fun writeIfAbsent(project: Project, file: File, content: String) { + if (file.exists()) return + file.parentFile?.mkdirs() + file.writeText(content) + project.logger.lifecycle("Created: ${file.absolutePath}") + } + + /** + * One of the files `plugwrightInit` scaffolds, from `src/main/resources/plugwright-init`. + * + * They are real `.ts` / `.json` files rather than string literals in here, so an editor + * checks them and nothing has to be escaped past the Kotlin parser. `@runnerVersion@` is + * the only placeholder. + */ + private fun initTemplate(name: String): String { + val stream = PlugwrightCorePlugin::class.java.getResourceAsStream("/plugwright-init/$name") + ?: throw GradleException("plugwright is missing its '$name' template. Reinstall the plugin.") + return stream.bufferedReader().use { it.readText() } + .replace("@runnerVersion@", runnerVersionRange()) + } + + /** + * Keeps the three generated directories out of version control. + * + * Appends to a `.gitignore` that is already there rather than replacing it: the workspace + * may well have entries of its own, and none of them are this task's to decide about. + */ + private fun writeGitignore(project: Project, workspaceDir: File) { + val gitignore = File(workspaceDir, ".gitignore") + val template = initTemplate("gitignore") + + if (!gitignore.exists()) { + gitignore.writeText(template) + project.logger.lifecycle("Created: ${gitignore.absolutePath}") + return + } + + val required = template.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") } + val present = gitignore.readLines().map { it.trim().trimEnd('/') }.toSet() + val missing = required.filter { it.trimEnd('/') !in present } + if (missing.isEmpty()) return + + val separator = if (gitignore.readText().endsWith("\n")) "" else "\n" + gitignore.appendText(separator + missing.joinToString("\n", postfix = "\n")) + project.logger.lifecycle("Added ${missing.joinToString(", ")} to ${gitignore.absolutePath}") + } + + /** + * npm range for the runner that goes with this plugin: `2.0.4-dev.0` asks for `^2.0.0`. + * + * The runner and the plugin are released together, so the plugin's own version is the + * right thing to derive from — but only down to the minor. A pre-release plugin names a + * patch npm has never seen, and `^2.0.0` resolves to the newest 2.x either way. + */ + private fun runnerVersionRange(): String { + val match = Regex("""^(\d+)\.(\d+)\.""").find(Banner.pluginVersion()) ?: return "latest" + val (major, minor) = match.destructured + return "^$major.$minor.0" + } } diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts new file mode 100644 index 0000000..41e7e9b --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts @@ -0,0 +1,34 @@ +import { definePlugin } from '@drownek/plugwright'; + +/** + * A runner plugin: hooks that run around every test, plus fixtures the tests can + * destructure. Load it by adding this to the environment in build.gradle.kts: + * + * plugins { local("example-plugin") } + * + * The name is the file name — plugwright compiles plugins/example-plugin.ts into + * dist/plugins/example-plugin.js and points the runner at that. + */ +export default definePlugin({ + name: 'example-plugin', + + // Runs before every test, with the bot already connected. + async beforeEach({ player, server }) { + // An environment without a console has no way to run commands, and says so. + if (!server.session.env.capabilities.console) return; + await server.executeAndWait(`minecraft:gamemode survival ${player.username}`); + }, + + // What this returns becomes part of the object every test destructures: + // test('...', async ({ player, say }) => { ... }) + extendContext({ player }) { + return { say: (message: string) => player.chat(message) }; + }, +}); + +// Without this block the fixture still works and TypeScript still complains. +declare module '@drownek/plugwright' { + interface TestContext { + say: (message: string) => void; + } +} diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts new file mode 100644 index 0000000..7c57f24 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts @@ -0,0 +1,6 @@ +import {expect, test} from '@drownek/plugwright'; + +test('help displays message', async ({ player, server }) => { + player.chat('/help'); + await expect(player).toHaveReceivedMessage('Help'); +}); diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore new file mode 100644 index 0000000..8cedc9f --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore @@ -0,0 +1,6 @@ +# Installed by plugwrightCompileTests +node_modules/ +# Compiled specs and plugins +dist/ +# Whatever the environments write while they run: servers, worlds, logs +generated/ diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json new file mode 100644 index 0000000..b24a898 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json @@ -0,0 +1,14 @@ +{ + "type": "module", + "scripts": { + "build": "rimraf dist && tsc" + }, + "dependencies": { + "@drownek/plugwright": "@runnerVersion@" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + } +} diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json new file mode 100644 index 0000000..a591734 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": [ + "tests/**/*.ts", + "plugins/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "generated" + ] +} From 0c293a5933394d44892305420b50fb0c6e0b79be Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 14:49:19 +0300 Subject: [PATCH 035/125] refactor(example): move the example onto the new layout Specs go to tests/, the server the local environment starts goes to generated/local/run, and the stand-reset plugin is named rather than pointed at through dist/. npm cannot install a package linked by path while that package has a prepare script: it packs the directory into a staging copy without dev dependencies, so the build there fails and takes the whole install with it. The three local packages are built up front instead, by the same two scripts CI now runs. --- .github/workflows/ci.yml | 9 +++++---- .gitignore | 6 ++---- auth-authme-package/package-lock.json | 3 +++ auth-authme-package/package.json | 1 - console-rcon-package/package-lock.json | 3 +++ console-rcon-package/package.json | 1 - example_plugin/build.gradle.kts | 10 ++++++---- example_plugin/src/test/e2e/.gitignore | 6 ++++++ .../src/test/e2e/{ => tests}/commands.spec.ts | 0 .../src/test/e2e/{ => tests}/describe.spec.ts | 0 .../src/test/e2e/{ => tests}/economy.spec.ts | 0 example_plugin/src/test/e2e/{ => tests}/events.spec.ts | 0 example_plugin/src/test/e2e/{ => tests}/kits.spec.ts | 0 .../src/test/e2e/{ => tests}/minigame.spec.ts | 0 .../src/test/e2e/{ => tests}/multi-bot.spec.ts | 0 .../src/test/e2e/{ => tests}/pagination.spec.ts | 0 .../src/test/e2e/{ => tests}/player-wrapper.spec.ts | 0 example_plugin/src/test/e2e/{ => tests}/shop.spec.ts | 0 .../src/test/e2e/{ => tests}/simple-ts.spec.ts | 0 .../src/test/e2e/{ => tests}/teleport.spec.ts | 0 example_plugin/src/test/e2e/tsconfig.json | 6 ++++-- package.json | 6 ++++-- runner-package/package-lock.json | 3 +++ runner-package/package.json | 1 - 24 files changed, 36 insertions(+), 19 deletions(-) create mode 100644 example_plugin/src/test/e2e/.gitignore rename example_plugin/src/test/e2e/{ => tests}/commands.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/describe.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/economy.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/events.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/kits.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/minigame.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/multi-bot.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/pagination.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/player-wrapper.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/shop.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/simple-ts.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/teleport.spec.ts (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be881fa..2bb8373 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Build runner-package + # The example links all three by path, and npm cannot build a linked package: it packs + # the directory into a staging copy that never gets its dev dependencies. + - name: Build the local npm packages run: | - cd runner-package - npm install - npm run build + npm run install:packages + npm run build:packages - uses: drownek/plugwright-action@v1 with: java-version: "17" diff --git a/.gitignore b/.gitignore index 9df230c..8f8de63 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,8 @@ logs/ *.tsbuildinfo dist/ -# Test server runtime +# Test server runtime — plugwright writes it under /generated/ +generated/ run/ test-server/ **/server.properties @@ -62,9 +63,6 @@ test-server/ **/spigot.jar server.jar -# Compiled test files -**/e2e/dist/ - # Temporary files *.tmp *.temp diff --git a/auth-authme-package/package-lock.json b/auth-authme-package/package-lock.json index 51e9f33..15a0e5f 100644 --- a/auth-authme-package/package-lock.json +++ b/auth-authme-package/package-lock.json @@ -32,6 +32,9 @@ "picocolors": "^1.1.1", "source-map-support": "^0.5.21" }, + "bin": { + "plugwright": "dist/cli.js" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.5", diff --git a/auth-authme-package/package.json b/auth-authme-package/package.json index 3a8f25e..0a92837 100644 --- a/auth-authme-package/package.json +++ b/auth-authme-package/package.json @@ -7,7 +7,6 @@ "types": "dist/index.d.ts", "scripts": { "build": "rimraf dist && tsc", - "prepare": "npm run build", "prepublishOnly": "npm run build", "watch": "tsc --watch", "typecheck": "tsc --noEmit" diff --git a/console-rcon-package/package-lock.json b/console-rcon-package/package-lock.json index eb1a967..c14af36 100644 --- a/console-rcon-package/package-lock.json +++ b/console-rcon-package/package-lock.json @@ -32,6 +32,9 @@ "picocolors": "^1.1.1", "source-map-support": "^0.5.21" }, + "bin": { + "plugwright": "dist/cli.js" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.5", diff --git a/console-rcon-package/package.json b/console-rcon-package/package.json index a182928..40f3621 100644 --- a/console-rcon-package/package.json +++ b/console-rcon-package/package.json @@ -7,7 +7,6 @@ "types": "dist/index.d.ts", "scripts": { "build": "rimraf dist && tsc", - "prepare": "npm run build", "prepublishOnly": "npm run build", "watch": "tsc --watch", "typecheck": "tsc --noEmit" diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index 22e402d..d1e9668 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -29,7 +29,8 @@ plugwright { create("local", LocalMode) { minecraftVersion.set("1.21.11") acceptEula.set(true) - runDir.set(file("run")) + // No runDir: the server goes to src/test/e2e/generated/local/run, which is where + // the layout puts what an environment generates. // start.sh is the hand-written launcher the "stand" environment connects to; it // lives in the run directory and has to survive the clean that precedes each run. @@ -90,7 +91,8 @@ plugwright { } // The same tests against a server plugwright does not own: started by hand from - // ./run, still up when the tests connect, still up after they finish. Out of the + // src/test/e2e/generated/local/run, still up when the tests connect, still up after + // they finish (the local environment left it there). Out of the // default matrix because it needs that server to be running. create("stand", ExternalMode) { host.set("localhost") @@ -120,8 +122,8 @@ plugwright { plugins { npm("@plugwright/auth-authme") - // Compiled output of src/test/e2e/plugins/stand-reset.ts. - local(file("src/test/e2e/dist/plugins/stand-reset.js")) + // src/test/e2e/plugins/stand-reset.ts, by the name of the file. + local("stand-reset") } // Matched against test names. What is left out here is what the stand cannot give diff --git a/example_plugin/src/test/e2e/.gitignore b/example_plugin/src/test/e2e/.gitignore new file mode 100644 index 0000000..8cedc9f --- /dev/null +++ b/example_plugin/src/test/e2e/.gitignore @@ -0,0 +1,6 @@ +# Installed by plugwrightCompileTests +node_modules/ +# Compiled specs and plugins +dist/ +# Whatever the environments write while they run: servers, worlds, logs +generated/ diff --git a/example_plugin/src/test/e2e/commands.spec.ts b/example_plugin/src/test/e2e/tests/commands.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/commands.spec.ts rename to example_plugin/src/test/e2e/tests/commands.spec.ts diff --git a/example_plugin/src/test/e2e/describe.spec.ts b/example_plugin/src/test/e2e/tests/describe.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/describe.spec.ts rename to example_plugin/src/test/e2e/tests/describe.spec.ts diff --git a/example_plugin/src/test/e2e/economy.spec.ts b/example_plugin/src/test/e2e/tests/economy.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/economy.spec.ts rename to example_plugin/src/test/e2e/tests/economy.spec.ts diff --git a/example_plugin/src/test/e2e/events.spec.ts b/example_plugin/src/test/e2e/tests/events.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/events.spec.ts rename to example_plugin/src/test/e2e/tests/events.spec.ts diff --git a/example_plugin/src/test/e2e/kits.spec.ts b/example_plugin/src/test/e2e/tests/kits.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/kits.spec.ts rename to example_plugin/src/test/e2e/tests/kits.spec.ts diff --git a/example_plugin/src/test/e2e/minigame.spec.ts b/example_plugin/src/test/e2e/tests/minigame.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/minigame.spec.ts rename to example_plugin/src/test/e2e/tests/minigame.spec.ts diff --git a/example_plugin/src/test/e2e/multi-bot.spec.ts b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/multi-bot.spec.ts rename to example_plugin/src/test/e2e/tests/multi-bot.spec.ts diff --git a/example_plugin/src/test/e2e/pagination.spec.ts b/example_plugin/src/test/e2e/tests/pagination.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/pagination.spec.ts rename to example_plugin/src/test/e2e/tests/pagination.spec.ts diff --git a/example_plugin/src/test/e2e/player-wrapper.spec.ts b/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/player-wrapper.spec.ts rename to example_plugin/src/test/e2e/tests/player-wrapper.spec.ts diff --git a/example_plugin/src/test/e2e/shop.spec.ts b/example_plugin/src/test/e2e/tests/shop.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/shop.spec.ts rename to example_plugin/src/test/e2e/tests/shop.spec.ts diff --git a/example_plugin/src/test/e2e/simple-ts.spec.ts b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/simple-ts.spec.ts rename to example_plugin/src/test/e2e/tests/simple-ts.spec.ts diff --git a/example_plugin/src/test/e2e/teleport.spec.ts b/example_plugin/src/test/e2e/tests/teleport.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/teleport.spec.ts rename to example_plugin/src/test/e2e/tests/teleport.spec.ts diff --git a/example_plugin/src/test/e2e/tsconfig.json b/example_plugin/src/test/e2e/tsconfig.json index ca67432..68a0509 100644 --- a/example_plugin/src/test/e2e/tsconfig.json +++ b/example_plugin/src/test/e2e/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "node", + "rootDir": ".", "outDir": "./dist", "strict": true, "esModuleInterop": true, @@ -10,5 +11,6 @@ "sourceMap": true, "inlineSources": true }, - "include": ["*.spec.ts", "plugins/*.ts"] -} \ No newline at end of file + "include": ["tests/**/*.ts", "plugins/**/*.ts"], + "exclude": ["node_modules", "dist", "generated"] +} diff --git a/package.json b/package.json index 0de69f6..9653ab4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,8 @@ { "private": true, "scripts": { - "bump": "node scripts/bump-version.js" + "bump": "node scripts/bump-version.js", + "install:packages": "npm install --prefix runner-package && npm install --prefix auth-authme-package && npm install --prefix console-rcon-package", + "build:packages": "npm run build --prefix runner-package && npm run build --prefix auth-authme-package && npm run build --prefix console-rcon-package" } -} \ No newline at end of file +} diff --git a/runner-package/package-lock.json b/runner-package/package-lock.json index e021a78..0fd1f73 100644 --- a/runner-package/package-lock.json +++ b/runner-package/package-lock.json @@ -14,6 +14,9 @@ "picocolors": "^1.1.1", "source-map-support": "^0.5.21" }, + "bin": { + "plugwright": "dist/cli.js" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.5", diff --git a/runner-package/package.json b/runner-package/package.json index 7032c9a..4593c44 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -10,7 +10,6 @@ }, "scripts": { "build": "rimraf dist && tsc", - "prepare": "npm run build", "prepublishOnly": "npm run build", "watch": "tsc --watch", "typecheck": "tsc --noEmit" From 8b31c29927659935a92e9667192c551e977ee4fe Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 14:52:36 +0300 Subject: [PATCH 036/125] docs: document the workspace layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a page describing the directories — tests, plugins, dist, generated — and what happens to a project still laid out the old way. The pages that showed runDir, a path to a compiled plugin, or a spec at the root of testsDir now show the current shape instead. --- README.md | 15 ++++++-- docs/configuration.mdx | 21 +++++----- docs/custom-modes.mdx | 16 ++++++++ docs/docs.json | 1 + docs/environments.mdx | 3 +- docs/plugins.mdx | 6 +-- docs/project-layout.mdx | 82 ++++++++++++++++++++++++++++++++++++++++ docs/quickstart.mdx | 15 ++++++-- docs/writing-tests.mdx | 2 +- example_plugin/README.md | 22 ++++++----- 10 files changed, 149 insertions(+), 34 deletions(-) create mode 100644 docs/project-layout.mdx diff --git a/README.md b/README.md index 4629518..da8600f 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,22 @@ plugwright { **2. Initialize the test folder:** -Run the init command to set up your test folder. -This will automatically generate your package.json, TypeScript configuration, and an example test in a chosen directory. +Run the init command to set up your test folder. It asks where to put it, then writes an npm project with a `package.json`, a TypeScript config, a `.gitignore`, an example spec and an example runner plugin: -This command is interactive, so simply follow the prompts on your screen: ```bash ./gradlew plugwrightInit ``` +``` +src/test/e2e/ + tests/example.spec.ts your specs go here + plugins/example-plugin.ts hooks, fixtures and matchers + package.json, tsconfig.json + .gitignore node_modules, dist, generated +``` + +Compiled specs land in `dist`, and everything an environment writes — the Paper server the local one starts, for instance — in `generated`. Neither belongs in version control. See [Project Layout](https://plugwright.dev/project-layout). + **3. Run your tests:** ```bash @@ -131,6 +139,7 @@ plugwright { `./gradlew plugwrightTest` runs the matrix and prints a summary per environment; `./gradlew plugwrightTestStaging` runs one. A server behind a login wall needs a runner plugin to get past it, and `@plugwright/auth-authme` is the reference implementation for AuthMe-style login. Writing your own kind of environment — a proxy, a Compose stack — is a Kotlin mode plus an npm package. +- [Project layout](https://plugwright.dev/project-layout) — where specs, plugins and generated files live - [Environments](https://plugwright.dev/environments) — modes, tasks, the matrix - [External servers](https://plugwright.dev/external-servers) — console channels, account pools, cleanup - [Runner plugins](https://plugwright.dev/plugins) — hooks, fixtures, matchers, inherited tests diff --git a/docs/configuration.mdx b/docs/configuration.mdx index bbe5ac4..448ce30 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -18,7 +18,6 @@ plugwright { environments { create("local", LocalMode) { minecraftVersion.set("1.21.11") - runDir.set(file("run")) acceptEula.set(true) } } @@ -40,10 +39,11 @@ plugwright { // ---- Server Configuration ---- // Version of Paper server to download and run minecraftVersion.set("1.19.4") - - // Directory where the test server will be located - runDir.set(file("run")) - + + // Where the test server lives. Leave it out and it goes to + // /generated//run + // runDir.set(file("/mnt/fast-disk/paper")) + // Automatically accept the Minecraft EULA acceptEula.set(true) @@ -86,21 +86,20 @@ minecraftVersion.set("1.20.1") ``` - Directory where the test server will be located. Default is `project.layout.projectDirectory.dir("run")`. + Directory where the test server will be located. Unset by default, which puts it in `/generated//run` — set it only to keep the server somewhere else. See [Project Layout](/project-layout). ```kotlin -runDir.set(file("run")) -runDir.set(file("test-server")) +runDir.set(file("/mnt/fast-disk/paper")) ``` - Directory containing test files. Default is `file("src/test/e2e")`. + Root of the test workspace: the npm project, the `tests` and `plugins` sources, and the `dist` and `generated` directories the build writes. Default is `file("src/test/e2e")`. ```kotlin testsDir.set(file("src/test/e2e")) -testsDir.set(file("tests/integration")) +testsDir.set(file("e2e")) ``` @@ -237,7 +236,7 @@ Per-environment, inside `create(...) { }`: - Runner plugins this environment loads: `npm("@scope/name") { options["key"] = "value" }` for a published package, `local(file("…"))` for a compiled file in your test project. See [Runner Plugins](/plugins). + Runner plugins this environment loads: `npm("@scope/name") { options["key"] = "value" }` for a published package, `local("name")` for one of your own in the workspace's `plugins` directory. See [Runner Plugins](/plugins). ```kotlin diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx index b422cc2..6a43ecb 100644 --- a/docs/custom-modes.mdx +++ b/docs/custom-modes.mdx @@ -90,6 +90,22 @@ What each piece is for: - `serialize` writes `environment.config` at configuration time. Secrets stay `SecretRef`s here — `node.put("password", spec.password.get())` writes a reference, not a password. - `registerTasks` adds tasks named `plugwright`, so `register("Up", ...)` in an environment called `proxy` gives `plugwrightUpProxy`. `prepareTask` marks the one that has to run before the tests do. +### Files your mode generates + +Anything written while an environment runs belongs under `ctx.layout.generatedDir(ctx.environmentName)` — `src/test/e2e/generated/proxy` for the mode above. That directory is gitignored and is yours alone; no other environment writes there. + +If the spec has a property for it, fill the default in `applyLayoutDefaults` rather than in the property's convention. It runs before validation, only for properties the build script left unset, so an explicit value in the build script still wins: + +```kotlin +override fun applyLayoutDefaults(spec: VelocityEnvironmentSpec, layout: PlugwrightLayout) { + if (!spec.workDir.isPresent) { + spec.workDir.set(File(layout.generatedDir(spec.name), "compose")) + } +} +``` + +`PlugwrightLayout` also knows where the sources and the compiled output are: `testsDir`, `pluginsDir`, `compiledTestsDir`, `compiledPluginsDir`. See [Project Layout](/project-layout). + Preparation belongs in a task rather than a callback. A callback executed inside someone else's `@TaskAction` drags your mode object into that task's state, breaks the configuration cache, and can never be run on its own. A task with declared inputs and outputs gets up-to-date checks and a name someone can type. If a config value needs something only a task can reach — the Java toolchain, a Gradle service — set it from `registerTasks` with `ctx.environmentConfig(provider)` instead of from `serialize`. That is what `LocalMode` does for the Java executable path. diff --git a/docs/docs.json b/docs/docs.json index 55dccec..08d7e9b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -20,6 +20,7 @@ "pages": [ "introduction", "quickstart", + "project-layout", "configuration" ] }, diff --git a/docs/environments.mdx b/docs/environments.mdx index d9f8251..78f5ccf 100644 --- a/docs/environments.mdx +++ b/docs/environments.mdx @@ -26,7 +26,6 @@ plugwright { create("local", LocalMode) { minecraftVersion.set("1.21.11") acceptEula.set(true) - runDir.set(file("run")) } create("staging", ExternalMode) { @@ -38,7 +37,7 @@ plugwright { } ``` -The name you pass to `create` becomes the task suffix and the report file name: `local` gives you `plugwrightTestLocal` and `build/reports/plugwright/local.json`. +The name you pass to `create` becomes the task suffix and the report file name: `local` gives you `plugwrightTestLocal` and `build/reports/plugwright/local.json`. It also names the directory the environment writes to — `src/test/e2e/generated/local`, where the Paper server for that environment ends up. Two local environments in one build therefore run two separate servers without either one saying where. See [Project Layout](/project-layout). A build script with no `environments { }` block still works. The flat properties (`minecraftVersion`, `runDir`, `downloadPlugins`, and the rest) describe one implicit `local` environment, exactly as they did before. See [Configuration](/configuration). diff --git a/docs/plugins.mdx b/docs/plugins.mdx index 9e33281..a9dc070 100644 --- a/docs/plugins.mdx +++ b/docs/plugins.mdx @@ -13,14 +13,14 @@ create("staging", ExternalMode) { npm("@plugwright/auth-authme") { options["loginCommand"] = "/log" } - local(file("src/test/e2e/dist/plugins/staging.js")) { + local("staging") { inheritTests = false } } } ``` -`npm(...)` names a published package, installed by `plugwrightCompileTests` along with the rest of the environment's packages. `local(...)` points at a compiled file in your own test project. Options are plain strings — anything secret belongs in `accounts { }`, where it stays a secret reference. +`npm(...)` names a published package, installed by `plugwrightCompileTests` along with the rest of the environment's packages. `local(...)` names a plugin of your own: `local("staging")` is `plugins/staging.ts` in the test workspace, compiled to `dist/plugins/staging.js` by the same `tsc` run as your specs. For a plugin that lives outside the workspace there is still `local(file("..."))`. Options are plain strings — anything secret belongs in `accounts { }`, where it stays a secret reference. `LocalMode` takes the same block. A local server running an authentication plugin needs the login hook exactly as much as a remote one does. @@ -82,7 +82,7 @@ tests: [ `preflight` tests run before any user spec and abort the run when they fail — there is no point testing a shop when nobody can log in. `suite` tests run alongside your own and are tagged with the plugin's name in the report. -Spec discovery skips `node_modules`, so this is the only way a packaged test ever runs. Per-plugin, `inheritTests = false` loads the hooks and matchers without the tests. +Spec discovery only looks at your own compiled `tests` directory, so this is the only way a packaged test ever runs. Per-plugin, `inheritTests = false` loads the hooks and matchers without the tests. ## Fixtures diff --git a/docs/project-layout.mdx b/docs/project-layout.mdx new file mode 100644 index 0000000..cd4cf8e --- /dev/null +++ b/docs/project-layout.mdx @@ -0,0 +1,82 @@ +--- +title: "Project Layout" +description: "Where the specs, the plugins and the generated files live." +--- + +Everything plugwright needs sits under one directory — `src/test/e2e` unless you point `testsDir` somewhere else. It is an npm project, so `package.json` and `node_modules` are there too: + +``` +src/test/e2e/ + package.json the npm project the runner is installed into + tsconfig.json + .gitignore node_modules, dist, generated + tests/ your specs + shop.spec.ts + plugins/ runner plugins you wrote yourself + stand-reset.ts + dist/ compiled output, mirroring tests/ and plugins/ + generated/ what the environments write while they run + local/run/ the Paper server the local environment starts + node_modules/ +``` + +Three of those directories are disposable: `node_modules`, `dist` and `generated`. Delete any of them and the next `plugwrightTest` recreates it. `plugwrightInit` writes a `.gitignore` covering all three; if you already have one, it appends the lines it needs and leaves the rest alone. + +## tests + +`plugwrightCompileTests` compiles `tests/**/*.ts` into `dist/tests`, keeping subdirectories, and the runner scans the result for `.spec.js`. Group specs into folders however you like — `tests/economy/shop.spec.ts` is fine. + +A workspace of plain JavaScript needs no compile step. Without a `tsconfig.json` the runner reads `tests/` directly. + +## plugins + +Runner plugins — hooks, fixtures, matchers, inherited tests — go in `plugins/`, one file each, and compile into `dist/plugins`. A plugin is loaded by name: + +```kotlin +plugins { + local("stand-reset") // plugins/stand-reset.ts +} +``` + +`local(file(...))` still takes a path, for a plugin that lives somewhere else entirely. See [Runner Plugins](/plugins). + +## generated + +Each environment gets its own directory under `generated/`, named after it. The local environment puts its Paper server in `generated//run`: the jar, the worlds, the logs, the plugins it downloaded. Two local environments in one matrix therefore never share a server directory. + +You can still choose the directory yourself, and an explicit value always wins: + +```kotlin +environments { + create("local", LocalMode) { + runDir.set(file("/mnt/fast-disk/paper")) + } +} +``` + +The `stand` in the [example project](https://github.com/Drownek/plugwright/tree/master/example_plugin) shows why the default is convenient: an external environment can point at the very server the local one left behind, because there is only one place it could be. + +## Moving the whole thing + +`testsDir` is the root of all of this: + +```kotlin +plugwright { + testsDir.set(file("e2e")) +} +``` + +Then the specs are in `e2e/tests`, the server in `e2e/generated/local/run`, and so on. + +## Migrating from the old layout + +Before this layout, specs sat directly in `testsDir` and the local server went to a `run/` directory next to `build.gradle.kts`. The move is mostly automatic — the first `plugwrightCompileTests` after upgrading moves every spec it finds into `tests/`, subdirectories intact, and rewrites `tsconfig.json` so `include` points at the new place. It logs both. + +Four things are worth checking by hand afterwards: + +1. **Your `.gitignore`.** `run/` no longer needs an entry. `generated/` inside the workspace does — run `plugwrightInit` again to have the lines appended, or add them yourself. +2. **`runDir`.** A build script that sets it keeps that exact directory. Drop the line to get `generated//run` instead, and move the server there if you want to keep the downloaded jar and the worlds. +3. **Local plugins.** `local(file("src/test/e2e/dist/plugins/x.js"))` becomes `local("x")` once the source is in `plugins/`. +4. **A `tsconfig.json` with comments.** JSON with comments is legal in a `tsconfig` and unparseable as JSON, so plugwright leaves such a file untouched and says so. Point `include` at `tests/**/*.ts` and `plugins/**/*.ts` yourself. + +If you would rather do the move by hand, `git mv` the specs into `tests/` before upgrading. The migration only runs while there is no `tests/` directory at all. diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index c053b19..c7cd12a 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -43,14 +43,21 @@ description: "Start running your first test in less than 5 minutes." - Run the init command to set up your test folder. - This will automatically generate your package.json, TypeScript configuration, and an example test in a chosen directory. - - This command is interactive, so simply follow the prompts on your screen: + Run the init command to set up your test folder. It asks where to put it, then writes an npm project with a `package.json`, a TypeScript config, a `.gitignore`, an example spec and an example runner plugin: ```bash ./gradlew plugwrightInit ``` + + ``` + src/test/e2e/ + tests/example.spec.ts your specs go here + plugins/example-plugin.ts hooks, fixtures and matchers + package.json, tsconfig.json + .gitignore node_modules, dist, generated + ``` + + Everything a run generates — the compiled specs, the server the local environment starts — stays inside that directory, under `dist` and `generated`. See [Project Layout](/project-layout). diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index 9a8fcbb..8d59a41 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -17,7 +17,7 @@ test('test description', async ({ player }) => { ## Your First Test -Create `src/test/e2e/first.spec.ts`: +Create `src/test/e2e/tests/first.spec.ts` — specs live in `tests`, in whatever subdirectories you like ([Project Layout](/project-layout)): ```typescript import { test, expect } from '@drownek/plugwright'; diff --git a/example_plugin/README.md b/example_plugin/README.md index f5b2c0f..30f425d 100644 --- a/example_plugin/README.md +++ b/example_plugin/README.md @@ -10,21 +10,21 @@ The same 47 tests run against two environments, declared in `build.gradle.kts`. ./gradlew plugwrightTest ``` -Downloads Paper into `run/`, installs PlaceholderAPI and AuthMe next to the plugin under test, writes an AuthMe config a bot can get through, starts the server, runs everything, and shuts it down. Every test gets a fresh username, which AuthMe treats as a fresh registration, which `@plugwright/auth-authme` answers. +Downloads Paper into `src/test/e2e/generated/local/run`, installs PlaceholderAPI and AuthMe next to the plugin under test, writes an AuthMe config a bot can get through, starts the server, runs everything, and shuts it down. Every test gets a fresh username, which AuthMe treats as a fresh registration, which `@plugwright/auth-authme` answers. ## `stand` — someone else owns the server This one connects to a server that is already running and leaves it running. Provision it once, start it by hand, then point the tests at it. ```bash -# 1. Prepare run/ (Paper, plugins, server.properties with RCON enabled) +# 1. Prepare the run directory (Paper, plugins, server.properties with RCON enabled) ./gradlew plugwrightProvisionLocal -# 2. Start the server yourself, from the run directory -cd run && ./start.sh +# 2. Start the server yourself, from where the local environment put it +cd src/test/e2e/generated/local/run && ./start.sh ``` -`run/` is not in version control, so `start.sh` is yours to write. Anything that starts the jar with Java 21 will do: +`generated/` is not in version control, so `start.sh` is yours to write. Anything that starts the jar with Java 21 will do: ```sh #!/usr/bin/env sh @@ -48,13 +48,15 @@ export PLUGWRIGHT_RCON_PASSWORD=plugwright Expect skips. The stand leases four accounts from a pool instead of inventing a name per test, so anything that assumes a clean balance, an unclaimed kit or an empty arena is excluded, and anything that reads the whole server log is skipped — RCON answers commands, it doesn't stream the log. -`plugins/stand-reset.ts` handles what can be reset: it deops the leased account and clears its inventory before each test. It is loaded for the `stand` environment only, through `plugins { local(...) }`. +`plugins/stand-reset.ts` handles what can be reset: it deops the leased account and clears its inventory before each test. It is loaded for the `stand` environment only, through `plugins { local("stand-reset") }`. ## Layout ``` -src/main/java/…/ExamplePlugin.java the plugin under test -src/test/e2e/*.spec.ts the suite, run against both environments -src/test/e2e/plugins/stand-reset.ts a local runner plugin, stand only -build.gradle.kts both environment declarations +src/main/java/…/ExamplePlugin.java the plugin under test +src/test/e2e/tests/*.spec.ts the suite, run against both environments +src/test/e2e/plugins/stand-reset.ts a runner plugin, stand only +src/test/e2e/dist/ compiled specs and plugins +src/test/e2e/generated/local/run/ the Paper server the local environment owns +build.gradle.kts both environment declarations ``` From f7e4d5bc2410e8a12d2a34915598a5731fa3972a Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 23 Aug 2026 19:55:56 +0300 Subject: [PATCH 037/125] refactor(example): move message-buffer.spec.ts onto the new layout Landed at the workspace root in c4a89a4, after this branch moved the rest of the suite into tests/. The migration only runs while there is no tests/ directory, so nothing would have picked it up and the spec would have stopped running. --- example_plugin/src/test/e2e/{ => tests}/message-buffer.spec.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename example_plugin/src/test/e2e/{ => tests}/message-buffer.spec.ts (100%) diff --git a/example_plugin/src/test/e2e/message-buffer.spec.ts b/example_plugin/src/test/e2e/tests/message-buffer.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/message-buffer.spec.ts rename to example_plugin/src/test/e2e/tests/message-buffer.spec.ts From 579fefcc920910eab52e0ba3d7aa4f4bfeb0faa6 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 23 Aug 2026 19:56:45 +0300 Subject: [PATCH 038/125] chore(example): refresh the workspace lockfile The linked plugin packages moved to 3.0.0-dev.0 and their peer range to >=3.0.0-dev.0 in the previous PR; the example's lockfile still recorded 1.0.0 and >=2.0.0. Written by 'npm install', no dependency changed. --- example_plugin/src/test/e2e/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index a039403..d4ba49a 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -17,7 +17,7 @@ }, "../../../../auth-authme-package": { "name": "@plugwright/auth-authme", - "version": "1.0.0", + "version": "3.0.0-dev.0", "license": "MIT", "devDependencies": { "@drownek/plugwright": "file:../runner-package", @@ -29,12 +29,12 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@drownek/plugwright": ">=2.0.0" + "@drownek/plugwright": ">=3.0.0-dev.0" } }, "../../../../console-rcon-package": { "name": "@plugwright/console-rcon", - "version": "1.0.0", + "version": "3.0.0-dev.0", "license": "MIT", "devDependencies": { "@drownek/plugwright": "file:../runner-package", @@ -46,7 +46,7 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@drownek/plugwright": ">=2.0.0" + "@drownek/plugwright": ">=3.0.0-dev.0" } }, "../../../../runner-package": { From 6f9726c8767dda9f6ab9efe7ad18275bb0021028 Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 24 Aug 2026 14:09:44 +0300 Subject: [PATCH 039/125] fix(core): stop stripping tsconfig.json comments on migration rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gson's isLenient=true made retargetTsConfig successfully parse a tsconfig.json with comments, then rewrite it as plain JSON — deleting the comments the try/catch was meant to leave untouched. Drop isLenient so a commented config fails to parse and falls through to the existing catch, which warns and leaves the file alone. Spotted by Drownek in PR #49 review. --- .../me/drownek/plugwright/PlugwrightCompileTestsTask.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 index ef05a62..6518b87 100644 --- 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 @@ -4,7 +4,6 @@ import com.google.gson.GsonBuilder import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser -import com.google.gson.stream.JsonReader import me.drownek.plugwright.api.PlugwrightLayout import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty @@ -12,7 +11,6 @@ import org.gradle.api.tasks.Input import org.gradle.api.tasks.Internal import org.gradle.api.tasks.TaskAction import java.io.File -import java.io.StringReader /** * Installs the workspace's npm dependencies and compiles its TypeScript. @@ -163,8 +161,7 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { if (!tsconfigFile.exists()) return val config = try { - JsonParser.parseReader(JsonReader(StringReader(tsconfigFile.readText())).apply { isLenient = true }) - .asJsonObject + JsonParser.parseString(tsconfigFile.readText()).asJsonObject } catch (e: Exception) { logger.warn( "Could not update ${tsconfigFile.absolutePath} (${e.message}). Point its \"include\" at " + From 4028a876f99b4188d3adacf6036bf4c0d508384e Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 16:05:10 +0300 Subject: [PATCH 040/125] feat(gradle): describe npm registries in the build script An npm { } block on the extension names the registries the workspace installs from, per scope where it needs to be, plus any other npmrc option. Credentials are SecretRefs only - a literal token in a build script ends up in the configuration cache and in version control. NpmrcWriter turns the block into the workspace .npmrc. It resolves the secrets at execution time and only ever replaces a file it wrote itself, which the marker on the first line identifies. Nothing calls it yet. --- .../me/drownek/plugwright/api/NpmSpec.kt | 181 ++++++++++++++++++ .../me/drownek/plugwright/NpmrcWriter.kt | 171 +++++++++++++++++ .../drownek/plugwright/PlugwrightExtension.kt | 16 ++ 3 files changed, 368 insertions(+) create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt new file mode 100644 index 0000000..6ed6134 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt @@ -0,0 +1,181 @@ +package me.drownek.plugwright.api + +import java.io.Serializable + +/** + * Credentials for one registry, as pointers to secrets — never the values. + * + * The values are read when the `.npmrc` is written, during task execution. Reading them + * while the build script is being configured would put them into the configuration cache. + */ +data class NpmCredentials( + val authToken: SecretRef? = null, + val username: SecretRef? = null, + val password: SecretRef? = null +) : Serializable { + + val isEmpty: Boolean get() = authToken == null && username == null && password == null + + companion object { + private const val serialVersionUID: Long = 1L + + val NONE = NpmCredentials() + } +} + +/** + * A registry npm should fetch from: the default one, or the one a single scope resolves to. + * + * @param scope npm scope including the leading `@`, e.g. `@drownek`; null for the default registry + * @param url registry URL, e.g. `https://nexus.corp/repository/npm-private/` + */ +data class NpmRegistry( + val scope: String?, + val url: String, + val credentials: NpmCredentials = NpmCredentials.NONE +) : Serializable { + companion object { + private const val serialVersionUID: Long = 1L + } +} + +/** + * What the workspace's generated `.npmrc` should say: which registries to fetch from, how to + * authenticate against them, and any other npm option the build script sets. + * + * Built from the `npm { }` block ([NpmSpec]) and carried into the tasks that run `npm install`. + */ +data class NpmConfig( + val registries: List = emptyList(), + val options: Map = emptyMap() +) : Serializable { + + /** No `npm { }` block, or an empty one: nothing to generate, and no `.npmrc` to keep. */ + val isEmpty: Boolean get() = registries.isEmpty() && options.isEmpty() + + /** + * Configuration mistakes worth failing the build over, reported before anything runs + * `npm install` — npm answers a malformed registry line with a 404 against the public + * registry, which is a much longer way round to the same conclusion. + */ + fun problems(): List { + val problems = mutableListOf() + + registries.forEach { registry -> + val label = registry.scope?.let { "scope '$it'" } ?: "the default registry" + + if (registry.scope != null && !registry.scope.startsWith("@")) { + problems += "npm scope '${registry.scope}' must start with '@'" + } + if (!registry.url.startsWith("http://") && !registry.url.startsWith("https://")) { + problems += "registry URL for $label must start with http:// or https://, got '${registry.url}'" + } + + val credentials = registry.credentials + if (credentials.username != null && credentials.password == null) { + problems += "$label has a username but no password" + } + if (credentials.password != null && credentials.username == null) { + problems += "$label has a password but no username" + } + } + + val duplicateScopes = registries.groupBy { it.scope }.filterValues { it.size > 1 }.keys + duplicateScopes.forEach { scope -> + problems += scope?.let { "npm scope '$it' is declared more than once" } + ?: "the default npm registry is declared more than once" + } + + options.keys.filter { it.isBlank() }.forEach { _ -> + problems += "npm option keys cannot be blank" + } + + return problems + } + + companion object { + private const val serialVersionUID: Long = 1L + + val EMPTY = NpmConfig() + } +} + +/** + * Credentials for one registry, as a build-script block. + * + * Only [SecretRef]s: a literal token in a build script ends up in the configuration cache, + * in build scans, and — for anyone who forgets what a build script is — in version control. + * Use `secret.env("NPM_TOKEN")`, which is also what a CI job already has. + */ +class NpmCredentialsSpec { + private var authToken: SecretRef? = null + private var username: SecretRef? = null + private var password: SecretRef? = null + + /** Bearer token for this registry, written as `_authToken`. */ + fun authToken(ref: SecretRef) { + authToken = ref + } + + /** Basic-auth user, written as `username`; needs a [password]. */ + fun username(ref: SecretRef) { + username = ref + } + + /** Basic-auth password, written base64-encoded as `_password`; needs a [username]. */ + fun password(ref: SecretRef) { + password = ref + } + + internal fun build(): NpmCredentials = NpmCredentials(authToken, username, password) +} + +/** + * The `npm { }` block: which registries this workspace installs from. + * + * ```kotlin + * plugwright { + * npm { + * registry("https://nexus.corp/repository/npm-group/") { + * authToken(secret.env("NPM_TOKEN")) + * } + * scope("@drownek", "https://nexus.corp/repository/npm-private/") { + * username(secret.env("NPM_USER")) + * password(secret.env("NPM_PASS")) + * } + * option("strict-ssl", "false") + * } + * } + * ``` + * + * The block becomes a `.npmrc` in the workspace root, written just before each `npm install` + * the build runs. It covers the whole workspace rather than one environment: there is one + * `node_modules` and one install for the entire matrix. + */ +class NpmSpec { + private val registries = mutableListOf() + private val options = linkedMapOf() + + /** The registry every package comes from unless a scope says otherwise. */ + @JvmOverloads + fun registry(url: String, action: NpmCredentialsSpec.() -> Unit = {}) { + registries += NpmRegistry(null, url, NpmCredentialsSpec().apply(action).build()) + } + + /** The registry packages under [scope] (`@drownek`, leading `@` included) come from. */ + @JvmOverloads + fun scope(scope: String, url: String, action: NpmCredentialsSpec.() -> Unit = {}) { + registries += NpmRegistry(scope, url, NpmCredentialsSpec().apply(action).build()) + } + + /** + * Any other npm setting, written verbatim: `option("strict-ssl", "false")`, + * `option("cafile", "/etc/ssl/corp-ca.pem")`. + */ + fun option(key: String, value: String) { + options[key] = value + } + + /** Snapshot of the block, for the tasks that write the `.npmrc`. */ + fun toConfig(): NpmConfig = NpmConfig(registries.toList(), options.toMap()) +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt new file mode 100644 index 0000000..1d0725e --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt @@ -0,0 +1,171 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.NpmConfig +import me.drownek.plugwright.api.NpmRegistry +import me.drownek.plugwright.api.SecretRef +import org.gradle.api.GradleException +import org.gradle.api.logging.Logger +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.util.Base64 + +/** + * Turns the `npm { }` block into the workspace's `.npmrc`, right before something runs + * `npm install` in it. + * + * The file is written at execution time and not a moment earlier: it holds resolved secrets, + * and the configuration cache is a file on disk like any other. + * + * Only a file plugwright wrote itself is ever replaced — the [MARKER] on the first line says + * so. A workspace with a hand-written `.npmrc` keeps it, and the build says which one won. + */ +internal object NpmrcWriter { + + const val FILE_NAME = ".npmrc" + + const val MARKER = "# Generated by plugwright - do not edit" + + private const val EXPLANATION = "# Edit the npm { } block in your build script instead." + + /** + * Brings `/.npmrc` in line with [config]. + * + * An empty config removes a file plugwright generated earlier — a registry that has been + * deleted from the build script should stop applying — and leaves everything else alone. + */ + fun write(workspace: File, config: NpmConfig, logger: Logger) { + val file = File(workspace, FILE_NAME) + + if (file.exists() && !isGenerated(file)) { + if (!config.isEmpty) { + logger.warn( + "${file.absolutePath} was not written by plugwright, so the npm { } block in the " + + "build script is being ignored. Delete the file to let plugwright manage it." + ) + } + return + } + + if (config.isEmpty) { + if (file.exists() && file.delete()) { + logger.lifecycle("Removed ${file.absolutePath}: no npm { } block declares a registry anymore") + } + return + } + + file.parentFile?.mkdirs() + file.writeText(render(config)) + restrictPermissions(file) + logger.lifecycle("Wrote ${file.absolutePath} (${summarize(config)})") + } + + /** Whether the file is one of ours: the marker is the first thing in it. */ + private fun isGenerated(file: File): Boolean = + file.useLines { lines -> lines.firstOrNull()?.trim() == MARKER } + + // ---- Rendering --------------------------------------------------------------------- + + private fun render(config: NpmConfig): String = buildString { + appendLine(MARKER) + appendLine(EXPLANATION) + + config.registries.forEach { registry -> + val key = registry.scope?.let { "$it:registry" } ?: "registry" + appendLine("$key=${registry.url}") + } + + config.registries.forEach { registry -> + credentialLines(registry).forEach { appendLine(it) } + } + + config.options.forEach { (key, value) -> appendLine("$key=$value") } + } + + private fun credentialLines(registry: NpmRegistry): List { + val credentials = registry.credentials + if (credentials.isEmpty) return emptyList() + + val prefix = authKeyPrefix(registry.url) + val label = registry.scope?.let { "npm scope '$it'" } ?: "the default npm registry" + val lines = mutableListOf() + + credentials.authToken?.let { lines += "$prefix:_authToken=${resolve(it, label)}" } + credentials.username?.let { lines += "$prefix:username=${resolve(it, label)}" } + credentials.password?.let { + val encoded = Base64.getEncoder().encodeToString(resolve(it, label).toByteArray(Charsets.UTF_8)) + lines += "$prefix:_password=$encoded" + } + return lines + } + + /** + * The `//host/path/` npm keys credentials hang off, from a registry URL. + * + * npm matches these against the registry it is about to talk to, so the path matters: + * a Nexus with `/repository/npm-private/` authenticates separately from its sibling + * repositories on the same host. + */ + private fun authKeyPrefix(url: String): String = + "//" + url.substringAfter("://").trimEnd('/') + "/" + + // ---- Secrets ----------------------------------------------------------------------- + + /** + * The value behind a [SecretRef], read now rather than at configuration time. + * + * A secret that resolves to nothing fails the build here, with the name of what was + * empty — the alternative is npm answering 401 several minutes into a CI job. + */ + private fun resolve(ref: SecretRef, label: String): String { + val (source, value) = when (ref) { + is SecretRef.FromEnv -> "environment variable '${ref.name}'" to System.getenv(ref.name) + is SecretRef.FromSystemProperty -> "system property '${ref.name}'" to System.getProperty(ref.name) + is SecretRef.FromFile -> { + val file = File(ref.path) + "file '${ref.path}'" to if (file.isFile) file.useLines { it.firstOrNull() } else null + } + } + + if (value.isNullOrBlank()) { + throw GradleException( + "The credentials for $label read from $source, which is empty or unset. " + + "Set it, or drop the credentials from the npm { } block." + ) + } + return value.trim() + } + + // ---- Housekeeping ------------------------------------------------------------------ + + /** Owner-only, on the systems that have a say in it: the file holds tokens. */ + private fun restrictPermissions(file: File) { + try { + Files.setPosixFilePermissions(file.toPath(), PosixFilePermissions.fromString("rw-------")) + } catch (_: UnsupportedOperationException) { + // Windows: the closest equivalent the java.io API offers. + file.setReadable(false, false) + file.setReadable(true, true) + file.setWritable(false, false) + file.setWritable(true, true) + } catch (_: IOException) { + // Best effort; a file we could write but not chmod is still a working .npmrc. + } + } + + /** What went into the file, with the secrets left out of the build log. */ + private fun summarize(config: NpmConfig): String { + val parts = mutableListOf() + + config.registries.forEach { registry -> + val name = registry.scope ?: "default" + val authenticated = if (registry.credentials.isEmpty) "" else ", credentials ***" + parts += "$name -> ${registry.url}$authenticated" + } + if (config.options.isNotEmpty()) { + parts += "options: ${config.options.keys.joinToString(", ")}" + } + return parts.joinToString("; ") + } +} 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 7cf9d0d..b6adc04 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,6 +1,7 @@ package me.drownek.plugwright import me.drownek.plugwright.api.LegacyEnvironmentProperties +import me.drownek.plugwright.api.NpmSpec import me.drownek.plugwright.api.PlugwrightMode import me.drownek.plugwright.api.RunDirFile import org.gradle.api.Project @@ -58,6 +59,21 @@ abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperti matrix.action() } + /** Registries the workspace installs from, and the credentials for them. See [npm]. */ + val npm: NpmSpec = NpmSpec() + + /** + * Configures npm itself: `npm { registry("https://nexus.corp/repository/npm-group/") }`. + * + * The block covers the whole workspace rather than one environment — there is one + * `node_modules` and one install behind the entire matrix. It is written to a `.npmrc` + * next to `package.json` before each install; without a block, no file is written and + * npm keeps using whatever the machine already configures. + */ + fun npm(action: NpmSpec.() -> Unit) { + npm.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. From 7544da74bf9ee26c29b4fa411ff3fa49ed8468b3 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 16:05:18 +0300 Subject: [PATCH 041/125] feat(gradle): write the .npmrc before installing plugwrightCompileTests writes the file just before npm install, so both the dependency install and the runner packages that follow it - same working directory, same npm - fetch from the configured registries. A malformed block (a scope without its @, a registry that is not http) is reported with the rest of the configuration problems rather than by npm 404ing against the public registry several minutes later. --- .../plugwright/PlugwrightCompileTestsTask.kt | 15 +++++++++++++++ .../me/drownek/plugwright/PlugwrightCorePlugin.kt | 2 ++ 2 files changed, 17 insertions(+) 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 index 6518b87..0cd3a75 100644 --- 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 @@ -4,9 +4,11 @@ import com.google.gson.GsonBuilder import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser +import me.drownek.plugwright.api.NpmConfig import me.drownek.plugwright.api.PlugwrightLayout import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.Internal import org.gradle.api.tasks.TaskAction @@ -43,6 +45,15 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { @get:Input abstract val runnerPackages: ListProperty + /** + * The registries npm should install from, from the build script's `npm { }` block. + * + * Holds [me.drownek.plugwright.api.SecretRef]s rather than credentials: the values are + * read when the `.npmrc` is written, in [compile]. + */ + @get:Input + abstract val npmConfig: Property + init { group = "verification" description = "Install npm dependencies and compile the E2E tests" @@ -71,6 +82,10 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { val nodePaths = resolveNode() val npmEnv = nodePathEnv(nodePaths) + // Before any install: both the dependency install below and the runner packages after + // it run with this workspace as their working directory, so one file covers both. + NpmrcWriter.write(workspace, npmConfig.getOrElse(NpmConfig.EMPTY), logger) + // Install dependencies if needed if (!File(workspace, "node_modules").exists()) { logger.lifecycle("Installing Node.js dependencies...") 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 60c47cd..0d2f3da 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 @@ -57,6 +57,7 @@ class PlugwrightCorePlugin : Plugin { testsDir.set(extension.testsDir) // Filled in once every environment has been wired; empty until then. runnerPackages.convention(emptyList()) + npmConfig.set(project.provider { extension.npm.toConfig() }) nodeVersion.set(extension.nodeVersion) downloadNode.set(extension.downloadNode) nodeInstallDir.set(defaultNodeInstallDir) @@ -137,6 +138,7 @@ class PlugwrightCorePlugin : Plugin { val layout = PlugwrightLayout.of(extension.testsDir.get().asFile) val projectPluginJarProvider = resolveProjectPluginJar(project, extension) val validationProblems = mutableListOf() + validationProblems += extension.npm.toConfig().problems().map { "[npm] $it" } val reportsDir = project.layout.buildDirectory.dir("reports/plugwright") // -Pplugwright.env=a,b narrows the matrix; ignored by direct plugwrightTest calls. From 0062204d1ad8741ed39d61bf7caad19c2e32e3e8 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 16:06:08 +0300 Subject: [PATCH 042/125] feat(init): scaffold a workspace that knows about .npmrc plugwrightInit writes the file before its own npm install: a scaffold that can only reach the public registry is no use to a project that lives behind a private one. The .gitignore template lists .npmrc, and a workspace created before this got one keeps its own file and gains the entry the first time the .npmrc is generated. It may hold a registry token. --- example_plugin/src/test/e2e/.gitignore | 2 ++ .../me/drownek/plugwright/NpmrcWriter.kt | 25 +++++++++++++++++++ .../plugwright/PlugwrightCorePlugin.kt | 5 ++++ .../main/resources/plugwright-init/gitignore | 2 ++ 4 files changed, 34 insertions(+) diff --git a/example_plugin/src/test/e2e/.gitignore b/example_plugin/src/test/e2e/.gitignore index 8cedc9f..c2ac073 100644 --- a/example_plugin/src/test/e2e/.gitignore +++ b/example_plugin/src/test/e2e/.gitignore @@ -4,3 +4,5 @@ node_modules/ dist/ # Whatever the environments write while they run: servers, worlds, logs generated/ +# Generated from the npm { } block; may hold registry credentials +.npmrc diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt index 1d0725e..71bf6fb 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt @@ -29,6 +29,8 @@ internal object NpmrcWriter { private const val EXPLANATION = "# Edit the npm { } block in your build script instead." + private const val IGNORE_COMMENT = "# Generated from the npm { } block; may hold registry credentials" + /** * Brings `/.npmrc` in line with [config]. * @@ -58,9 +60,32 @@ internal object NpmrcWriter { file.parentFile?.mkdirs() file.writeText(render(config)) restrictPermissions(file) + ensureIgnored(workspace, logger) logger.lifecycle("Wrote ${file.absolutePath} (${summarize(config)})") } + /** + * Keeps the file out of version control. + * + * `plugwrightInit` scaffolds a `.gitignore` that already covers it, but a workspace made + * before this existed has one without the entry — and the file it is missing may hold a + * registry token. + */ + private fun ensureIgnored(workspace: File, logger: Logger) { + val gitignore = File(workspace, ".gitignore") + val entry = FILE_NAME + + if (gitignore.exists()) { + val present = gitignore.readLines().any { it.trim().trimStart('/') == entry } + if (present) return + val separator = if (gitignore.readText().endsWith("\n")) "" else "\n" + gitignore.appendText("$separator$IGNORE_COMMENT\n$entry\n") + } else { + gitignore.writeText("$IGNORE_COMMENT\n$entry\n") + } + logger.lifecycle("Added $entry to ${gitignore.absolutePath}") + } + /** Whether the file is one of ours: the marker is the first thing in it. */ private fun isGenerated(file: File): Boolean = file.useLines { lines -> lines.firstOrNull()?.trim() == MARKER } 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 0d2f3da..1458998 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 @@ -345,6 +345,11 @@ class PlugwrightCorePlugin : Plugin { writeIfAbsent(project, layout.testsDir.resolve("example.spec.ts"), initTemplate("example.spec.ts")) writeIfAbsent(project, layout.pluginsDir.resolve("example-plugin.ts"), initTemplate("example-plugin.ts")) + // The install below is the first one this workspace runs, so it needs the + // registries too — a scaffold that can only reach the public registry is no + // use to a project that lives behind a private one. + NpmrcWriter.write(targetDir, extension.npm.toConfig(), project.logger) + project.logger.lifecycle("Executing 'npm install' in ${targetDir.absolutePath}...") val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get()) diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore index 8cedc9f..921a22a 100644 --- a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore @@ -1,5 +1,7 @@ # Installed by plugwrightCompileTests node_modules/ +# Generated from the npm { } block; may hold registry credentials +.npmrc # Compiled specs and plugins dist/ # Whatever the environments write while they run: servers, worlds, logs From ef98b104c9fc40772e1a44d677a77dc029ce76b5 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 16:09:57 +0300 Subject: [PATCH 043/125] docs: document custom npm registries Configuration gets the reference for the npm { } block: the registry and scope calls, credentials as SecretRefs, what the generated .npmrc looks like and when plugwright refuses to touch one. CI/CD gets the token-through-the-environment version, since that is where private registries actually bite. Project layout and quickstart just mention the file, which is gitignored and regenerated per install. --- docs/ci-cd.mdx | 27 ++++++++++++++++++++ docs/configuration.mdx | 56 +++++++++++++++++++++++++++++++++++++++++ docs/project-layout.mdx | 5 +++- docs/quickstart.mdx | 16 +++++++++++- 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/docs/ci-cd.mdx b/docs/ci-cd.mdx index b9f3101..c126eef 100644 --- a/docs/ci-cd.mdx +++ b/docs/ci-cd.mdx @@ -26,3 +26,30 @@ jobs: # Path to your plugin gradle project if it's not at the project's root working-directory: "." ``` + +## Private npm registries + +If the test workspace installs from a private registry, declare it once in `build.gradle.kts` and pass the token through the environment. Nothing about the registry has to be configured on the runner, and the workflow file holds a secret name rather than a secret: + +```kotlin +plugwright { + npm { + registry("https://nexus.corp/repository/npm-group/") { + authToken(secret.env("NPM_TOKEN")) + } + } +} +``` + +```yaml + - uses: drownek/plugwright-action@v1 + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + with: + java-version: "17" + node-version: "24" +``` + +Plugwright generates the `.npmrc` from that block before each `npm install`. If `NPM_TOKEN` is missing from the job, the build stops and names it, instead of failing later with a 404 that looks like a typo in a package name. See [Configuration](/configuration#npm-registries). + +The generated file is gitignored, but it does exist on disk for the length of the job. On a self-hosted runner with a shared workspace, clean it up the way you would any other credential the job writes. diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 448ce30..83b5014 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -194,6 +194,62 @@ downloadNode.set(true) // no local Node.js required - download it automatically nodeVersion.set("22.14.0") ``` +## npm registries + +The workspace is an npm project, and by default it installs from whatever registry the machine is already pointed at. If your packages come from a private registry (a Nexus or an Artifactory, usually), declare it in the build script instead of leaving a `.npmrc` for everyone to set up by hand: + +```kotlin +import me.drownek.plugwright.api.secret + +plugwright { + npm { + registry("https://nexus.corp/repository/npm-group/") { + authToken(secret.env("NPM_TOKEN")) + } + + // Only @drownek packages come from here; everything else uses the registry above. + scope("@drownek", "https://nexus.corp/repository/npm-private/") { + username(secret.env("NPM_USER")) + password(secret.env("NPM_PASS")) + } + + option("strict-ssl", "false") + } +} +``` + +Plugwright writes this to a `.npmrc` next to `package.json` immediately before it runs `npm install`, which covers both the workspace's own dependencies and the runner packages your environments pull in. Without an `npm { }` block no file is written and nothing changes. + + + Registries the workspace installs from. `registry(url)` sets the default one, `scope("@org", url)` routes a single scope, and `option(key, value)` writes any other npmrc setting verbatim. All three are optional and can appear in any order. + + +### Credentials + +Credentials are [`SecretRef`](/environments#secrets) values — `secret.env("NPM_TOKEN")`, `secret.file("/run/secrets/npm")`, `secret.systemProperty("npm.token")`. There is deliberately no way to write a literal token: a build script is a file in your repository, and a literal would also end up in the configuration cache. + +`authToken(...)` becomes an `_authToken` line. `username(...)` plus `password(...)` become `username` and a base64-encoded `_password`, which is what npm 7 and later expect. A username without a password (or the other way round) is a configuration error and fails the build. + +So is a secret that resolves to nothing. An unset `NPM_TOKEN` stops the build before `npm install` runs, with the name of the variable that was empty — rather than several minutes later, with a 404 from the public registry. + +### The generated file + +The `.npmrc` carries a marker on its first line: + +``` +# Generated by plugwright - do not edit +# Edit the npm { } block in your build script instead. +registry=https://nexus.corp/repository/npm-group/ +@drownek:registry=https://nexus.corp/repository/npm-private/ +//nexus.corp/repository/npm-private/:username=ci +//nexus.corp/repository/npm-private/:_password=Y2ktcGFzcw== +strict-ssl=false +``` + +Only a file carrying that marker is ever overwritten. If the workspace already has an `.npmrc` you wrote yourself, plugwright leaves it alone and warns that the `npm { }` block is being ignored — delete the file to hand the job over. Remove the block from the build script and the generated file is deleted with it, so a registry you stopped declaring stops applying. + +The file holds resolved credentials, so it is gitignored: `plugwrightInit` scaffolds a `.gitignore` that lists it, and a workspace created before this existed gets the entry the first time the file is generated. It is written with owner-only permissions where the filesystem supports them. + ## Multi-environment options These live on `plugwright { }` itself, next to `testsDir`. diff --git a/docs/project-layout.mdx b/docs/project-layout.mdx index cd4cf8e..f1a08ba 100644 --- a/docs/project-layout.mdx +++ b/docs/project-layout.mdx @@ -9,7 +9,8 @@ Everything plugwright needs sits under one directory — `src/test/e2e` unless y src/test/e2e/ package.json the npm project the runner is installed into tsconfig.json - .gitignore node_modules, dist, generated + .npmrc generated from npm { }, when the build script has one + .gitignore node_modules, dist, generated, .npmrc tests/ your specs shop.spec.ts plugins/ runner plugins you wrote yourself @@ -22,6 +23,8 @@ src/test/e2e/ Three of those directories are disposable: `node_modules`, `dist` and `generated`. Delete any of them and the next `plugwrightTest` recreates it. `plugwrightInit` writes a `.gitignore` covering all three; if you already have one, it appends the lines it needs and leaves the rest alone. +So is the `.npmrc`, when there is one — it is generated from the `npm { }` block before every install and may hold a registry token, which is why it is gitignored too. See [Configuration](/configuration#npm-registries). + ## tests `plugwrightCompileTests` compiles `tests/**/*.ts` into `dist/tests`, keeping subdirectories, and the runner scans the result for `.spec.js`. Group specs into folders however you like — `tests/economy/shop.spec.ts` is fine. diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index c7cd12a..94814f6 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -40,6 +40,20 @@ description: "Start running your first test in less than 5 minutes." If you already have Node.js installed on your system, you can comment out `downloadNode.set(true)` to speed up initialization. Otherwise, leave it uncommented. + + If npm at your company goes through a private registry, add an `npm { }` block now — the next step installs packages, and it will need it: + + ```kotlin + plugwright { + npm { + registry("https://nexus.corp/repository/npm-group/") { + authToken(secret.env("NPM_TOKEN")) + } + } + } + ``` + + Plugwright writes that to a gitignored `.npmrc` in the workspace before each install. See [Configuration](/configuration#npm-registries). @@ -54,7 +68,7 @@ description: "Start running your first test in less than 5 minutes." tests/example.spec.ts your specs go here plugins/example-plugin.ts hooks, fixtures and matchers package.json, tsconfig.json - .gitignore node_modules, dist, generated + .gitignore node_modules, dist, generated, .npmrc ``` Everything a run generates — the compiled specs, the server the local environment starts — stays inside that directory, under `dist` and `generated`. See [Project Layout](/project-layout). From 9cd7472729eef4006df71d4101cf4a17241e141b Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 16:26:14 +0300 Subject: [PATCH 044/125] feat(gitignore): update patterns to ignore plugin binaries and add Code Graph index --- .gitignore | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 8f8de63..815d8e8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,7 @@ gradle-app.setting !gradle-wrapper.jar *.class gradle.properties -/gradle-plugin/bin/ -/example_plugin/bin/ +**/bin/ # IDE .idea/ @@ -32,6 +31,9 @@ gradle.properties Thumbs.db desktop.ini +# Code Graph index +.codegraph/ + # Logs *.log logs/ From 0bd09220bfd5f2757ab03cd03a8b4814aa2454d9 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 23:38:50 +0300 Subject: [PATCH 045/125] fix(npm): stop cmd eating the caret in a runner package range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows runs npm through `cmd /c`, where `^` is the escape character, so `@scope/pkg@^1.2.0` reached npm as `@scope/pkg@1.2.0` — an exact version nobody published, reported as ETARGET. Java quotes an argument only for a space or a redirection, so the quoting has to happen here. Runner packages are also deduplicated by package name now. A mode names the package it needs a version of, and the build script names it again, bare, in plugins { npm(...) }; both specs in one install is npm resolving the same package twice, and the bare one asks for a "latest" that a package released under another tag does not have. --- .../me/drownek/plugwright/AbstractNodeTask.kt | 26 +++++++++++++- .../plugwright/PlugwrightCorePlugin.kt | 34 ++++++++++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) 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 index aefa167..9170071 100644 --- 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 @@ -15,6 +15,11 @@ import java.io.File */ abstract class AbstractNodeTask : DefaultTask() { + private companion object { + /** What `cmd /c` acts on rather than hands to the program it runs. */ + const val CMD_SPECIAL_CHARACTERS = "^&|<>()!%\" \t" + } + @get:Input abstract val nodeVersion: Property @@ -44,7 +49,7 @@ abstract class AbstractNodeTask : DefaultTask() { 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 + listOf("cmd", "/c") + command.map { quoteForCmd(it) } } else { command.toList() } @@ -68,6 +73,25 @@ abstract class AbstractNodeTask : DefaultTask() { } } + /** + * Makes an argument survive the `cmd /c` in front of it. + * + * `cmd` re-parses the line it is handed and `^` is its escape character, so an npm range + * like `@scope/pkg@^1.2.0` reaches npm as `@scope/pkg@1.2.0` — an exact version nobody + * published, reported as "No matching version found". Java quotes an argument only when + * it holds a space or a redirection, and `^` is neither, so the quoting that makes it + * literal has to happen here. + * + * An argument already carrying a quote of its own is left alone: it is either quoted + * already or means something by it, and Java rejects a quoted argument with a quote + * inside outright. + */ + private fun quoteForCmd(argument: String): String = when { + argument.none { it in CMD_SPECIAL_CHARACTERS } -> argument + argument.contains('"') -> argument + else -> "\"$argument\"" + } + protected fun runProcess( process: Process, command: Array, 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 1458998..2948ef7 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 @@ -147,7 +147,26 @@ class PlugwrightCorePlugin : Plugin { val matrixEntries = mutableListOf() val matrixPrepareTasks = mutableListOf>() - val runnerPackageSpecs = linkedSetOf() + // Keyed by package name rather than by the whole spec: a mode names a package with the + // version it needs, and the same build script names it again — bare — in that + // environment's plugins { npm(...) }. Both specs in one `npm install` is a package + // asked for twice at two different versions, and the bare one resolves "latest", + // which a package released only under another tag does not have. + val runnerPackageSpecs = linkedMapOf() + fun addRunnerPackage(spec: String) { + val name = npmPackageNameOf(spec) + val existing = runnerPackageSpecs[name] + when { + // A version beats no version; between two versions the mode's comes first and + // wins, because it is the half that knows what its own export needs. + existing == null || existing == name -> runnerPackageSpecs[name] = spec + spec == name || spec == existing -> Unit + else -> project.logger.warn( + "plugwright: $name is asked for as both '$existing' and '$spec'. Installing " + + "'$existing'; drop the version from one of them to say which you meant." + ) + } + } extension.environments.all.forEach { entry -> val envName = entry.spec.name @@ -198,7 +217,7 @@ class PlugwrightCorePlugin : Plugin { // Merged across environments so the whole matrix is covered by one install. modePackages.forEach { ref -> - runnerPackageSpecs += if (ref.version != null) "${ref.name}@${ref.version}" else ref.name + addRunnerPackage(if (ref.version != null) "${ref.name}@${ref.version}" else ref.name) } val validation = ValidationContextImpl(envName, project.logger) @@ -217,7 +236,7 @@ class PlugwrightCorePlugin : Plugin { pluginConfigsProvider.get() .map { it.specifier } .filter { isNpmPackageName(it) } - .forEach { runnerPackageSpecs += it } + .forEach { addRunnerPackage(it) } testTask.configure { ctx.prepareTaskRef?.let { dependsOn(it) } @@ -247,7 +266,7 @@ class PlugwrightCorePlugin : Plugin { } } - plugwrightCompileTests.configure { runnerPackages.set(runnerPackageSpecs.toList()) } + plugwrightCompileTests.configure { runnerPackages.set(runnerPackageSpecs.values.toList()) } if (validationProblems.isNotEmpty()) { throw GradleException("plugwright configuration problems:\n" + validationProblems.joinToString("\n") { " $it" }) @@ -278,6 +297,13 @@ class PlugwrightCorePlugin : Plugin { return ref.copy(specifier = File(layout.compiledPluginsDir, "$name.js").absolutePath) } + /** `@scope/name@^1.0.0` → `@scope/name`; the version separator is the last `@`, which for + * a scoped package is never the leading one. */ + private fun npmPackageNameOf(spec: String): String { + val separator = spec.lastIndexOf('@') + return if (separator > 0) spec.substring(0, separator) else spec + } + /** Whether a plugin specifier names an npm package rather than a file in the project. * Paths are what `plugins { local(file(...)) }` produces; everything else is installable. */ private fun isNpmPackageName(specifier: String): Boolean { From cdd721f85bf4adcdd350c8f23d2e929298d77623 Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 24 Aug 2026 15:13:29 +0300 Subject: [PATCH 046/125] fix(npm): don't cut git/URL package specs at the last @ npmPackageNameOf assumed the last @ was always the version separator. git+ssh://git@host/repo and https://user:pass@host/pkg carry @ of their own, so two different URL specs got truncated to the same wrong key and collided in the map. Reported by Drownek on PR #50. --- .../kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 2948ef7..aecce6f 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 @@ -298,8 +298,11 @@ class PlugwrightCorePlugin : Plugin { } /** `@scope/name@^1.0.0` → `@scope/name`; the version separator is the last `@`, which for - * a scoped package is never the leading one. */ + * a scoped package is never the leading one. A git/URL spec (`git+ssh://git@host/repo`, + * `https://user:pass@registry/pkg`) carries its own `@`s that aren't a version separator + * at all, so it is returned as-is instead of being cut at the last one. */ private fun npmPackageNameOf(spec: String): String { + if (spec.contains("://") || spec.startsWith("git+")) return spec val separator = spec.lastIndexOf('@') return if (separator > 0) spec.substring(0, separator) else spec } From 1ff9119a2e9638d6e10fae901e2a3de5910cfd27 Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 24 Aug 2026 15:13:36 +0300 Subject: [PATCH 047/125] fix(npm): insert call after cmd /c to stop it eating outer quotes cmd.exe strips the outer pair of quotes from the whole command line when the line starts with a quote and holds more than two quotes total. quoteForCmd now adds quotes to protect ^ in version ranges, so a spaced npm.cmd path (already quoted by Java) plus one quoted argument hits that case and cmd mangles the path. call makes the line start with a letter instead of a quote, which cmd doesn't touch. Reported by Drownek on PR #50. --- .../main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 9170071..c9f09e8 100644 --- 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 @@ -49,7 +49,11 @@ abstract class AbstractNodeTask : DefaultTask() { 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.map { quoteForCmd(it) } + // "call" after /c so the line handed to cmd starts with a letter, not a quote: + // when it starts with a quote and holds more than two quotes total (guaranteed + // once quoteForCmd wraps an argument), cmd strips the outer pair itself, mangling + // a spaced npm.cmd path Java already quoted. + listOf("cmd", "/c", "call") + command.map { quoteForCmd(it) } } else { command.toList() } From f457e44004cac1b0fdbc096e53b321b2e034e422 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 13:21:48 +0300 Subject: [PATCH 048/125] feat(runner): reuse a connected player across test boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in mechanism to hand a test a bot that survived from an earlier test instead of reconnecting for every one. Matching goes by ability labels (op, gamemode:*, and anything a plugin marks by hand) rather than resetting server state, since the runner has no way to undo what a command changed. - lib/player.ts: abilities set, mark()/unmark(); makeOp/deOp/ setGameMode label automatically. makeOp also now recognizes an already-op player on a stdio/full console, which reuse can hand it a second time. - lib/player-registry.ts: PlayerRegistry — resolves a request against free entries, evicts LRU at maxPlayers, transparently rejoins a dead connection before handing it out, invalidates on failure. - lib/session.ts: owns the registry; disconnectAllBots takes a keep list so a per-test sweep leaves other live registry entries alone. - lib/test-registry.ts, lib/types.ts, lib/plugin.ts: reuse option on test()/describe(), TestContext.invalidatePlayer, TestResult.reuse, PlugwrightPlugin.onPlayerReuse, PluginTestRef.reuse. - lib/test-runner.ts, runner.ts: resolves the primary player and any createPlayer() call through the registry when reuse applies; computes the default maxPlayers from account pool capacity; reports a per-test reuse summary line. - lib/config.ts: tests.reuse config, PLUGWRIGHT_REUSE env override. - gradle-plugin: reuse { enabled, maxPlayers } extension block, threaded through to every environment's tests.reuse. - auth-authme-package: preflight declares reuse: false — its whole point is proving the login flow runs, which a reused, already authenticated player would skip. - docs: reuse coverage across writing-tests, configuration, test-filtering, plugins, external-servers, custom-modes, reports. Verified against example_plugin's local suite both ways (PLUGWRIGHT_REUSE=1 and unset): 47/47 either way. Three specs needed reuse: false / excludeAbilities to keep their isolation assumptions once reuse was on, annotated with why. --- auth-authme-package/index.ts | 4 +- docs/configuration.mdx | 17 ++ docs/custom-modes.mdx | 5 +- docs/external-servers.mdx | 6 + docs/plugins.mdx | 21 ++- docs/reports.mdx | 8 +- docs/test-filtering.mdx | 4 + docs/writing-tests.mdx | 48 ++++- .../src/test/e2e/tests/events.spec.ts | 4 +- .../src/test/e2e/tests/kits.spec.ts | 4 +- .../src/test/e2e/tests/player-wrapper.spec.ts | 4 +- .../src/test/e2e/tests/shop.spec.ts | 4 +- .../plugwright/PlugwrightCorePlugin.kt | 6 + .../drownek/plugwright/PlugwrightExtension.kt | 8 + .../plugwright/PlugwrightMatrixTask.kt | 4 + .../drownek/plugwright/PlugwrightTestTask.kt | 13 ++ .../kotlin/me/drownek/plugwright/ReuseSpec.kt | 24 +++ .../me/drownek/plugwright/RunnerLauncher.kt | 10 + runner-package/lib/account.ts | 7 + runner-package/lib/config.ts | 37 +++- runner-package/lib/environment.ts | 3 + runner-package/lib/player-registry.ts | 168 +++++++++++++++++ runner-package/lib/player.ts | 65 ++++++- runner-package/lib/plugin-host.ts | 10 +- runner-package/lib/plugin.ts | 8 + runner-package/lib/reporter.ts | 1 + runner-package/lib/session.ts | 24 ++- runner-package/lib/test-registry.ts | 21 ++- runner-package/lib/test-runner.ts | 171 +++++++++++++----- runner-package/lib/types.ts | 8 +- runner-package/runner.ts | 56 +++++- 31 files changed, 695 insertions(+), 78 deletions(-) create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt create mode 100644 runner-package/lib/player-registry.ts diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index 76ff2ed..122ff6e 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -59,7 +59,9 @@ let resolved: Required> & { password?: strin export default definePlugin({ name: 'authme', apiVersion: 1, - tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight' }], + // Preflight exists to prove the login/register flow actually runs — a reused, already + // authenticated player would skip straight past what this test checks. + tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight', reuse: false }], setup({ options }) { resolved = { ...DEFAULTS, ...options }; diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 83b5014..91d89a0 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -273,6 +273,19 @@ matrix { } ``` + + Settings for reusing a connected bot across test boundaries instead of reconnecting for every test. `enabled` is `false` by default — an existing suite that depends on a fresh player per test keeps working unchanged until it opts in. `maxPlayers` caps live registry entries; unset falls back to 4, or the environment's account pool capacity minus one when it has a pool. + + +```kotlin +reuse { + enabled.set(true) + maxPlayers.set(4) +} +``` + +`PLUGWRIGHT_REUSE=1` / `PLUGWRIGHT_REUSE=0` overrides `reuse.enabled` from the environment, for trying it in a dev loop without editing a committed build script. See [Writing Tests](/writing-tests) for the test-side API. + Per-environment, inside `create(...) { }`: @@ -309,4 +322,8 @@ plugins { Set `PLUGWRIGHT_DEBUG=1` in your environment to enable verbose debug logging during test execution. This is particularly useful for troubleshooting GUI flows and inspecting window open/close events from the bot. + + Overrides `reuse.enabled` for this run: `1`/`true` turns it on, `0`/`false` turns it off. Unset, or any other value, leaves the build script's own setting alone. + + diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx index 6a43ecb..37e336a 100644 --- a/docs/custom-modes.mdx +++ b/docs/custom-modes.mdx @@ -152,6 +152,9 @@ class VelocityEnvironment implements Environment { arbitraryUsernames: true, lifecycle: true, cleanupStrategy: 'compensating', + // Absent means "allowed". Set this to false if a bot sitting connected between + // tests would break the environment (an idle-kick timeout, a per-test world reset). + playerReuse: true, }; async setup(session: Session): Promise { /* connect, probe, warm up */ } @@ -163,7 +166,7 @@ class VelocityEnvironment implements Environment { } ``` -Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. +Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. `playerReuse: false` overrides `tests.reuse.enabled` for the whole run against this environment — set it when a long-lived bot would break something the environment can't tell tests about any other way. `accounts()` and `beforeJoin()` are optional. Returning no pool means every bot gets a throwaway `Test_` username, which is what `local` does. diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index f8dc16e..4e211e6 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -94,6 +94,12 @@ That is a request for a specific identity, not for whatever is free — so nothi A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, exclude what you can't, and treat `capabilities.freshState = false` as the honest description it is. +## Reuse and the account pool + +With `tests.reuse` on ([Configuration](/configuration)), a registry entry holds its leased account for as long as the entry lives, not just for one test — an account checked out by a long-lived player doesn't return to the pool until that player is evicted, invalidated, or the run ends. Size `accounts { }` accordingly: `maxPlayers` defaults to the pool's capacity minus one so a test's own `createPlayer()` still has a spare slot, but a pool exactly as big as `maxPlayers` leaves nothing free for it. + +An environment that can't tolerate a bot sitting connected between tests — an idle-kick timeout, a per-test world reset — should report `capabilities.playerReuse = false` after `setup()` rather than let reuse quietly misbehave. See [Writing a Mode](/custom-modes). + ## Checking the stand before you test ```bash diff --git a/docs/plugins.mdx b/docs/plugins.mdx index a9dc070..f09dfb9 100644 --- a/docs/plugins.mdx +++ b/docs/plugins.mdx @@ -32,11 +32,12 @@ export interface PlugwrightPlugin { apiVersion?: number; setup?(ctx: { session, env, options: O }): Promise | void; onPlayerCreate?(player, ctx: { account, env }): Promise | void; + onPlayerReuse?(player, ctx: { account, env }): Promise | void; beforeEach?(ctx: TestContext): Promise | void; afterEach?(ctx: TestContext): Promise | void; extendContext?(ctx: TestContext): Record | void; matchers?: Record; - tests?: Array<{ file: string; mode: 'preflight' | 'suite' }>; + tests?: Array<{ file: string; mode: 'preflight' | 'suite'; reuse?: false }>; cleanup?(ctx: { session, scope: 'session' | 'manual' }): Promise | void; teardown?(): Promise | void; } @@ -71,6 +72,24 @@ export default definePlugin({ `onPlayerCreate` fires on every connection: the first bot of a test, a second bot from `createPlayer()`, every `player.rejoin()`, and the admin-bot console channel. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook. +## Reuse + +```ts +export interface PlugwrightPlugin { + onPlayerReuse?(player, ctx: { account, env }): Promise | void; +} +``` + +Fires before a reused player is handed to the next test — never on the first connection, where `onPlayerCreate` already runs. By the time it fires, the core has already done its own safe minimum (closing a leftover open window); anything beyond that — clearing a hotbar, resetting a scoreboard value your plugin tracks — is yours to do here. See [Writing Tests](/writing-tests) for the test-side `reuse` option and ability labels. + +An auth plugin's preflight exists to prove the login flow runs, so a reused, already-authenticated player would defeat the point: + +```ts +tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight', reuse: false }] +``` + +`PluginTestRef.reuse: false` forces every test in that file onto a fresh connection, regardless of the run's own `reuse` setting. + ## Inherited tests ```ts diff --git a/docs/reports.mdx b/docs/reports.mdx index a125849..2b3432e 100644 --- a/docs/reports.mdx +++ b/docs/reports.mdx @@ -25,7 +25,8 @@ build/reports/plugwright/.log per-environment output, matrix runs o "durationMs": 63, "error": null, "skipReason": null, - "plugin": null + "plugin": null, + "reuse": { "key": "auto:[]!()", "reused": true, "abilities": [] } }, { "file": "…/dist/simple-ts.spec.js", @@ -34,7 +35,8 @@ build/reports/plugwright/.log per-environment output, matrix runs o "durationMs": 0, "error": null, "skipReason": "requires capability [consoleOutput:full], unavailable on \"staging\"", - "plugin": null + "plugin": null, + "reuse": null } ] } @@ -42,6 +44,8 @@ build/reports/plugwright/.log per-environment output, matrix runs o `status` is `pass`, `fail` or `skip`. `plugin` names the plugin a test came from when it was inherited rather than found in your test directory. +`reuse` is `null` when `tests.reuse` is off for the run. Otherwise it's always present, even for a test that opted out with `reuse: false` (`reused: false`, `key: "none"`). `reused: true` means the player came from an earlier test instead of a fresh connection; `abilities` is the label set it was matched against. See [Writing Tests](/writing-tests). + Every skip carries its reason: excluded by name, wrong environment, or a capability the environment doesn't have. A skipped test that doesn't say why is worse than a failing one, because it reads as coverage. ## JUnit XML diff --git a/docs/test-filtering.mdx b/docs/test-filtering.mdx index f3ebc12..2e61c88 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -77,6 +77,10 @@ test('the /debug dev command', { environments: ['local'] }, async ({ player }) = }); ``` +## Reuse is a different axis + +`requires` and `environments` decide whether a test runs at all. `reuse` (see [Writing Tests](/writing-tests)) decides which bot it gets once it's already running — a filter never skips a test because of its `reuse` option. The two do interact on one environment setting: `capabilities.playerReuse === false` disables reuse for the whole environment without affecting anything a test declared through `requires`. + ## Skips are reported Every skipped test lands in the report with its reason: diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index 8d59a41..c2f5972 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -123,9 +123,55 @@ test('advanced waiting', async ({ player }) => { }); ``` +## Player Reuse + +By default, every test gets a fresh bot and disconnects it when the test ends. When `tests.reuse` is on ([Configuration](/configuration)), a test can ask for a bot that survived from an earlier test instead of reconnecting: + +```typescript +test('shows prices', async ({ player }) => { + // same long-lived player as any other default-reuse test, if one is free +}); + +opTest('admin can edit', async ({ player }) => { + // matched to a player already carrying the `op` label — makeOp() only runs if none does +}); + +test('regular player cannot edit', { reuse: { excludeAbilities: ['op'] } }, async ({ player }) => { + // explicitly asks for a player that is NOT op, even if an op'd one is sitting free +}); + +test('first join flow', { reuse: false }, async ({ player }) => { + // always a brand-new connection, regardless of the run's reuse setting +}); +``` + +Matching goes by **ability labels**, not by resetting server state — the runner has no way to undo what a command changed, so it doesn't pretend to. `player.makeOp()`, `player.deOp()` and `player.setGameMode()` label the player automatically (`op`, `gamemode:creative`, …); anything else needs an explicit `player.mark('kit:starter')` / `player.unmark(...)`. Read the current set with `player.abilities`. + +```typescript +export interface ReuseOptions { + key?: string; // explicit identity — same bot every time, e.g. the second player in a multiplayer test + abilities?: string[]; // player must carry all of these + excludeAbilities?: string[]; // player must carry none of these + strict?: boolean; // player's labels must equal `abilities` exactly, no extras +} +``` + +`reuse` on `test()` accepts `false`, a string (shorthand for `{ key }`), or a `ReuseOptions` object. `ctx.createPlayer({ reuse: … })` takes the same shape for any secondary player a test creates. + +A test that cares about a clean nick, the absence of a label, or a first-registration flow declares `reuse: false` (or the right `excludeAbilities`) explicitly — reuse never guesses on a test's behalf. + +```typescript +test('cleans up on failure', async ({ player, invalidatePlayer }) => { + // ... + if (somethingLeftThePlayerInABadState) invalidatePlayer(player); +}); +``` + +`invalidatePlayer` marks a player unfit for the next test: it disconnects instead of being handed out again. The runner does this automatically for a test that fails or times out — one bad test shouldn't hand its mess to the next one. + ## Best Practices -1. **Keep tests isolated** - Each test gets a fresh bot +1. **Keep tests isolated** - Each test gets a fresh bot, unless reuse is on 2. **Use descriptive names** - Make test failures easy to understand 3. **Wait for conditions** - Use assertions that auto-retry 4. **Test one thing** - Each test should verify one behavior diff --git a/example_plugin/src/test/e2e/tests/events.spec.ts b/example_plugin/src/test/e2e/tests/events.spec.ts index a91ffbc..cb5bad9 100644 --- a/example_plugin/src/test/e2e/tests/events.spec.ts +++ b/example_plugin/src/test/e2e/tests/events.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from '@drownek/plugwright'; -test('player receives item on first join', async ({ player }) => { +// Depends on the join itself, not just on a player's current state, so it always needs a +// brand-new connection — reuse would hand it a player who already joined once before. +test('player receives item on first join', { reuse: false }, async ({ player }) => { await expect(player).toHaveReceivedMessage('Welcome'); await expect(player).toContainItem('wooden_sword'); }); diff --git a/example_plugin/src/test/e2e/tests/kits.spec.ts b/example_plugin/src/test/e2e/tests/kits.spec.ts index 9874194..9a79156 100644 --- a/example_plugin/src/test/e2e/tests/kits.spec.ts +++ b/example_plugin/src/test/e2e/tests/kits.spec.ts @@ -14,7 +14,9 @@ test('kit has cooldown', async ({ player }) => { await expect(player).toHaveReceivedMessage('cooldown'); }); -test('VIP kit requires permission', async ({ player }) => { +// Op bypasses permission checks in Bukkit by default, so this only proves anything against a +// player that isn't one. +test('VIP kit requires permission', { reuse: { excludeAbilities: ['op'] } }, async ({ player }) => { player.chat('/kit vip'); await expect(player).toHaveReceivedMessage('no permission'); }); diff --git a/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts b/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts index b43aec9..d354ce6 100644 --- a/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts +++ b/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts @@ -4,7 +4,9 @@ import { expect, test } from '@drownek/plugwright'; -test('makeOp', async ({ player }) => { +// Needs a player that isn't already op, or the server never sends the "Made ... a server +// operator" confirmation this test checks for. +test('makeOp', { reuse: { excludeAbilities: ['op'] } }, async ({ player }) => { // This executes op server command, and we wait for response from server // so when await completes, we are sure player is op. await player.makeOp(); diff --git a/example_plugin/src/test/e2e/tests/shop.spec.ts b/example_plugin/src/test/e2e/tests/shop.spec.ts index 0b0ef34..5e8de4d 100644 --- a/example_plugin/src/test/e2e/tests/shop.spec.ts +++ b/example_plugin/src/test/e2e/tests/shop.spec.ts @@ -19,7 +19,9 @@ test('purchase item from shop', async ({ player }) => { await expect(player).toContainItem('diamond'); }); -test('cannot buy without money', async ({ player }) => { +// Depends on starting with no currency, which a reused player carried over from an earlier +// test can't promise — a fresh connection is the only way to guarantee it. +test('cannot buy without money', { reuse: false }, async ({ player }) => { player.chat('/shop'); const gui = await player.gui({ title: 'Shop' }); await gui.locator(item => item.name === 'diamond').click(); 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 aecce6f..2b3aa20 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 @@ -210,6 +210,10 @@ class PlugwrightCorePlugin : Plugin { runtimePackage.set(ref.name) ref.export?.let { runtimeExport.set(it) } } + if (extension.reuse.enabled.get()) { + reuseEnabled.set(true) + extension.reuse.maxPlayers.orNull?.let { reuseMaxPlayers.set(it) } + } if (project.hasProperty("testFiles")) testFiles.set(project.property("testFiles") as String) if (project.hasProperty("testNames")) testNames.set(project.property("testNames") as String) @@ -261,6 +265,8 @@ class PlugwrightCorePlugin : Plugin { journalFile = journalFilePath, runtimePackage = runtimeRef?.name, runtimeExport = runtimeRef?.export, + reuseEnabled = extension.reuse.enabled.get().takeIf { it }, + reuseMaxPlayers = extension.reuse.maxPlayers.orNull, ) ctx.prepareTaskRef?.let { matrixPrepareTasks += it } } 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 b6adc04..a7fdd41 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 @@ -59,6 +59,14 @@ abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperti matrix.action() } + /** Settings for reusing a connected bot across test boundaries. See [reuse]. */ + val reuse: ReuseSpec = project.objects.newInstance(ReuseSpec::class.java) + + /** Configures reuse: `reuse { enabled.set(true); maxPlayers.set(4) }`. */ + fun reuse(action: ReuseSpec.() -> Unit) { + reuse.action() + } + /** Registries the workspace installs from, and the credentials for them. See [npm]. */ val npm: NpmSpec = NpmSpec() 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 ad3e9fa..9e7c4f0 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 @@ -30,6 +30,8 @@ internal data class MatrixEnvironmentInput( val journalFile: File?, val runtimePackage: String? = null, val runtimeExport: String? = null, + val reuseEnabled: Boolean? = null, + val reuseMaxPlayers: Int? = null, ) private data class EnvironmentSummary(val total: Int, val passed: Int, val failed: Int, val skipped: Int, val durationMs: Long) @@ -128,6 +130,8 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { journalFile = env.journalFile, runtimePackage = env.runtimePackage, runtimeExport = env.runtimeExport, + reuseEnabled = env.reuseEnabled, + reuseMaxPlayers = env.reuseMaxPlayers, ) RunnerLauncher.writeConfig(entry) val cliJs = RunnerLauncher.resolveCliJs(env.workspaceDir) 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 0578217..5fa44ba 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 @@ -49,6 +49,17 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { @get:Optional abstract val excludeTests: ListProperty + /** From `plugwright.reuse.enabled`. Unset means "reuse off", matching a config with no + * `tests.reuse` key at all. */ + @get:Input + @get:Optional + abstract val reuseEnabled: Property + + /** From `plugwright.reuse.maxPlayers`. Unset means "runner default". */ + @get:Input + @get:Optional + abstract val reuseMaxPlayers: Property + /** * 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 @@ -130,6 +141,8 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { journalFile = journalFile.orNull?.asFile, runtimePackage = runtimePackage.orNull, runtimeExport = runtimeExport.orNull, + reuseEnabled = reuseEnabled.orNull, + reuseMaxPlayers = reuseMaxPlayers.orNull, ) RunnerLauncher.writeConfig(entry) logger.lifecycle("Runner config: ${configDestination.absolutePath}") diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt new file mode 100644 index 0000000..b3c40fc --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt @@ -0,0 +1,24 @@ +package me.drownek.plugwright + +import org.gradle.api.provider.Property + +/** + * Settings for reusing a connected bot across test boundaries instead of reconnecting for + * every test. Written into every environment's `tests.reuse`, the same way `MatrixSpec` + * configures the matrix run rather than any one environment. + */ +abstract class ReuseSpec { + + /** Off by default: a project turns this on deliberately, so an existing suite that + * depends on a fresh player per test (a unique nick, no leftover op) keeps working + * unchanged until it opts in. */ + abstract val enabled: Property + + /** Live registry entries allowed at once. Unset means "runner default" — 4, or the + * environment's account pool capacity minus one when it has a pool. */ + abstract val maxPlayers: Property + + init { + enabled.convention(false) + } +} 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 31abd16..ef16307 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 @@ -38,6 +38,10 @@ object RunnerLauncher { val runtimeExport: String? = null, /** Crash-recovery journal path for `Session.journal`; null disables on-disk persistence. */ val journalFile: File? = null, + /** null means "reuse off", matching a config with no `tests.reuse` key at all. */ + val reuseEnabled: Boolean? = null, + /** null means "runner default" — only meaningful when [reuseEnabled] is true. */ + val reuseMaxPlayers: Int? = null, ) fun writeConfig(entry: Entry) { @@ -66,6 +70,12 @@ object RunnerLauncher { if (entry.excludeTests.isNotEmpty()) putStrings("exclude", entry.excludeTests) else putNull("exclude") // null means "runner default", which TEST_TIMEOUT can still override. putNull("timeoutMs") + if (entry.reuseEnabled != null) { + obj("reuse") { + put("enabled", entry.reuseEnabled) + entry.reuseMaxPlayers?.let { put("maxPlayers", it) } + } + } } if (entry.jsonReportFile != null || entry.junitReportFile != null) { obj("reports") { diff --git a/runner-package/lib/account.ts b/runner-package/lib/account.ts index 7bb7a36..0e39f3b 100644 --- a/runner-package/lib/account.ts +++ b/runner-package/lib/account.ts @@ -83,6 +83,13 @@ export class AccountPool { : null; } + /** Total configured slots (pool + microsoft + autoRegister's max), not the number + * currently free. Used to size a fixed-slot consumer (e.g. reuse's `maxPlayers` + * default) before anything has been leased. */ + capacity(): number { + return this.queue.length + (this.autoRegister?.max ?? 0); + } + async lease(): Promise { const entry = this.queue.shift(); if (entry) { diff --git a/runner-package/lib/config.ts b/runner-package/lib/config.ts index d129e84..9193a26 100644 --- a/runner-package/lib/config.ts +++ b/runner-package/lib/config.ts @@ -31,6 +31,17 @@ export interface EnvironmentConfig { config: Record; } +/** Settings for reusing a connected bot across test boundaries instead of reconnecting for + * every test. Absent, or `enabled: false`, is the pre-reuse behavior: connect → test → + * disconnect, every time. */ +export interface ReuseConfig { + enabled: boolean; + /** Live registry entries allowed at once. Defaults to 4, or `AccountPool` capacity minus + * one when the environment has a pool — one slot is kept free for a test's own + * `createPlayer()` call. */ + maxPlayers?: number | null; +} + export interface TestsConfig { /** Directory scanned for compiled spec files. Defaults to the working directory. */ dir?: string | null; @@ -42,6 +53,7 @@ export interface TestsConfig { names?: string[] | null; /** Per-test timeout; falls back to TEST_TIMEOUT and then to 30s. */ timeoutMs?: number | null; + reuse?: ReuseConfig | null; } export interface ReportsConfig { @@ -188,10 +200,15 @@ function configFromEnvironment(): RunnerConfig { */ 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 config = flagPath + ? readConfigFile(isAbsolute(flagPath) ? flagPath : resolve(process.cwd(), flagPath)) + : loadDefaultOrLegacyConfig(); + + applyReuseEnvOverride(config); + return config; +} +function loadDefaultOrLegacyConfig(): RunnerConfig { const defaultPath = resolve(process.cwd(), DEFAULT_CONFIG_FILENAME); try { readFileSync(defaultPath); @@ -201,6 +218,20 @@ export function loadRunnerConfig(argv: string[] = process.argv.slice(2)): Runner } } +/** `PLUGWRIGHT_REUSE` overrides `tests.reuse.enabled` from a committed config file — the + * toggle for a dev's own edit loop, so reuse never has to live in a checked-in build script + * just to be tried locally. `1`/`true` enables it, `0`/`false` disables it; anything else, or + * unset, leaves the config's own value alone. */ +function applyReuseEnvOverride(config: RunnerConfig): void { + const raw = process.env.PLUGWRIGHT_REUSE; + if (raw === undefined) return; + const enabled = raw === '1' || raw.toLowerCase() === 'true'; + const disabled = raw === '0' || raw.toLowerCase() === 'false'; + if (!enabled && !disabled) return; + + config.tests.reuse = { ...config.tests.reuse, enabled }; +} + /** 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'; diff --git a/runner-package/lib/environment.ts b/runner-package/lib/environment.ts index 61e43c6..bef610f 100644 --- a/runner-package/lib/environment.ts +++ b/runner-package/lib/environment.ts @@ -12,6 +12,9 @@ export interface EnvironmentCapabilities { arbitraryUsernames: boolean; lifecycle: boolean; cleanupStrategy: 'wipe' | 'compensating' | 'none'; + /** Absent means "allowed". An environment that breaks under a bot that stays connected + * across tests (an idle-kick timeout, a world reset between tests) sets this to `false`. */ + playerReuse?: boolean; } export interface BotConnectionOptions { diff --git a/runner-package/lib/player-registry.ts b/runner-package/lib/player-registry.ts new file mode 100644 index 0000000..4345ece --- /dev/null +++ b/runner-package/lib/player-registry.ts @@ -0,0 +1,168 @@ +import pc from 'picocolors'; +import type { Bot } from 'mineflayer'; +import type { PlayerWrapper } from './player.js'; +import type { Account, AccountPool } from './account.js'; +import type { Session } from './session.js'; + +/** What a test asks for when it wants a long-lived player instead of a fresh connection. + * `key` names an identity directly; without it, matching goes by ability labels. */ +export interface ReuseOptions { + /** Explicit identity. Needed when a test cares that it gets the *same* bot back — + * the second player in a multiplayer test, for instance. */ + key?: string; + /** Labels the player must carry. */ + abilities?: string[]; + /** Labels the player must not carry. */ + excludeAbilities?: string[]; + /** The player's label set must equal `abilities` exactly — no extras allowed. */ + strict?: boolean; +} + +export interface ConnectedPlayer { + player: PlayerWrapper; + account: Account; + pool: AccountPool | null; +} + +export interface ResolveResult { + player: PlayerWrapper; + key: string; + reused: boolean; +} + +interface RegistryEntry { + key: string; + player: PlayerWrapper; + account: Account; + pool: AccountPool | null; + /** Held by the current test: not handed out a second time, not evicted by LRU. */ + checkedOut: boolean; + lastUsedAt: number; +} + +/** Implicit key for a request with no explicit `key`: the normalized requirement set, so two + * requests asking for the same shape of player land on the same entry. */ +function derivedKey(options: ReuseOptions): string { + const abilities = [...(options.abilities ?? [])].sort().join(','); + const exclude = [...(options.excludeAbilities ?? [])].sort().join(','); + return `auto:[${abilities}]!(${exclude})${options.strict ? ':strict' : ''}`; +} + +function matches(entry: RegistryEntry, options: ReuseOptions): boolean { + const abilities = entry.player.abilities; + if ((options.abilities ?? []).some(a => !abilities.has(a))) return false; + if ((options.excludeAbilities ?? []).some(a => abilities.has(a))) return false; + if (options.strict && abilities.size !== (options.abilities?.length ?? 0)) return false; + return true; +} + +/** + * Long-lived bots that survive test boundaries within one run. Entries are matched by the + * ability labels a player carries (see `PlayerWrapper.abilities`) rather than by resetting + * server state back to a known baseline — the core has no way to undo what a plugin's own + * commands changed, so it doesn't pretend to. + * + * `resolve()` never connects a bot itself; it calls the `connect` callback it's given, so the + * caller keeps ownership of connection options, throttling and account leasing. + */ +export class PlayerRegistry { + private readonly entries: RegistryEntry[] = []; + + constructor( + private readonly session: Session, + private readonly maxPlayers: number, + ) {} + + /** Every bot this registry currently owns — checked out by the running test or sitting + * free for the next one. Callers pass this to `Session.disconnectAllBots` as the "keep" + * list, so a per-test sweep doesn't take down an entry no test happened to touch this + * time. */ + ownedBots(): Bot[] { + return this.entries.map(e => e.player.bot); + } + + async resolve(options: ReuseOptions, connect: () => Promise): Promise { + if (options.key) { + const existing = this.entries.find(e => e.key === options.key); + if (existing) { + if (matches(existing, options)) return this.checkout(existing, connect); + await this.drop(existing, `abilities don't match a new request for key "${options.key}"`); + } + return this.createEntry(options.key, connect); + } + + const free = this.entries.find(e => !e.checkedOut && matches(e, options)); + if (free) return this.checkout(free, connect); + + if (this.entries.length >= this.maxPlayers) { + const victim = this.entries + .filter(e => !e.checkedOut) + .sort((a, b) => a.lastUsedAt - b.lastUsedAt)[0]; + if (!victim) { + throw new Error( + `PlayerRegistry: maxPlayers=${this.maxPlayers} reached and every entry is checked out ` + + 'by the current test. Request fewer simultaneous players, or raise tests.reuse.maxPlayers.' + ); + } + await this.drop(victim, `evicted: maxPlayers=${this.maxPlayers} reached`); + } + + return this.createEntry(derivedKey(options), connect); + } + + /** Returns a checked-out entry to the free pool. No-op for a player this registry doesn't own. */ + release(player: PlayerWrapper): void { + const entry = this.entries.find(e => e.player === player); + if (!entry) return; + entry.checkedOut = false; + entry.lastUsedAt = Date.now(); + } + + /** Drops a broken or disqualified entry: disconnects it, returns its account, forgets it. + * No-op for a player this registry doesn't own. */ + async invalidate(player: PlayerWrapper, reason: string = 'invalidated'): Promise { + const entry = this.entries.find(e => e.player === player); + if (entry) await this.drop(entry, reason); + } + + /** Disconnects and forgets every entry — end-of-run teardown. */ + async disconnectAll(): Promise { + for (const entry of [...this.entries]) await this.drop(entry, 'session teardown'); + } + + /** A dead connection is transparently rejoined before it's handed out — a bot picked back + * up by the registry is otherwise indistinguishable from one that's still live, and the + * test has no reason to expect it might not be. A failed rejoin falls back to a fresh + * entry under the same key, same as a first-time miss. */ + private async checkout(entry: RegistryEntry, connect: () => Promise): Promise { + if ((entry.player.bot as any)._client?.ended) { + try { + await entry.player.rejoin(); + } catch (error) { + await this.drop(entry, `dead connection, rejoin failed: ${(error as Error).message}`); + return this.createEntry(entry.key, connect); + } + } + + entry.checkedOut = true; + const labels = [...entry.player.abilities].join(', ') || '-'; + console.log(pc.dim(`[Reuse] ${entry.player.username} from registry (key "${entry.key}", abilities: ${labels})`)); + return { player: entry.player, key: entry.key, reused: true }; + } + + private async createEntry(key: string, connect: () => Promise): Promise { + const { player, account, pool } = await connect(); + this.entries.push({ key, player, account, pool, checkedOut: true, lastUsedAt: Date.now() }); + console.log(pc.dim(`[Reuse] ${player.username} new player (key "${key}")`)); + return { player, key, reused: false }; + } + + private async drop(entry: RegistryEntry, reason: string): Promise { + const idx = this.entries.indexOf(entry); + if (idx !== -1) this.entries.splice(idx, 1); + console.log(pc.dim(`[Reuse] ${entry.player.username} discarded (${reason})`)); + await this.session.disconnectBot(entry.player.bot, entry.player.username); + this.session.removeBot(entry.player.bot); + entry.pool?.release(entry.account); + } +} diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 2b9bdfb..c38ed81 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -45,7 +45,13 @@ export class PlayerWrapper { private _botOptions?: BotConnectionOptions; private _spawnPromise: Promise | null = null; private _listenersBot: Bot | null = null; - private account?: Account; + private _account?: Account; + /** Labels describing server state this player is known to carry — set automatically by + * `makeOp`/`deOp`/`setGameMode`, and by hand via `mark`/`unmark` for anything else. Survives + * `rejoin()`: it describes server state, which a reconnect doesn't touch. Used by + * `PlayerRegistry` to match a reused player against a test's requirements; the core never + * parses or verifies a label's meaning. */ + private readonly _abilities = new Set(); constructor(bot: Bot, session: Session) { this.bot = bot; @@ -119,12 +125,12 @@ export class PlayerWrapper { // is a prompt no authentication plugin can answer. this._registerPersistentListeners(); - if (this.account) { + if (this._account) { // Authentication has to happen while the server still holds the player: AuthMe and // friends keep an unauthenticated bot out of the world entirely, so waiting for the // spawn first would wait for something login is the precondition of. await Promise.race([this._spawnPromise, this._waitForLogin(timeout)]); - await this.session.onPlayerCreate?.(this, { account: this.account, env: this.session.env }); + await this.session.onPlayerCreate?.(this, { account: this._account, env: this.session.env }); } await this._spawnPromise; @@ -154,7 +160,13 @@ export class PlayerWrapper { /** @internal */ _setAccount(account: Account): void { - this.account = account; + this._account = account; + } + + /** The account this player connected with. Set for every player the runner creates + * (`createPlayer` always calls `_setAccount`); undefined only if constructed by hand. */ + get account(): Account | undefined { + return this._account; } private _registerPersistentListeners(): void { @@ -192,6 +204,29 @@ export class PlayerWrapper { this.serverWrapper = server; } + /** Read-only snapshot of this player's ability labels. */ + get abilities(): ReadonlySet { + return this._abilities; + } + + /** Records that this player carries `ability`. A statement, not a check — nothing here + * verifies it against real server state. */ + mark(ability: string): void { + this._abilities.add(ability); + } + + /** Removes `ability`. No-op if the player never carried it. */ + unmark(ability: string): void { + this._abilities.delete(ability); + } + + private markGameMode(mode: string): void { + for (const ability of this._abilities) { + if (ability.startsWith('gamemode:')) this._abilities.delete(ability); + } + this._abilities.add(`gamemode:${mode}`); + } + getCurrentGui(): GuiWrapper | null { let currentWindow = this.bot.currentWindow; return currentWindow ? new GuiWrapper(this.bot, currentWindow as Window) : null; @@ -259,24 +294,39 @@ export class PlayerWrapper { const response = await this.serverWrapper!.executeAndWait(command); // "Made X a server operator" on success, "Nothing changed. The player already is // an operator" when it was already granted — both mean the player is op now. - if (/operator/i.test(response)) return; + if (/operator/i.test(response)) { + this.mark('op'); + return; + } throw new Error(`Player ${this.username} was not opped: ${response.trim() || 'no response from the console'}`); } + const messagesSince = this.messageBuffer.length; + const consoleSince = this.session.consoleLog.length; this.serverWrapper!.execute(command); + // "Made X a server operator" reaches the player's own chat. "Nothing changed. The + // player already is an operator" — the case a reused, already-op player hits on a + // second `makeOp()` — never does; it only ever shows up in the server's own log. await poll( - () => this.messageBuffer.find(m => m.includes(`Made ${this.username} a server operator`)), + () => + this.messageBuffer.slice(messagesSince).find(m => m.includes(`Made ${this.username} a server operator`)) ?? + this.session.consoleLog.slice(consoleSince).find(m => /operator/i.test(m)), { message: `Player ${this.username} was not opped` } ); + this.mark('op'); } async deOp(): Promise { await this.executeAndSync(`minecraft:deop ${this.username}`); + this.unmark('op'); } async setGameMode(mode: 'survival' | 'creative' | 'adventure' | 'spectator'): Promise { - if (this.bot.game.gameMode === mode) return; + if (this.bot.game.gameMode === mode) { + this.markGameMode(mode); + return; + } this.requireServer(); this.serverWrapper!.execute(`minecraft:gamemode ${mode} ${this.username}`); @@ -284,6 +334,7 @@ export class PlayerWrapper { () => this.bot.game.gameMode === mode ? true : undefined, { message: `Game mode did not change to "${mode}"` } ); + this.markGameMode(mode); } async teleport(x: number, y: number, z: number): Promise { diff --git a/runner-package/lib/plugin-host.ts b/runner-package/lib/plugin-host.ts index 177a18f..974169d 100644 --- a/runner-package/lib/plugin-host.ts +++ b/runner-package/lib/plugin-host.ts @@ -76,6 +76,12 @@ export class PluginHost { } } + async onPlayerReuse(player: PlayerWrapper, ctx: { account: Account; env: Environment }): Promise { + for (const { plugin } of this.plugins) { + await plugin.onPlayerReuse?.(player, ctx); + } + } + async beforeEach(ctx: TestContext): Promise { for (const { plugin } of this.plugins) { await plugin.beforeEach?.(ctx); @@ -105,13 +111,13 @@ export class PluginHost { /** 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 }[] { + testFiles(mode: PluginTestRef['mode']): { file: string; pluginName: string; reuse?: false }[] { return this.plugins .filter(p => p.inheritTests) .flatMap(({ plugin }) => (plugin.tests ?? []) .filter(t => t.mode === mode) - .map(t => ({ file: t.file, pluginName: plugin.name })) + .map(t => ({ file: t.file, pluginName: plugin.name, reuse: t.reuse })) ); } diff --git a/runner-package/lib/plugin.ts b/runner-package/lib/plugin.ts index 352666c..3f1aec4 100644 --- a/runner-package/lib/plugin.ts +++ b/runner-package/lib/plugin.ts @@ -30,6 +30,10 @@ export interface PluginTestRef { * `suite` runs alongside user specs as regular tests, tagged with the plugin's name * in reports. */ mode: 'preflight' | 'suite'; + /** How this file relates to reuse. Absent means "follow the run's general rule". `false` + * forces every test in the file onto a fresh connection — the shape a preflight auth + * check needs, since it exists to prove the login flow, not to skip it. */ + reuse?: false; } export type MatcherFn = (this: any, ...args: any[]) => unknown; @@ -46,6 +50,10 @@ export interface PlugwrightPlugin { * 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; + /** Fired before a reused player is handed to the next test — never on the first connection, + * where `onPlayerCreate` already runs. The core has already done its own safe minimum + * (closing a leftover open window); anything beyond that is the plugin's call. */ + onPlayerReuse?(player: PlayerWrapper, ctx: { account: Account; env: Environment }): Promise | void; beforeEach?(ctx: TestContext): Promise | void; afterEach?(ctx: TestContext): Promise | void; extendContext?(ctx: TestContext): Record | void; diff --git a/runner-package/lib/reporter.ts b/runner-package/lib/reporter.ts index 8211386..16bad53 100644 --- a/runner-package/lib/reporter.ts +++ b/runner-package/lib/reporter.ts @@ -126,6 +126,7 @@ export function writeJsonReport(path: string, environmentName: string, testResul error: r.error ? r.error.message : null, skipReason: r.skipReason ?? null, plugin: r.plugin ?? null, + reuse: r.reuse ?? null, })), }; diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index 93f201c..5c6374f 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -1,6 +1,7 @@ import mineflayer, { Bot } from 'mineflayer'; import pc from 'picocolors'; import { CleanupJournal } from './journal.js'; +import { PlayerRegistry } from './player-registry.js'; import type { Environment, BotConnectionOptions } from './environment.js'; import type { ServerConsole } from './console.js'; import type { PlayerWrapper } from './player.js'; @@ -55,15 +56,19 @@ export class Session { readonly bots: Bot[] = []; readonly consoleLog = new MessageBuffer(); readonly journal: CleanupJournal; + /** Players that survive a test boundary instead of disconnecting in `finally`. Always + * present; unused unless a test actually asks for reuse (`tests.reuse.enabled`). */ + readonly players: PlayerRegistry; /** 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) { + constructor(env: Environment, journalPath: string | null = null, reuseMaxPlayers: number = 4) { this.env = env; this.journal = new CleanupJournal(journalPath); + this.players = new PlayerRegistry(this, reuseMaxPlayers); } /** Pulls the console channel from the environment. Called once `env.setup()` has produced one. */ @@ -163,12 +168,25 @@ export class Session { }); } - async disconnectAllBots(): Promise { + /** Disconnects every bot except those in `keep` (registry-owned bots a test released + * rather than dropped, typically). Called with no argument, this is a full teardown — + * the shape every caller before player reuse existed relied on. + * + * Each bot goes through `disconnectBot`, which is also what strips its listeners: a kept + * bot is still connected and still listening, so tearing the others down must not be a + * second implementation that forgets to. */ + async disconnectAllBots(keep: Bot[] = []): Promise { + const keepSet = new Set(keep); + await Promise.all( - this.bots.map((b, i) => this.disconnectBot(b, b.username ?? `bot-${i}`, 2000)) + this.bots + .filter(b => !keepSet.has(b)) + .map((b, i) => this.disconnectBot(b, b.username ?? `bot-${i}`, 2000)) ); + const remaining = this.bots.filter(b => keepSet.has(b)); this.bots.length = 0; + this.bots.push(...remaining); } /** Feeds raw environment output (e.g. Minecraft server stdout/stderr) into the console log buffer. */ diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 5e4705e..1245aec 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -1,4 +1,5 @@ import type { TestContext } from './types.js'; +import type { ReuseOptions } from './player-registry.js'; export type Hook = (context: TestContext) => Promise | void; type TestFn = (context: TestContext) => Promise; @@ -14,6 +15,11 @@ type TestFn = (context: TestContext) => Promise; export interface TestOptions { requires?: string[]; environments?: string[]; + /** How this test wants its player resolved when `tests.reuse` is on. `false` forces a + * fresh connection regardless of the run's reuse setting — for a test that depends on a + * brand-new nick or the absence of a label another test might have left behind. A string + * is shorthand for `{ key }`. Omitted means "match by ability labels", the default. */ + reuse?: false | string | ReuseOptions; } interface DescribeScope { @@ -32,6 +38,7 @@ export interface TestCase { afterHooks: Hook[]; requires: string[]; environments: string[] | null; + reuse?: false | string | ReuseOptions; } export const testRegistry: TestCase[] = []; @@ -57,6 +64,7 @@ function registerTest(name: string, options: TestOptions, fn: TestFn): void { afterHooks: [...scopeStack].reverse().flatMap(s => s.afterHooks), requires: options.requires ?? [], environments: options.environments ?? null, + reuse: options.reuse, }); } @@ -70,13 +78,22 @@ export function test(name: string, fnOrOptions: TestFn | TestOptions, maybeFn?: } } +/** Appends `abilities: ['op']` to whatever `reuse` the test declared (or the implicit `{}`), + * so a reused player is matched by op status same as a fresh one gets opped. */ +function withOpAbility(reuse: TestOptions['reuse']): ReuseOptions { + const base: ReuseOptions = reuse === false ? {} : reuse === undefined ? {} : typeof reuse === 'string' ? { key: reuse } : reuse; + return { ...base, abilities: [...(base.abilities ?? []), 'op'] }; +} + 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(); + registerTest(name, { ...options, reuse: options.reuse === false ? false : withOpAbility(options.reuse) }, async (context: TestContext) => { + // Only when the resolved player doesn't already carry it: resolution above already + // matched on `op`, so a reused player skips straight to the test body. + if (!context.player.abilities.has('op')) await context.player.makeOp(); await fn(context); }); } diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index 6088d0b..76c892f 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -10,6 +10,7 @@ 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'; +import type { ConnectedPlayer, ReuseOptions } from './player-registry.js'; export interface RunTestCaseParams { file: string; @@ -20,17 +21,34 @@ export interface RunTestCaseParams { timeoutMs: number; /** Set when this test came from a plugin's inherited `tests`, for report labeling. */ pluginName?: string | null; + /** Whole-run setting: `tests.reuse.enabled` narrowed by the environment's + * `capabilities.playerReuse`. `false` reproduces the pre-reuse behavior exactly, down to + * the absence of `TestResult.reuse`. */ + reuseEnabled?: boolean; + /** Set for a plugin test file declaring `PluginTestRef.reuse === false` — forces a fresh + * connection for every test in the file regardless of `reuseEnabled` or the test's own + * `reuse` option. */ + forceReuseOff?: boolean; +} + +/** Normalizes a `TestOptions.reuse` value to what `PlayerRegistry.resolve` takes. */ +function normalizeReuse(reuse: false | string | ReuseOptions | undefined): false | ReuseOptions { + if (reuse === false) return false; + if (reuse === undefined) return {}; + if (typeof reuse === 'string') return { key: reuse }; + return reuse; } /** - * 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. + * Runs one test case end to end: resolves the primary bot (a fresh connection, or — when + * reuse applies — a registry lookup that may hand back a player from an earlier test), 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; + const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null, reuseEnabled = false, forceReuseOff = false } = params; console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); session.consoleLog.clear(); @@ -38,50 +56,100 @@ 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 => { - // An explicit username always bypasses the pool: it names a specific bot identity - // the test wants, not "give me whatever account is free". + // Accounts leased outside the registry (a bypass `createPlayer({ username })`, or any + // player created while reuse doesn't apply to this test) — returned in `finally` below, + // same as before player reuse existed. + const adhocAccounts: Array<{ account: Account; pool: AccountPool }> = []; + // Players this test drew from the registry, so `finally` knows what to release or drop. + const registryPlayers: PlayerWrapper[] = []; + const invalidated = new Set(); + const reuseEffective = reuseEnabled && !forceReuseOff; + let primaryReuse: { key: string; reused: boolean } | null = null; + + // The actual connect: leases an account (or generates a throwaway identity), joins the + // server, and returns the wrapper. Used directly for a fresh connection, and passed to + // the registry as the "nothing free matched" fallback. + const connectNewPlayer = async (options?: { username?: string }): Promise => { 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 account: Account = pool + ? await pool.lease() + : syntheticAccount(options?.username || `Test_${randomUUID().split('-')[0]}`); + + try { + const botUsername = account.username; + console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(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(botOptions); + player._setAccount(account); + + await player.join(); + return { player, account, pool }; + } catch (error) { + if (pool) pool.release(account); + throw error; } - const botUsername = account.username; - console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(botUsername)}`); + }; - await session.env.beforeJoin?.(); + /** `ctx.player` and `ctx.createPlayer` both funnel through here. `usernameOverride` is + * `createPlayer({ username })` — a specific identity, which always bypasses both the + * account pool and the registry, same as before reuse existed. */ + const resolvePlayer = async ( + usernameOverride: string | undefined, + reuseRequest: false | string | ReuseOptions | undefined, + ): Promise<{ player: PlayerWrapper; key: string; reused: boolean } | { player: PlayerWrapper; key: null; reused: false }> => { + if (usernameOverride) { + const { player } = await connectNewPlayer({ username: usernameOverride }); + return { player, key: null, reused: false }; + } - 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(botOptions); - player._setAccount(account); - - await player.join(); + const normalized = normalizeReuse(reuseRequest); + if (!reuseEffective || normalized === false) { + const { player, account, pool } = await connectNewPlayer(); + if (pool) adhocAccounts.push({ account, pool }); + return { player, key: null, reused: false }; + } + + const result = await session.players.resolve(normalized, () => connectNewPlayer()); + registryPlayers.push(result.player); + + if (result.reused) { + // Core's own safe minimum for a player coming back from a previous test — anything + // beyond this is the plugin's domain via onPlayerReuse. + const openWindow = result.player.bot.currentWindow; + if (openWindow) { + try { result.player.bot.closeWindow(openWindow); } catch { /* best effort */ } + } + await plugins.onPlayerReuse(result.player, { account: result.player.account!, env: session.env }); + } + + return { player: result.player, key: result.key, reused: result.reused }; + }; + + const createPlayer = async (options?: { username?: string; reuse?: false | string | ReuseOptions }): Promise => { + const { player } = await resolvePlayer(options?.username, options?.reuse); return player; }; - const player = await createPlayer(); + const { player, key: primaryKey, reused: primaryReused } = await resolvePlayer(undefined, testCase.reuse); + if (primaryKey !== null) primaryReuse = { key: primaryKey, reused: primaryReused }; const abortController = new AbortController(); const ctx: TestContext = { player, server, createPlayer, + invalidatePlayer: (p: PlayerWrapper) => { invalidated.add(p); }, signal: abortController.signal, cleanup: (fn: () => void | Promise) => { finalizers.push(fn); }, }; @@ -89,6 +157,7 @@ export async function runTestCase(params: RunTestCaseParams): Promise; @@ -133,16 +202,36 @@ export async function runTestCase(params: RunTestCaseParams): Promise clearTimeout(timeoutHandle)), timeoutPromise]); + testPassed = true; 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 }; + return { file, testName: testCase.name, passed: true, durationMs, plugin: pluginName, reuse: reportedReuse() }; } 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 }; + return { file, testName: testCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName, reuse: reportedReuse() }; } finally { - await session.disconnectAllBots(); - for (const { account, pool } of leasedAccounts) pool.release(account); + // A failed or timed-out test hands nothing forward: one bad test turning into a + // cascade of unrelated failures would put the real cause somewhere other than the + // report points at. + for (const p of registryPlayers) { + const dead = !!(p.bot as any)._client?.ended; + if (!testPassed || dead || invalidated.has(p)) { + await session.players.invalidate(p, !testPassed ? 'test failed' : dead ? 'connection dead' : 'invalidated by test'); + } else { + session.players.release(p); + } + } + // Keep every bot the registry owns, not just the ones this test happened to touch — + // a free entry another test will pick up later is not this test's to disconnect. + await session.disconnectAllBots(session.players.ownedBots()); + for (const { account, pool } of adhocAccounts) pool.release(account); + } + + function reportedReuse(): TestResult['reuse'] { + if (!reuseEnabled) return undefined; + if (!primaryReuse) return { key: 'none', reused: false, abilities: [] }; + return { key: primaryReuse.key, reused: primaryReuse.reused, abilities: [...player.abilities] }; } } diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 8909f91..573b971 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -1,10 +1,14 @@ import type { PlayerWrapper } from './player.js'; import type { ServerWrapper } from './server.js'; +import type { ReuseOptions } from './player-registry.js'; export interface TestContext { player: PlayerWrapper; server: ServerWrapper; - createPlayer: (options?: { username?: string }) => Promise; + createPlayer: (options?: { username?: string; reuse?: false | string | ReuseOptions }) => Promise; + /** Marks a player unfit for the next test: it disconnects instead of being handed out + * again. No-op for a player reuse never picked up (a plain fresh connection). */ + invalidatePlayer: (player: PlayerWrapper) => void; signal: AbortSignal; /** Registers a LIFO finalizer that always runs after the test body, before afterEach. * Errors are logged but never override the test result. */ @@ -23,4 +27,6 @@ export interface TestResult { skipReason?: string; /** Name of the plugin this test was inherited from, or null for a user spec. */ plugin?: string | null; + /** How the primary player was obtained. Absent when reuse is off for this run. */ + reuse?: { key: string; reused: boolean; abilities: string[] }; } \ No newline at end of file diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 458b4d0..b8e1054 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -32,8 +32,10 @@ export { test, opTest, describe, beforeEach, afterEach } from './lib/test-regist 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, PluginConfig } from './lib/config.js'; -export type { TestContext } from './lib/types.js'; +export type { RunnerConfig, EnvironmentConfig, TestsConfig, LocalEnvironmentConfig, SecretRef, PluginConfig, ReuseConfig } from './lib/config.js'; +export type { TestContext, TestResult } from './lib/types.js'; +export { PlayerRegistry } from './lib/player-registry.js'; +export type { ReuseOptions } from './lib/player-registry.js'; export type { Environment, EnvironmentCapabilities, BotConnectionOptions } from './lib/environment.js'; export type { ServerConsole } from './lib/console.js'; export { Session } from './lib/session.js'; @@ -109,6 +111,25 @@ async function findSpecFiles(dir: string): Promise { return results; } +/** `tests.reuse.enabled`, narrowed by the environment's own `capabilities.playerReuse`. An + * environment that can't tolerate a long-lived bot always wins over the config. */ +function resolveReuse(config: RunnerConfig, env: Environment): { enabled: boolean; maxPlayers: number } { + const requested = config.tests.reuse?.enabled ?? false; + if (!requested) return { enabled: false, maxPlayers: 4 }; + + if (env.capabilities.playerReuse === false) { + console.log(pc.yellow( + `[Reuse] tests.reuse.enabled is true, but environment "${config.environment.name}" declares ` + + 'capabilities.playerReuse = false — running with reuse off for this environment.' + )); + return { enabled: false, maxPlayers: 4 }; + } + + const maxPlayers = config.tests.reuse?.maxPlayers + ?? Math.max(1, (env.accounts?.()?.capacity() ?? 5) - 1); + return { enabled: true, maxPlayers }; +} + export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): Promise { const testFileFilters = config.tests.include ?? null; const testNameFilters = config.tests.names ?? null; @@ -118,7 +139,11 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): const testResults: TestResult[] = []; const env = await resolveEnvironment(config.environment); - const session = new Session(env, config.journal ?? null); + const reuse = resolveReuse(config, env); + const session = new Session(env, config.journal ?? null, reuse.maxPlayers); + if (reuse.enabled) { + console.log(pc.dim(`[Reuse] enabled, maxPlayers=${reuse.maxPlayers}`)); + } const plugins = new PluginHost(); await plugins.load(config.plugins ?? []); // Must happen before the first spec file is imported — see PluginHost.registerMatchers. @@ -160,7 +185,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): /** 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 { + async function runFile(file: string, pluginName: string | null, forceReuseOff: boolean = false): Promise { resetRegistry(); await import(pathToFileURL(file).href); @@ -172,17 +197,20 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): continue; } - const result = await runTestCase({ file, testCase, session, plugins, connOpts, timeoutMs, pluginName }); + const result = await runTestCase({ + file, testCase, session, plugins, connOpts, timeoutMs, pluginName, + reuseEnabled: reuse.enabled, forceReuseOff, + }); 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')) { + for (const { file, pluginName, reuse: fileReuse } 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); + await runFile(file, pluginName, fileReuse === false); 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'}`); @@ -211,17 +239,27 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): } // Suite: plugin tests that run alongside user specs, tagged with the plugin's name. - for (const { file, pluginName } of plugins.testFiles('suite')) { + for (const { file, pluginName, reuse: fileReuse } of plugins.testFiles('suite')) { console.log(`\n${pc.blue(pc.bold(`Running tests from: ${file} ${pc.dim(`(plugin ${pluginName})`)}`))}`); - await runFile(file, pluginName); + await runFile(file, pluginName, fileReuse === false); } } finally { await plugins.runCleanup(session, 'session'); await plugins.teardown(); + // Registry-owned bots first: it disconnects and forgets each entry, so the plain + // sweep after it only has to deal with whatever was never handed to the registry. + await session.players.disconnectAll(); await session.disconnectAllBots(); await env.teardown(); + if (reuse.enabled) { + const reusedCount = testResults.filter(r => r.reuse?.reused).length; + if (reusedCount > 0) { + console.log(pc.dim(`[Reuse] ${reusedCount} test(s) reused an existing connection instead of reconnecting`)); + } + } + if (config.reports?.json) { writeJsonReport(config.reports.json, config.environment.name, testResults); console.log(pc.dim(`JSON report: ${config.reports.json}`)); From 44110b5b5ac2a52fb0234a2a86c4a5192bb932c3 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 22:45:02 +0300 Subject: [PATCH 049/125] feat(reuse): let a reused player leave the server between tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse held a bot connected from the test that created it until the run ended, which is not something a server that kicks an idle player allows. The only way out was to switch reuse off entirely, and with it the account, the nick and the ability labels — none of which have anything to do with idling. `stay` splits the two apart. Left alone it is `true` and nothing changes. `false` parks the registry entry instead of holding its connection: the bot leaves at the end of every test, the entry keeps the identity, and the next test matched to it gets a `rejoin()` first. What carries over is the identity, not the connection. It is settable for the run (`tests.reuse.stay`, `reuse { stay }`, PLUGWRIGHT_REUSE_STAY) and for one test (`reuse: { stay }`). An environment forces it with `capabilities.playerReuse = 'rejoin'`, a middle value between the `true` and `false` that field already had, for a server that objects to an idle bot but not to a reused one. Neither the config nor a test overrides that, the same way `false` already outranks `tests.reuse.enabled`. The registry now calls `env.beforeJoin()` before a rejoin, which it never did. A rejoin skipping an external server's join throttle was easy to miss while it happened once in a run on a dead connection; under `stay: false` it happens on every test. --- docs/configuration.mdx | 11 ++++- docs/custom-modes.mdx | 7 +-- docs/external-servers.mdx | 2 +- docs/reports.mdx | 4 +- docs/test-filtering.mdx | 2 +- docs/writing-tests.mdx | 11 +++++ .../plugwright/PlugwrightCorePlugin.kt | 2 + .../drownek/plugwright/PlugwrightExtension.kt | 2 +- .../plugwright/PlugwrightMatrixTask.kt | 2 + .../drownek/plugwright/PlugwrightTestTask.kt | 7 +++ .../kotlin/me/drownek/plugwright/ReuseSpec.kt | 8 +++ .../me/drownek/plugwright/RunnerLauncher.kt | 4 ++ runner-package/lib/config.ts | 36 ++++++++++---- runner-package/lib/environment.ts | 10 ++-- runner-package/lib/player-registry.ts | 49 +++++++++++++++---- runner-package/lib/test-registry.ts | 4 +- runner-package/lib/test-runner.ts | 36 ++++++++++---- runner-package/lib/types.ts | 5 +- runner-package/runner.ts | 37 ++++++++++---- 19 files changed, 184 insertions(+), 55 deletions(-) diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 91d89a0..f892a59 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -274,17 +274,20 @@ matrix { ``` - Settings for reusing a connected bot across test boundaries instead of reconnecting for every test. `enabled` is `false` by default — an existing suite that depends on a fresh player per test keeps working unchanged until it opts in. `maxPlayers` caps live registry entries; unset falls back to 4, or the environment's account pool capacity minus one when it has a pool. + Settings for reusing a connected bot across test boundaries instead of reconnecting for every test. `enabled` is `false` by default — an existing suite that depends on a fresh player per test keeps working unchanged until it opts in. `maxPlayers` caps live registry entries; unset falls back to 4, or the environment's account pool capacity minus one when it has a pool. `stay` decides whether a reused bot keeps its connection between the tests that borrow it; `true` by default. ```kotlin reuse { enabled.set(true) maxPlayers.set(4) + stay.set(true) } ``` -`PLUGWRIGHT_REUSE=1` / `PLUGWRIGHT_REUSE=0` overrides `reuse.enabled` from the environment, for trying it in a dev loop without editing a committed build script. See [Writing Tests](/writing-tests) for the test-side API. +`stay.set(false)` keeps the reuse but drops the parking: at the end of every test the bot leaves the server, and the entry it came from — same account, same nick, same ability labels — waits offline until a later test takes it and rejoins under that identity. What carries over is the identity, not the connection. That's the only form of reuse a server which kicks idle players allows, and it's what an environment declaring `capabilities.playerReuse = 'rejoin'` forces regardless of this setting. A single test can override it with `reuse: { stay }` — see [Writing Tests](/writing-tests). + +`PLUGWRIGHT_REUSE=1` / `PLUGWRIGHT_REUSE=0` overrides `reuse.enabled` from the environment, and `PLUGWRIGHT_REUSE_STAY` does the same for `reuse.stay`, for trying either in a dev loop without editing a committed build script. Per-environment, inside `create(...) { }`: @@ -326,4 +329,8 @@ plugins { Overrides `reuse.enabled` for this run: `1`/`true` turns it on, `0`/`false` turns it off. Unset, or any other value, leaves the build script's own setting alone. + + Overrides `reuse.stay` for this run, same `1`/`true` and `0`/`false` spelling. `0` keeps reuse on but sends each bot off the server at the end of every test, rejoining it when a later test takes its entry. Ignored when reuse itself is off. + + diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx index 37e336a..d871026 100644 --- a/docs/custom-modes.mdx +++ b/docs/custom-modes.mdx @@ -152,8 +152,9 @@ class VelocityEnvironment implements Environment { arbitraryUsernames: true, lifecycle: true, cleanupStrategy: 'compensating', - // Absent means "allowed". Set this to false if a bot sitting connected between - // tests would break the environment (an idle-kick timeout, a per-test world reset). + // Absent means "allowed". `false` if a bot surviving a test boundary at all would + // break the environment (a per-test world reset); 'rejoin' if only a bot *sitting* + // there is the problem (an idle-kick timeout, an AFK check). playerReuse: true, }; @@ -166,7 +167,7 @@ class VelocityEnvironment implements Environment { } ``` -Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. `playerReuse: false` overrides `tests.reuse.enabled` for the whole run against this environment — set it when a long-lived bot would break something the environment can't tell tests about any other way. +Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. `playerReuse: false` overrides `tests.reuse.enabled` for the whole run against this environment — set it when a long-lived bot would break something the environment can't tell tests about any other way. `playerReuse: 'rejoin'` is the softer form for a server that only objects to an *idle* bot: reuse stays on, but every entry leaves at the end of its test and rejoins when a later one takes it, and no `tests.reuse.stay` or per-test `reuse: { stay: true }` can talk it out of that. `accounts()` and `beforeJoin()` are optional. Returning no pool means every bot gets a throwaway `Test_` username, which is what `local` does. diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index 4e211e6..ae078f0 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -98,7 +98,7 @@ A leased account comes back with the previous test's inventory, balance and op s With `tests.reuse` on ([Configuration](/configuration)), a registry entry holds its leased account for as long as the entry lives, not just for one test — an account checked out by a long-lived player doesn't return to the pool until that player is evicted, invalidated, or the run ends. Size `accounts { }` accordingly: `maxPlayers` defaults to the pool's capacity minus one so a test's own `createPlayer()` still has a spare slot, but a pool exactly as big as `maxPlayers` leaves nothing free for it. -An environment that can't tolerate a bot sitting connected between tests — an idle-kick timeout, a per-test world reset — should report `capabilities.playerReuse = false` after `setup()` rather than let reuse quietly misbehave. See [Writing a Mode](/custom-modes). +An environment that can't tolerate a bot surviving a test boundary at all — a per-test world reset — should report `capabilities.playerReuse = false` after `setup()` rather than let reuse quietly misbehave. When the problem is narrower than that, and it usually is on a public server, `capabilities.playerReuse = 'rejoin'` keeps the reuse and drops the idling: the bot leaves at the end of every test and rejoins under the same account when a later test takes its entry. `tests.reuse.stay = false` asks for the same thing from the config side. Either way the account stays leased while the entry is parked, so pool sizing doesn't change. See [Writing a Mode](/custom-modes). ## Checking the stand before you test diff --git a/docs/reports.mdx b/docs/reports.mdx index 2b3432e..710ef2e 100644 --- a/docs/reports.mdx +++ b/docs/reports.mdx @@ -26,7 +26,7 @@ build/reports/plugwright/.log per-environment output, matrix runs o "error": null, "skipReason": null, "plugin": null, - "reuse": { "key": "auto:[]!()", "reused": true, "abilities": [] } + "reuse": { "key": "auto:[]!()", "reused": true, "stay": true, "abilities": [] } }, { "file": "…/dist/simple-ts.spec.js", @@ -44,7 +44,7 @@ build/reports/plugwright/.log per-environment output, matrix runs o `status` is `pass`, `fail` or `skip`. `plugin` names the plugin a test came from when it was inherited rather than found in your test directory. -`reuse` is `null` when `tests.reuse` is off for the run. Otherwise it's always present, even for a test that opted out with `reuse: false` (`reused: false`, `key: "none"`). `reused: true` means the player came from an earlier test instead of a fresh connection; `abilities` is the label set it was matched against. See [Writing Tests](/writing-tests). +`reuse` is `null` when `tests.reuse` is off for the run. Otherwise it's always present, even for a test that opted out with `reuse: false` (`reused: false`, `key: "none"`). `reused: true` means the player came from an earlier test instead of a fresh connection; `abilities` is the label set it was matched against; `stay` is whether it kept its connection after this test or was parked offline until a later test rejoins it. See [Writing Tests](/writing-tests). Every skip carries its reason: excluded by name, wrong environment, or a capability the environment doesn't have. A skipped test that doesn't say why is worse than a failing one, because it reads as coverage. diff --git a/docs/test-filtering.mdx b/docs/test-filtering.mdx index 2e61c88..817eb33 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -79,7 +79,7 @@ test('the /debug dev command', { environments: ['local'] }, async ({ player }) = ## Reuse is a different axis -`requires` and `environments` decide whether a test runs at all. `reuse` (see [Writing Tests](/writing-tests)) decides which bot it gets once it's already running — a filter never skips a test because of its `reuse` option. The two do interact on one environment setting: `capabilities.playerReuse === false` disables reuse for the whole environment without affecting anything a test declared through `requires`. +`requires` and `environments` decide whether a test runs at all. `reuse` (see [Writing Tests](/writing-tests)) decides which bot it gets once it's already running — a filter never skips a test because of its `reuse` option. The two do interact on one environment setting: `capabilities.playerReuse` disables reuse for the whole environment when it's `false`, or forces every player off the server between tests when it's `'rejoin'` — neither affects anything a test declared through `requires`. ## Skips are reported diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index c2f5972..c3cdcbe 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -153,11 +153,22 @@ export interface ReuseOptions { abilities?: string[]; // player must carry all of these excludeAbilities?: string[]; // player must carry none of these strict?: boolean; // player's labels must equal `abilities` exactly, no extras + stay?: boolean; // keep the connection after this test, or park the entry offline } ``` `reuse` on `test()` accepts `false`, a string (shorthand for `{ key }`), or a `ReuseOptions` object. `ctx.createPlayer({ reuse: … })` takes the same shape for any secondary player a test creates. +`stay` is the one option that describes what happens *after* the test rather than which player it gets. Left alone it follows the run's `tests.reuse.stay` (`true` by default): the bot stays on the server, and the next test that matches it skips connecting entirely. `stay: false` sends it off at the end of this test and keeps only the entry — the account, the nick and the labels — so a later test gets the same identity back through a rejoin: + +```typescript +test('slow inventory walk', { reuse: { stay: false } }, async ({ player }) => { + // the bot leaves when this test ends; the next test to want this player rejoins it +}); +``` + +Reach for it when a parked bot is the problem — an idle-kick timeout, an AFK check, a server that counts online players. Everything is disconnected at the end of the run either way. An environment can force it for every test by declaring `capabilities.playerReuse = 'rejoin'`, and then `stay: true` here doesn't lift it. + A test that cares about a clean nick, the absence of a label, or a first-registration flow declares `reuse: false` (or the right `excludeAbilities`) explicitly — reuse never guesses on a test's behalf. ```typescript 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 2b3aa20..4f3b717 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 @@ -213,6 +213,7 @@ class PlugwrightCorePlugin : Plugin { if (extension.reuse.enabled.get()) { reuseEnabled.set(true) extension.reuse.maxPlayers.orNull?.let { reuseMaxPlayers.set(it) } + reuseStay.set(extension.reuse.stay) } if (project.hasProperty("testFiles")) testFiles.set(project.property("testFiles") as String) @@ -267,6 +268,7 @@ class PlugwrightCorePlugin : Plugin { runtimeExport = runtimeRef?.export, reuseEnabled = extension.reuse.enabled.get().takeIf { it }, reuseMaxPlayers = extension.reuse.maxPlayers.orNull, + reuseStay = extension.reuse.stay.orNull, ) ctx.prepareTaskRef?.let { matrixPrepareTasks += it } } 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 a7fdd41..4dc1c4e 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 @@ -62,7 +62,7 @@ abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperti /** Settings for reusing a connected bot across test boundaries. See [reuse]. */ val reuse: ReuseSpec = project.objects.newInstance(ReuseSpec::class.java) - /** Configures reuse: `reuse { enabled.set(true); maxPlayers.set(4) }`. */ + /** Configures reuse: `reuse { enabled.set(true); maxPlayers.set(4); stay.set(true) }`. */ fun reuse(action: ReuseSpec.() -> Unit) { reuse.action() } 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 9e7c4f0..fc4f107 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 @@ -32,6 +32,7 @@ internal data class MatrixEnvironmentInput( val runtimeExport: String? = null, val reuseEnabled: Boolean? = null, val reuseMaxPlayers: Int? = null, + val reuseStay: Boolean? = null, ) private data class EnvironmentSummary(val total: Int, val passed: Int, val failed: Int, val skipped: Int, val durationMs: Long) @@ -132,6 +133,7 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { runtimeExport = env.runtimeExport, reuseEnabled = env.reuseEnabled, reuseMaxPlayers = env.reuseMaxPlayers, + reuseStay = env.reuseStay, ) RunnerLauncher.writeConfig(entry) val cliJs = RunnerLauncher.resolveCliJs(env.workspaceDir) 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 5fa44ba..06d81ac 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 @@ -60,6 +60,12 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { @get:Optional abstract val reuseMaxPlayers: Property + /** From `plugwright.reuse.stay`. Unset means "runner default" — the bot stays connected + * between the tests that borrow it. */ + @get:Input + @get:Optional + abstract val reuseStay: Property + /** * 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 @@ -143,6 +149,7 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { runtimeExport = runtimeExport.orNull, reuseEnabled = reuseEnabled.orNull, reuseMaxPlayers = reuseMaxPlayers.orNull, + reuseStay = reuseStay.orNull, ) RunnerLauncher.writeConfig(entry) logger.lifecycle("Runner config: ${configDestination.absolutePath}") diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt index b3c40fc..ba075f3 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt @@ -18,7 +18,15 @@ abstract class ReuseSpec { * environment's account pool capacity minus one when it has a pool. */ abstract val maxPlayers: Property + /** Whether a reused bot keeps its connection between the tests that borrow it. On by + * default, which is what reuse meant before this existed. `false` keeps the identity — + * account, nick, ability labels — but drops the connection at the end of every test and + * rejoins when a later one takes it: the only form of reuse a server that kicks idle bots + * allows. A single test can still override this with `reuse: { stay }`. */ + abstract val stay: Property + init { enabled.convention(false) + stay.convention(true) } } 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 ef16307..e699fe8 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 @@ -42,6 +42,9 @@ object RunnerLauncher { val reuseEnabled: Boolean? = null, /** null means "runner default" — only meaningful when [reuseEnabled] is true. */ val reuseMaxPlayers: Int? = null, + /** Whether a reused bot stays connected between tests; null means "runner default" + * (true). Only meaningful when [reuseEnabled] is true. */ + val reuseStay: Boolean? = null, ) fun writeConfig(entry: Entry) { @@ -74,6 +77,7 @@ object RunnerLauncher { obj("reuse") { put("enabled", entry.reuseEnabled) entry.reuseMaxPlayers?.let { put("maxPlayers", it) } + entry.reuseStay?.let { put("stay", it) } } } } diff --git a/runner-package/lib/config.ts b/runner-package/lib/config.ts index 9193a26..57e8c4b 100644 --- a/runner-package/lib/config.ts +++ b/runner-package/lib/config.ts @@ -40,6 +40,13 @@ export interface ReuseConfig { * one when the environment has a pool — one slot is kept free for a test's own * `createPlayer()` call. */ maxPlayers?: number | null; + /** Whether a reused bot keeps its connection between the tests that borrow it. Defaults to + * `true`, which is what reuse meant before this setting existed. `false` parks each entry + * instead — the identity (account, nick, ability labels) is what carries over, and the bot + * rejoins when a later test takes it. A single test can override this through + * `reuse: { stay }`; an environment declaring `playerReuse: 'rejoin'` can't be overridden + * upwards by either. */ + stay?: boolean | null; } export interface TestsConfig { @@ -218,18 +225,27 @@ function loadDefaultOrLegacyConfig(): RunnerConfig { } } -/** `PLUGWRIGHT_REUSE` overrides `tests.reuse.enabled` from a committed config file — the - * toggle for a dev's own edit loop, so reuse never has to live in a checked-in build script - * just to be tried locally. `1`/`true` enables it, `0`/`false` disables it; anything else, or - * unset, leaves the config's own value alone. */ +/** `1`/`true` and `0`/`false`; anything else, including unset, reads as "not specified". */ +function booleanEnv(raw: string | undefined): boolean | null { + if (raw === undefined) return null; + if (raw === '1' || raw.toLowerCase() === 'true') return true; + if (raw === '0' || raw.toLowerCase() === 'false') return false; + return null; +} + +/** `PLUGWRIGHT_REUSE` and `PLUGWRIGHT_REUSE_STAY` override `tests.reuse` from a committed config + * file — the toggle for a dev's own edit loop, so neither reuse nor the choice between a parked + * and a rejoining bot has to live in a checked-in build script just to be tried locally. */ function applyReuseEnvOverride(config: RunnerConfig): void { - const raw = process.env.PLUGWRIGHT_REUSE; - if (raw === undefined) return; - const enabled = raw === '1' || raw.toLowerCase() === 'true'; - const disabled = raw === '0' || raw.toLowerCase() === 'false'; - if (!enabled && !disabled) return; + const enabled = booleanEnv(process.env.PLUGWRIGHT_REUSE); + const stay = booleanEnv(process.env.PLUGWRIGHT_REUSE_STAY); + if (enabled === null && stay === null) return; - config.tests.reuse = { ...config.tests.reuse, enabled }; + config.tests.reuse = { + ...config.tests.reuse, + enabled: enabled ?? config.tests.reuse?.enabled ?? false, + ...(stay === null ? {} : { stay }), + }; } /** True when [value] is a secret pointer rather than a plain value. */ diff --git a/runner-package/lib/environment.ts b/runner-package/lib/environment.ts index bef610f..a578c76 100644 --- a/runner-package/lib/environment.ts +++ b/runner-package/lib/environment.ts @@ -12,9 +12,13 @@ export interface EnvironmentCapabilities { arbitraryUsernames: boolean; lifecycle: boolean; cleanupStrategy: 'wipe' | 'compensating' | 'none'; - /** Absent means "allowed". An environment that breaks under a bot that stays connected - * across tests (an idle-kick timeout, a world reset between tests) sets this to `false`. */ - playerReuse?: boolean; + /** Absent or `true` means "allowed". `false` turns reuse off here entirely — an environment + * that breaks under a bot surviving a test boundary at all (a world reset between tests). + * `'rejoin'` is the middle ground for a server that only objects to the bot *sitting* there: + * entries are reused, but each one leaves at the end of its test and rejoins when a later + * test takes it. It caps `tests.reuse.stay` — a test asking for `stay: true` still gets a + * rejoin, the same way `false` outranks the config today. */ + playerReuse?: boolean | 'rejoin'; } export interface BotConnectionOptions { diff --git a/runner-package/lib/player-registry.ts b/runner-package/lib/player-registry.ts index 4345ece..255f25a 100644 --- a/runner-package/lib/player-registry.ts +++ b/runner-package/lib/player-registry.ts @@ -16,6 +16,11 @@ export interface ReuseOptions { excludeAbilities?: string[]; /** The player's label set must equal `abilities` exactly — no extras allowed. */ strict?: boolean; + /** Whether the bot keeps its connection once the test that borrowed it finishes. `false` + * parks the entry instead: the account, the nick and the labels survive, the connection + * doesn't, and a later test that takes the entry gets a `rejoin()` first. That's the shape + * a server which kicks an idle bot needs. Defaults to the run's `tests.reuse.stay`. */ + stay?: boolean; } export interface ConnectedPlayer { @@ -37,6 +42,9 @@ interface RegistryEntry { pool: AccountPool | null; /** Held by the current test: not handed out a second time, not evicted by LRU. */ checkedOut: boolean; + /** Released with `stay: false`, so the bot left the server but the entry stayed. Checking + * it out again rejoins first. */ + parked: boolean; lastUsedAt: number; } @@ -64,6 +72,11 @@ function matches(entry: RegistryEntry, options: ReuseOptions): boolean { * * `resolve()` never connects a bot itself; it calls the `connect` callback it's given, so the * caller keeps ownership of connection options, throttling and account leasing. + * + * An entry surviving a test boundary does not have to stay *connected* across it: released with + * `stay: false`, it parks — the bot leaves, the identity (account, nick, labels) stays, and the + * next test to take the entry gets a rejoin. What's reused there is the identity, not the + * connection, which is the only form of reuse a server that kicks idle bots allows. */ export class PlayerRegistry { private readonly entries: RegistryEntry[] = []; @@ -110,12 +123,20 @@ export class PlayerRegistry { return this.createEntry(derivedKey(options), connect); } - /** Returns a checked-out entry to the free pool. No-op for a player this registry doesn't own. */ - release(player: PlayerWrapper): void { + /** Returns a checked-out entry to the free pool. `stay: false` parks it on the way out — + * the connection goes, the entry stays, and the next checkout rejoins it. No-op for a + * player this registry doesn't own. */ + async release(player: PlayerWrapper, stay: boolean = true): Promise { const entry = this.entries.find(e => e.player === player); if (!entry) return; entry.checkedOut = false; entry.lastUsedAt = Date.now(); + + if (stay || entry.parked) return; + entry.parked = true; + console.log(pc.dim(`[Reuse] ${entry.player.username} parked (stay: false — rejoins when a later test takes it)`)); + await this.session.disconnectBot(entry.player.bot, entry.player.username); + this.session.removeBot(entry.player.bot); } /** Drops a broken or disqualified entry: disconnects it, returns its account, forgets it. @@ -130,29 +151,37 @@ export class PlayerRegistry { for (const entry of [...this.entries]) await this.drop(entry, 'session teardown'); } - /** A dead connection is transparently rejoined before it's handed out — a bot picked back - * up by the registry is otherwise indistinguishable from one that's still live, and the - * test has no reason to expect it might not be. A failed rejoin falls back to a fresh - * entry under the same key, same as a first-time miss. */ + /** An entry that isn't connected — parked on purpose, or dropped by the server — is + * transparently rejoined before it's handed out: a bot picked back up by the registry is + * otherwise indistinguishable from one that's still live, and the test has no reason to + * expect it might not be. A failed rejoin falls back to a fresh entry under the same key, + * same as a first-time miss. */ private async checkout(entry: RegistryEntry, connect: () => Promise): Promise { - if ((entry.player.bot as any)._client?.ended) { + const parked = entry.parked; + if (parked || (entry.player.bot as any)._client?.ended) { try { + // The same gate a first connection passes through: a rejoin is just another + // login as far as a shared server's join throttle is concerned, and `stay: false` + // turns every single test into one. + await this.session.env.beforeJoin?.(); await entry.player.rejoin(); } catch (error) { - await this.drop(entry, `dead connection, rejoin failed: ${(error as Error).message}`); + await this.drop(entry, `${parked ? 'parked' : 'dead connection'}, rejoin failed: ${(error as Error).message}`); return this.createEntry(entry.key, connect); } + entry.parked = false; } entry.checkedOut = true; const labels = [...entry.player.abilities].join(', ') || '-'; - console.log(pc.dim(`[Reuse] ${entry.player.username} from registry (key "${entry.key}", abilities: ${labels})`)); + const how = parked ? 'from registry, rejoined' : 'from registry'; + console.log(pc.dim(`[Reuse] ${entry.player.username} ${how} (key "${entry.key}", abilities: ${labels})`)); return { player: entry.player, key: entry.key, reused: true }; } private async createEntry(key: string, connect: () => Promise): Promise { const { player, account, pool } = await connect(); - this.entries.push({ key, player, account, pool, checkedOut: true, lastUsedAt: Date.now() }); + this.entries.push({ key, player, account, pool, checkedOut: true, parked: false, lastUsedAt: Date.now() }); console.log(pc.dim(`[Reuse] ${player.username} new player (key "${key}")`)); return { player, key, reused: false }; } diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 1245aec..9f66934 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -18,7 +18,9 @@ export interface TestOptions { /** How this test wants its player resolved when `tests.reuse` is on. `false` forces a * fresh connection regardless of the run's reuse setting — for a test that depends on a * brand-new nick or the absence of a label another test might have left behind. A string - * is shorthand for `{ key }`. Omitted means "match by ability labels", the default. */ + * is shorthand for `{ key }`. Omitted means "match by ability labels", the default. + * `{ stay }` decides whether the player this test used keeps its connection afterwards, + * overriding the run's `tests.reuse.stay` for this test alone. */ reuse?: false | string | ReuseOptions; } diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index 76c892f..7018c38 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -25,6 +25,11 @@ export interface RunTestCaseParams { * `capabilities.playerReuse`. `false` reproduces the pre-reuse behavior exactly, down to * the absence of `TestResult.reuse`. */ reuseEnabled?: boolean; + /** Whole-run default for `ReuseOptions.stay`: does a registry player keep its connection + * once this test is done, or does it park until a later test rejoins it. `'rejoin'` is the + * environment's own `capabilities.playerReuse` saying it can't hold an idle bot at all — + * a test asking for `stay: true` doesn't get to lift that. */ + reuseStay?: boolean | 'rejoin'; /** Set for a plugin test file declaring `PluginTestRef.reuse === false` — forces a fresh * connection for every test in the file regardless of `reuseEnabled` or the test's own * `reuse` option. */ @@ -48,7 +53,7 @@ function normalizeReuse(reuse: false | string | ReuseOptions | undefined): false * behavior. */ export async function runTestCase(params: RunTestCaseParams): Promise { - const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null, reuseEnabled = false, forceReuseOff = false } = params; + const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null, reuseEnabled = false, reuseStay = true, forceReuseOff = false } = params; console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); session.consoleLog.clear(); @@ -60,11 +65,17 @@ export async function runTestCase(params: RunTestCaseParams): Promise = []; - // Players this test drew from the registry, so `finally` knows what to release or drop. - const registryPlayers: PlayerWrapper[] = []; + // Players this test drew from the registry, with the `stay` each was taken under, so + // `finally` knows what to release, what to park and what to drop. + const registryPlayers: Array<{ player: PlayerWrapper; stay: boolean }> = []; const invalidated = new Set(); const reuseEffective = reuseEnabled && !forceReuseOff; - let primaryReuse: { key: string; reused: boolean } | null = null; + let primaryReuse: { key: string; reused: boolean; stay: boolean } | null = null; + + /** The run's `stay` unless the request overrides it — except under `'rejoin'`, where the + * environment has said an idle bot doesn't survive and no test gets to disagree. */ + const stayFor = (options: ReuseOptions): boolean => + reuseStay === 'rejoin' ? false : options.stay ?? reuseStay; // The actual connect: leases an account (or generates a throwaway identity), joins the // server, and returns the wrapper. Used directly for a fresh connection, and passed to @@ -121,7 +132,7 @@ export async function runTestCase(params: RunTestCaseParams): Promise connectNewPlayer()); - registryPlayers.push(result.player); + registryPlayers.push({ player: result.player, stay: stayFor(normalized) }); if (result.reused) { // Core's own safe minimum for a player coming back from a previous test — anything @@ -142,7 +153,10 @@ export async function runTestCase(params: RunTestCaseParams): Promise { return results; } -/** `tests.reuse.enabled`, narrowed by the environment's own `capabilities.playerReuse`. An - * environment that can't tolerate a long-lived bot always wins over the config. */ -function resolveReuse(config: RunnerConfig, env: Environment): { enabled: boolean; maxPlayers: number } { +/** `tests.reuse`, narrowed by the environment's own `capabilities.playerReuse`. An environment + * that can't tolerate a long-lived bot always wins over the config — outright when it declares + * `false`, and down to a rejoin per test when it declares `'rejoin'`. */ +function resolveReuse(config: RunnerConfig, env: Environment): { enabled: boolean; maxPlayers: number; stay: boolean | 'rejoin' } { const requested = config.tests.reuse?.enabled ?? false; - if (!requested) return { enabled: false, maxPlayers: 4 }; + if (!requested) return { enabled: false, maxPlayers: 4, stay: true }; if (env.capabilities.playerReuse === false) { console.log(pc.yellow( `[Reuse] tests.reuse.enabled is true, but environment "${config.environment.name}" declares ` + 'capabilities.playerReuse = false — running with reuse off for this environment.' )); - return { enabled: false, maxPlayers: 4 }; + return { enabled: false, maxPlayers: 4, stay: true }; } const maxPlayers = config.tests.reuse?.maxPlayers ?? Math.max(1, (env.accounts?.()?.capacity() ?? 5) - 1); - return { enabled: true, maxPlayers }; + + if (env.capabilities.playerReuse === 'rejoin') { + if (config.tests.reuse?.stay ?? true) { + console.log(pc.yellow( + `[Reuse] environment "${config.environment.name}" declares capabilities.playerReuse = 'rejoin' — ` + + 'players leave at the end of every test and rejoin when a later one takes them, whatever tests.reuse.stay says.' + )); + } + return { enabled: true, maxPlayers, stay: 'rejoin' }; + } + + return { enabled: true, maxPlayers, stay: config.tests.reuse?.stay ?? true }; } export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): Promise { @@ -142,7 +154,8 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): const reuse = resolveReuse(config, env); const session = new Session(env, config.journal ?? null, reuse.maxPlayers); if (reuse.enabled) { - console.log(pc.dim(`[Reuse] enabled, maxPlayers=${reuse.maxPlayers}`)); + const stayLabel = reuse.stay === 'rejoin' ? "stay=false (environment's own 'rejoin')" : `stay=${reuse.stay}`; + console.log(pc.dim(`[Reuse] enabled, maxPlayers=${reuse.maxPlayers}, ${stayLabel}`)); } const plugins = new PluginHost(); await plugins.load(config.plugins ?? []); @@ -199,7 +212,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): const result = await runTestCase({ file, testCase, session, plugins, connOpts, timeoutMs, pluginName, - reuseEnabled: reuse.enabled, forceReuseOff, + reuseEnabled: reuse.enabled, reuseStay: reuse.stay, forceReuseOff, }); testResults.push(result); } @@ -256,7 +269,13 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): if (reuse.enabled) { const reusedCount = testResults.filter(r => r.reuse?.reused).length; if (reusedCount > 0) { - console.log(pc.dim(`[Reuse] ${reusedCount} test(s) reused an existing connection instead of reconnecting`)); + // Under `stay: false` the connection is not what carried over — the identity is, + // and the bot rejoined under it — so the count is worth reporting either way. + const rejoined = testResults.filter(r => r.reuse?.reused && !r.reuse.stay).length; + const how = rejoined === reusedCount ? 'a registry player, rejoined' + : rejoined > 0 ? `a registry player (${rejoined} of them rejoined)` + : 'an existing connection instead of reconnecting'; + console.log(pc.dim(`[Reuse] ${reusedCount} test(s) reused ${how}`)); } } From c8cead61039541fa12a55ab480e08bd2f69c1cac Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 17 Aug 2026 01:11:26 +0300 Subject: [PATCH 050/125] feat(reuse): add reuseTest pool-initialization hooks Registers a one-time setup step per reuse pool (the same string used as reuse: 'pool' / { key: 'pool' }). PlayerRegistry.resolve/createEntry now take an onFreshEntry callback, fired only when an entry is actually (re)built - first-ever request for a key, or a rebuild after a drop - never on a plain checkout of an already-live entry. A rejected onFreshEntry discards the half-built entry so the next attempt retries initialization instead of handing out a broken player. --- runner-package/lib/player-registry.ts | 33 ++++++++++++++++++++++---- runner-package/lib/test-registry.ts | 34 ++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/runner-package/lib/player-registry.ts b/runner-package/lib/player-registry.ts index 255f25a..cfb4d37 100644 --- a/runner-package/lib/player-registry.ts +++ b/runner-package/lib/player-registry.ts @@ -94,14 +94,22 @@ export class PlayerRegistry { return this.entries.map(e => e.player.bot); } - async resolve(options: ReuseOptions, connect: () => Promise): Promise { + /** `onFreshEntry` fires exactly when this call ends up creating a brand-new entry — + * first-ever request for a key, or a rebuild after a drop — never on a plain checkout of + * an entry that's already live. A rejected `onFreshEntry` discards the entry it just built, + * same as a broken connection would, and the rejection propagates to the caller. */ + async resolve( + options: ReuseOptions, + connect: () => Promise, + onFreshEntry?: (key: string, player: PlayerWrapper) => Promise, + ): Promise { if (options.key) { const existing = this.entries.find(e => e.key === options.key); if (existing) { if (matches(existing, options)) return this.checkout(existing, connect); await this.drop(existing, `abilities don't match a new request for key "${options.key}"`); } - return this.createEntry(options.key, connect); + return this.createEntry(options.key, connect, onFreshEntry); } const free = this.entries.find(e => !e.checkedOut && matches(e, options)); @@ -120,7 +128,7 @@ export class PlayerRegistry { await this.drop(victim, `evicted: maxPlayers=${this.maxPlayers} reached`); } - return this.createEntry(derivedKey(options), connect); + return this.createEntry(derivedKey(options), connect, onFreshEntry); } /** Returns a checked-out entry to the free pool. `stay: false` parks it on the way out — @@ -179,10 +187,25 @@ export class PlayerRegistry { return { player: entry.player, key: entry.key, reused: true }; } - private async createEntry(key: string, connect: () => Promise): Promise { + private async createEntry( + key: string, + connect: () => Promise, + onFreshEntry?: (key: string, player: PlayerWrapper) => Promise, + ): Promise { const { player, account, pool } = await connect(); - this.entries.push({ key, player, account, pool, checkedOut: true, parked: false, lastUsedAt: Date.now() }); + const entry: RegistryEntry = { key, player, account, pool, checkedOut: true, parked: false, lastUsedAt: Date.now() }; + this.entries.push(entry); console.log(pc.dim(`[Reuse] ${player.username} new player (key "${key}")`)); + + if (onFreshEntry) { + try { + await onFreshEntry(key, player); + } catch (error) { + await this.drop(entry, `reuseTest failed: ${(error as Error).message}`); + throw error; + } + } + return { player, key, reused: false }; } diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 9f66934..5b471f0 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -46,9 +46,22 @@ export interface TestCase { export const testRegistry: TestCase[] = []; export const scopeStack: DescribeScope[] = [{ label: '', beforeHooks: [], afterHooks: [] }]; +/** A reuseTest's body, keyed by the reuse `key` ("pool") it initializes. */ +export interface ReuseTestCase { + pool: string; + name: string; + fn: TestFn; +} + +/** One reuseTest per pool, kept for the whole run rather than reset per spec file — + * `PlayerRegistry` entries live for the whole run too, so a pool declared in one file must + * still be found when a later file is the first to actually create that entry. */ +export const reuseTestRegistry = new Map(); + /** 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. */ + * as long as one process runs one environment and files run sequentially. `reuseTestRegistry` + * is deliberately NOT cleared here — see its own comment. */ export function resetRegistry(): void { testRegistry.length = 0; scopeStack.length = 0; @@ -100,6 +113,25 @@ export function opTest(name: string, fnOrOptions: TestFn | TestOptions, maybeFn? }); } +/** + * Registers a one-time initializer for a reuse pool. `pool` is the same string a test passes + * as `reuse: 'poolName'` (or `reuse: { key: 'poolName' }`) — `reuseTest` runs `fn` against that + * pool's player right when `PlayerRegistry` (re)creates its entry: the very first time any test + * asks for `poolName`, or later if that entry was dropped (rejoin failed, abilities stopped + * matching) and needs to be built again. It does NOT run on an ordinary checkout of an + * already-live entry — that's every other call, which is the common case. + * + * Runs as its own reported test, right before whichever test triggered the (re)creation. If + * `fn` throws, that test fails as a dependency failure and the entry is discarded, so the next + * attempt runs `reuseTest` again instead of handing out a half-initialized player. + */ +export function reuseTest(pool: string, fn: TestFn): void { + if (reuseTestRegistry.has(pool)) { + throw new Error(`reuseTest: pool "${pool}" is already registered (reuseTest can only be declared once per pool)`); + } + reuseTestRegistry.set(pool, { pool, name: `reuse:${pool}`, fn }); +} + export function describe(label: string, fn: () => void): void { scopeStack.push({ label, beforeHooks: [], afterHooks: [] }); try { From 702d9ee4f57080a2bd61ef4eb3836cd6341a8072 Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 17 Aug 2026 01:11:31 +0300 Subject: [PATCH 051/125] feat(reuse): run reuseTest as a reported dependency of its trigger test Wires PlayerRegistry's onFreshEntry into test-runner.ts: when a fresh registry entry is being built for a key with a registered reuseTest, run it with its own timeout/finalizers/plugin fixtures, report it as its own test result via onExtraResult (appended ahead of the test that triggered creation), and propagate failure so the dependent test fails too instead of the whole run crashing on an uncaught rejection. Exports reuseTest from the package's public API alongside test/opTest. --- runner-package/lib/test-runner.ts | 98 ++++++++++++++++++++++++++++--- runner-package/runner.ts | 3 +- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index 7018c38..04624c1 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -8,6 +8,7 @@ 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'; +import { reuseTestRegistry } from './test-registry.js'; import type { TestCase } from './test-registry.js'; import type { TestContext, TestResult } from './types.js'; import type { ConnectedPlayer, ReuseOptions } from './player-registry.js'; @@ -34,6 +35,10 @@ export interface RunTestCaseParams { * connection for every test in the file regardless of `reuseEnabled` or the test's own * `reuse` option. */ forceReuseOff?: boolean; + /** Reports a `reuseTest`'s own result, whenever one runs as a dependency of this test case. + * Called before `runTestCase` resolves, so the caller can append it to the report ahead of + * the test case's own result. */ + onExtraResult?: (result: TestResult) => void; } /** Normalizes a `TestOptions.reuse` value to what `PlayerRegistry.resolve` takes. */ @@ -53,7 +58,7 @@ function normalizeReuse(reuse: false | string | ReuseOptions | undefined): false * behavior. */ export async function runTestCase(params: RunTestCaseParams): Promise { - const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null, reuseEnabled = false, reuseStay = true, forceReuseOff = false } = params; + const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null, reuseEnabled = false, reuseStay = true, forceReuseOff = false, onExtraResult } = params; console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); session.consoleLog.clear(); @@ -112,6 +117,65 @@ export async function runTestCase(params: RunTestCaseParams): Promise => { + const reuseCase = reuseTestRegistry.get(poolKey); + if (!reuseCase) return; + + console.log(` ${pc.bold(`Test: ${reuseCase.name}`)}`); + const reuseAbort = new AbortController(); + const reuseFinalizers: Array<() => void | Promise> = []; + const reuseCtx: TestContext = { + player: reusePlayer, + server, + createPlayer, + invalidatePlayer: (p: PlayerWrapper) => { invalidated.add(p); }, + signal: reuseAbort.signal, + cleanup: (fn: () => void | Promise) => { reuseFinalizers.push(fn); }, + }; + plugins.extendContext(reuseCtx); + + const start = Date.now(); + let timeoutHandle: ReturnType; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reuseAbort.abort(); + reject(new Error(`reuseTest "${poolKey}" timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + const body = (async (): Promise => { + try { + await reuseCase.fn(reuseCtx); + } finally { + for (const finalizer of [...reuseFinalizers].reverse()) { + try { + await finalizer(); + } catch (e) { + console.error(pc.red(`[cleanup] reuseTest "${poolKey}" finalizer error: ${(e as Error).message}`)); + } + } + } + })().finally(() => clearTimeout(timeoutHandle)); + + try { + await Promise.race([body, timeoutPromise]); + const durationMs = Date.now() - start; + console.log(` ${pc.green(pc.bold('PASSED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}\n`); + onExtraResult?.({ file, testName: reuseCase.name, passed: true, durationMs, plugin: pluginName }); + } catch (error) { + const durationMs = Date.now() - start; + const errorMsg = (error as Error).message; + console.log(` ${pc.red(pc.bold('FAILED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}: ${pc.red(errorMsg)}\n`); + onExtraResult?.({ file, testName: reuseCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName }); + throw error; + } + }; + /** `ctx.player` and `ctx.createPlayer` both funnel through here. `usernameOverride` is * `createPlayer({ username })` — a specific identity, which always bypasses both the * account pool and the registry, same as before reuse existed. */ @@ -131,7 +195,11 @@ export async function runTestCase(params: RunTestCaseParams): Promise connectNewPlayer()); + const result = await session.players.resolve( + normalized, + () => connectNewPlayer(), + normalized.key ? (key, p) => runReuseTestCase(key, p) : undefined, + ); registryPlayers.push({ player: result.player, stay: stayFor(normalized) }); if (result.reused) { @@ -152,10 +220,27 @@ export async function runTestCase(params: RunTestCaseParams): Promise testResults.push(r), }); testResults.push(result); } From 6950497f73c91b8356390fca49555a5950aeb9c2 Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 17 Aug 2026 01:11:35 +0300 Subject: [PATCH 052/125] docs(writing-tests): document reuseTest --- docs/writing-tests.mdx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index c3cdcbe..8306c9a 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -180,6 +180,23 @@ test('cleans up on failure', async ({ player, invalidatePlayer }) => { `invalidatePlayer` marks a player unfit for the next test: it disconnects instead of being handed out again. The runner does this automatically for a test that fails or times out — one bad test shouldn't hand its mess to the next one. +### Initializing a reuse pool with `reuseTest` + +`reuseTest(pool, fn)` registers a one-time setup step for a named reuse pool (the same string used as `reuse: 'poolName'` or `reuse: { key: 'poolName' }`). It runs **only** when the pool's registry entry is actually (re)built — the first time any test asks for `'poolName'`, or later if that entry was dropped (a rejoin failed, abilities stopped matching) and needs to be created again. An ordinary checkout of an already-live entry never runs it: + +```typescript +reuseTest('shopkeeper', async ({ player }) => { + await player.chat('/vip add'); + player.mark('vip'); +}); + +test('shop shows vip discount', { reuse: 'shopkeeper' }, async ({ player }) => { + // guaranteed to run after 'shopkeeper' has been initialized at least once +}); +``` + +`reuseTest` is reported as its own test, listed right before whichever test triggered the (re)creation. If its body throws, that test fails too — the entry is discarded so the next attempt runs `reuseTest` again instead of handing out a half-initialized player. + ## Best Practices 1. **Keep tests isolated** - Each test gets a fresh bot, unless reuse is on From 9a095c4f4409bd167f91f7c77f60b7ac61c39e49 Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 17 Aug 2026 01:25:48 +0300 Subject: [PATCH 053/125] feat(reuse): give reuseTest the full TestOptions scope of a regular test reuseTest now takes (pool, fn) or (pool, options, fn) with the same requires/environments TestOptions carries, plus describe nesting for its name and beforeEach/afterEach hooks - same scope as test/opTest, minus reuse itself (a reuseTest initializes a pool, it doesn't resolve into one). requires/environments skip it exactly like a regular test: reported skipped, fn never runs, no error - a player handed out under a pool whose reuseTest doesn't apply on this environment still connects, just never gets initialized. Extracted the environments+requires skip check (previously inline in runner.ts's skipReasonFor) into lib/skip-reason.ts so runFile and reuseTest execution share one implementation instead of two copies drifting apart. runner.ts now threads environmentName through to test-runner.ts so reuseTest's own skip check has something to compare against. --- runner-package/lib/skip-reason.ts | 39 +++++++++++++++++++++++++++ runner-package/lib/test-registry.ts | 41 +++++++++++++++++++++-------- runner-package/lib/test-runner.ts | 35 +++++++++++++++++++++++- runner-package/runner.ts | 29 +++----------------- 4 files changed, 106 insertions(+), 38 deletions(-) create mode 100644 runner-package/lib/skip-reason.ts diff --git a/runner-package/lib/skip-reason.ts b/runner-package/lib/skip-reason.ts new file mode 100644 index 0000000..077d91c --- /dev/null +++ b/runner-package/lib/skip-reason.ts @@ -0,0 +1,39 @@ +import type { Environment } from './environment.js'; + +/** Capability keys from a `requires` list that `env` does not actually satisfy. A value of + * `false`, `'none'`, or an absent key all count as unmet. + * + * `'key:value'` demands one specific value instead — `'consoleOutput:full'` for a test that + * reads the server log, which a console answering only its own commands cannot provide even + * though it satisfies plain `'console'`. */ +export function missingCapabilities(env: Environment, required: string[]): string[] { + const capabilities = env.capabilities as unknown as Record; + return required.filter(key => { + const separator = key.indexOf(':'); + if (separator !== -1) { + return String(capabilities[key.slice(0, separator)]) !== key.slice(separator + 1); + } + const value = capabilities[key]; + return value === false || value === 'none' || value === undefined; + }); +} + +/** Shared by `runFile`'s `skipReasonFor` (for a normal `TestCase`) and `reuseTest` execution + * (for a `ReuseTestCase`) — both check the same two `TestOptions` fields, `environments` and + * `requires`, against the same running environment. Name filters (`tests.names`/`exclude`) stay + * local to `runFile`: they're a run-level concern, not part of what a test itself declares. */ +export function skipReasonForOptions( + env: Environment, + environmentName: string, + requires: string[], + environments: string[] | null, +): string | null { + if (environments && !environments.includes(environmentName)) { + return `requires environment in [${environments.join(', ')}], running "${environmentName}"`; + } + const missing = missingCapabilities(env, requires); + if (missing.length > 0) { + return `requires capability [${missing.join(', ')}], unavailable on "${environmentName}"`; + } + return null; +} diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 5b471f0..612bd43 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -46,11 +46,17 @@ export interface TestCase { export const testRegistry: TestCase[] = []; export const scopeStack: DescribeScope[] = [{ label: '', beforeHooks: [], afterHooks: [] }]; -/** A reuseTest's body, keyed by the reuse `key` ("pool") it initializes. */ +/** A reuseTest's body, keyed by the reuse `key` ("pool") it initializes. Carries the same + * `describe`-scoped hooks and `requires`/`environments` filters a regular `TestCase` does — + * a reuseTest is a real test in every way but how it gets triggered. */ export interface ReuseTestCase { pool: string; name: string; fn: TestFn; + beforeHooks: Hook[]; + afterHooks: Hook[]; + requires: string[]; + environments: string[] | null; } /** One reuseTest per pool, kept for the whole run rather than reset per spec file — @@ -68,19 +74,21 @@ export function resetRegistry(): void { scopeStack.push({ label: '', beforeHooks: [], afterHooks: [] }); } -function registerTest(name: string, options: TestOptions, fn: TestFn): void { +/** Everything a registered test needs from the current `describe` scope, shared by `test`/ + * `opTest` (pushed into `testRegistry`) and `reuseTest` (kept in `reuseTestRegistry` instead). */ +function scopedEntry(name: string, options: TestOptions | Omit) { const labels = scopeStack.map(s => s.label).filter(l => l); - const fullName = [...labels, name].join(' > '); - - testRegistry.push({ - name: fullName, - fn, + return { + name: [...labels, name].join(' > '), beforeHooks: scopeStack.flatMap(s => s.beforeHooks), afterHooks: [...scopeStack].reverse().flatMap(s => s.afterHooks), requires: options.requires ?? [], environments: options.environments ?? null, - reuse: options.reuse, - }); + }; +} + +function registerTest(name: string, options: TestOptions, fn: TestFn): void { + testRegistry.push({ ...scopedEntry(name, options), fn, reuse: options.reuse }); } export function test(name: string, fn: TestFn): void; @@ -121,15 +129,26 @@ export function opTest(name: string, fnOrOptions: TestFn | TestOptions, maybeFn? * matching) and needs to be built again. It does NOT run on an ordinary checkout of an * already-live entry — that's every other call, which is the common case. * + * Takes the same scope as `test`/`opTest`: `describe` nesting names it and contributes its + * `beforeEach`/`afterEach` hooks, `requires`/`environments` skip it the same way (reported + * `skipped`, not run — same as a regular test would be), and plugin `beforeEach`/`afterEach` + * and fixtures wrap it too. `reuse` isn't accepted — a reuseTest initializes a pool, it doesn't + * resolve into one itself. + * * Runs as its own reported test, right before whichever test triggered the (re)creation. If * `fn` throws, that test fails as a dependency failure and the entry is discarded, so the next * attempt runs `reuseTest` again instead of handing out a half-initialized player. */ -export function reuseTest(pool: string, fn: TestFn): void { +export function reuseTest(pool: string, fn: TestFn): void; +export function reuseTest(pool: string, options: Omit, fn: TestFn): void; +export function reuseTest(pool: string, fnOrOptions: TestFn | Omit, maybeFn?: TestFn): void { + const options = typeof fnOrOptions === 'function' ? {} : fnOrOptions; + const fn = typeof fnOrOptions === 'function' ? fnOrOptions : maybeFn!; + if (reuseTestRegistry.has(pool)) { throw new Error(`reuseTest: pool "${pool}" is already registered (reuseTest can only be declared once per pool)`); } - reuseTestRegistry.set(pool, { pool, name: `reuse:${pool}`, fn }); + reuseTestRegistry.set(pool, { pool, ...scopedEntry(`reuse:${pool}`, options), fn }); } export function describe(label: string, fn: () => void): void { diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index 04624c1..8416b1b 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -9,6 +9,7 @@ import type { Session } from './session.js'; import type { PluginHost } from './plugin-host.js'; import type { BotConnectionOptions } from './environment.js'; import { reuseTestRegistry } from './test-registry.js'; +import { skipReasonForOptions } from './skip-reason.js'; import type { TestCase } from './test-registry.js'; import type { TestContext, TestResult } from './types.js'; import type { ConnectedPlayer, ReuseOptions } from './player-registry.js'; @@ -20,6 +21,10 @@ export interface RunTestCaseParams { plugins: PluginHost; connOpts: BotConnectionOptions; timeoutMs: number; + /** The running environment's configured name — `TestOptions.environments` on a `reuseTest` + * is checked against this, same as `runFile`'s own `skipReasonFor` checks it for a + * regular `TestCase`. */ + environmentName: string; /** Set when this test came from a plugin's inherited `tests`, for report labeling. */ pluginName?: string | null; /** Whole-run setting: `tests.reuse.enabled` narrowed by the environment's @@ -58,7 +63,7 @@ function normalizeReuse(reuse: false | string | ReuseOptions | undefined): false * behavior. */ export async function runTestCase(params: RunTestCaseParams): Promise { - const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null, reuseEnabled = false, reuseStay = true, forceReuseOff = false, onExtraResult } = params; + const { file, testCase, session, plugins, connOpts, timeoutMs, environmentName, pluginName = null, reuseEnabled = false, reuseStay = true, forceReuseOff = false, onExtraResult } = params; console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); session.consoleLog.clear(); @@ -126,6 +131,16 @@ export async function runTestCase(params: RunTestCaseParams): Promise void | Promise> = []; @@ -148,9 +163,17 @@ export async function runTestCase(params: RunTestCaseParams): Promise => { + await plugins.beforeEach(reuseCtx); + for (const hook of reuseCase.beforeHooks) await hook(reuseCtx); + + let testError: unknown; try { await reuseCase.fn(reuseCtx); + } catch (e) { + testError = e; } finally { for (const finalizer of [...reuseFinalizers].reverse()) { try { @@ -159,7 +182,17 @@ export async function runTestCase(params: RunTestCaseParams): Promise clearTimeout(timeoutHandle)); try { diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 1e723af..351ae2e 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -8,6 +8,7 @@ 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 { skipReasonForOptions } from './lib/skip-reason.js'; import { LocalEnvironment } from './lib/environments/local.js'; import { externalEnvironment } from './lib/environments/external.js'; import { PlayerWrapper } from './lib/player.js'; @@ -81,24 +82,6 @@ async function resolveEnvironment(cfg: EnvironmentConfig): Promise 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 - * value of `false`, `'none'`, or an absent key all count as unmet. - * - * `'key:value'` demands one specific value instead — `'consoleOutput:full'` for a test that - * reads the server log, which a console answering only its own commands cannot provide even - * though it satisfies plain `'console'`. */ -function missingCapabilities(env: Environment, required: string[]): string[] { - const capabilities = env.capabilities as unknown as Record; - return required.filter(key => { - const separator = key.indexOf(':'); - if (separator !== -1) { - return String(capabilities[key.slice(0, separator)]) !== key.slice(separator + 1); - } - 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 })) { @@ -185,14 +168,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): 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; + return skipReasonForOptions(env, config.environment.name, testCase.requires, testCase.environments); } /** Imports one compiled spec file (a fresh `testRegistry`) and runs everything it @@ -212,6 +188,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): const result = await runTestCase({ file, testCase, session, plugins, connOpts, timeoutMs, pluginName, + environmentName: config.environment.name, reuseEnabled: reuse.enabled, reuseStay: reuse.stay, forceReuseOff, onExtraResult: r => testResults.push(r), }); From b02d4bed1002471f9a8573c3ab2c9878fb0f2b48 Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 17 Aug 2026 01:25:52 +0300 Subject: [PATCH 054/125] docs(writing-tests): document reuseTest's full TestOptions scope --- docs/writing-tests.mdx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index 8306c9a..220f403 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -197,6 +197,20 @@ test('shop shows vip discount', { reuse: 'shopkeeper' }, async ({ player }) => { `reuseTest` is reported as its own test, listed right before whichever test triggered the (re)creation. If its body throws, that test fails too — the entry is discarded so the next attempt runs `reuseTest` again instead of handing out a half-initialized player. +It takes the same scope as `test`/`opTest` — everything except `reuse` itself, which doesn't apply to a test that's initializing a pool rather than resolving into one: + +```typescript +describe('Shop', () => { + reuseTest('shopkeeper', { requires: ['op'] }, async ({ player }) => { + // named "Shop > reuse:shopkeeper" — describe nesting applies same as any other test + }); +}); +``` + +- `requires` / `environments` skip it exactly like a regular test would be skipped — reported `skipped`, `fn` never runs. A player handed out under a pool whose `reuseTest` doesn't apply on this environment still connects; it's just never initialized, same as if no `reuseTest` had been declared for that pool at all. +- Spec-level `beforeEach`/`afterEach` from the enclosing `describe` wrap it, and so do plugin `beforeEach`/`afterEach` and `extendContext` fixtures — `ctx.holy`, matchers, everything a normal test body gets. +- It does not accept `reuse` — pass `(pool, fn)` or `(pool, options, fn)` where `options` is `requires`/`environments` only. + ## Best Practices 1. **Keep tests isolated** - Each test gets a fresh bot, unless reuse is on From 76ffb24d147370feb6242945a57fbc89d4a8d65d Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 21 Aug 2026 02:01:09 +0300 Subject: [PATCH 055/125] fix(reuse): clear a stayed player's chat history on checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message buffers are per player, and `rejoin` empties one on the way back in. A player checked out under `stay` never left, so it never rejoins and never gets that clear — its chat from the previous test stayed in the buffer and could satisfy an assertion in the next one. Nothing caught it before because the buffer was session-wide and every test started by clearing it. Per-player buffers are the right shape, but they moved that clear out from under reuse, so reuse now does it where it belongs: beside the closed window, in core's safe minimum for a returning player. --- runner-package/lib/test-runner.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index 8416b1b..b7cbbd4 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -242,6 +242,11 @@ export async function runTestCase(params: RunTestCaseParams): Promise Date: Thu, 27 Aug 2026 20:26:23 +0300 Subject: [PATCH 056/125] [v3] feat: publish npm packages and the Gradle plugin, rename runner to @plugwright/runner (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bump): move the plugin packages with the runner auth-authme and console-rcon sat at 1.0.0 while the runner they are written against had moved on several versions, because the bump script only ever knew about runner-package. Nothing said which runner a given plugin package was built for. They now share one version. A plugin package is not useful without the runner, so a version pair that has to be looked up is a cost with nothing on the other side of it. * feat(publish): publish the plugin to a maven repository of your choosing The plugin only went to the Gradle Plugin Portal, which is unreachable from a network that does not let builds out to the internet. Such an organisation had no supported way to get the plugin at all. `publishAllPublicationsToPlugwrightRepository` now deploys both the plugin jar and its marker to whatever repository `plugwright.publish.url` names, with credentials from `plugwright.publish.user` and `.password`. All three also read from PLUGWRIGHT_PUBLISH_URL, _USER and _PASSWORD, which is the shape a CI job already has its secrets in. None of the three appear in this repository. A URL here would tie a public build to one company's servers; a password here would be a password in version control. With no URL set the repository is not declared at all, so a build that does not opt in publishes exactly where it did before. * feat(publish): publish the packages to npmjs or a registry of your choosing The release workflow published one of the three packages, and only to npmjs. The other two — the AuthMe and RCON reference plugins — had no publish path at all, and an organisation that mirrors its dependencies had none either. `npm run publish:packages` now sends all three wherever it is pointed. Given no configuration that is npmjs, which is what a release is; given a registry and credentials in the environment it is that registry instead. The two cases differ only in where the request goes and how it is authenticated, so they share a script rather than each growing one. Nothing about a private registry is written into the packages. A `publishConfig.registry` in a package.json would send the public release there too, so the URL and the credentials come from the environment, and the credentials go into a temporary npm config outside the working tree that is deleted whether the publish worked or not. * feat(publish): name the two destinations the same way, and make both optional The plugin could already go to the Gradle Plugin Portal or to a maven repository of the build's choosing, but the two were reached by tasks that look nothing alike — `publishPlugins` against `publishAllPublicationsToPlugwrightRepository`. Documenting a release meant documenting two vocabularies. `publishToPublicRepository` and `publishToPrivateRepository` wrap what was already there. Neither destination is mandatory, and neither being available is a failure: the private one is off until `plugwright.publish.url` names a repository, and without one the task succeeds, publishes nothing and says why. Most checkouts have no private repository, so a build script or a CI job can name the task unconditionally instead of guarding every call. Either can also be switched off outright, for when the implicit rule gets it wrong — a fork that publishes only inside a company wants the public one off, and a machine that holds the private URL for *resolving* may still want to publish nowhere: plugwright.publish.public.enabled default true plugwright.publish.private.enabled default: on when a URL is set The switch goes on the wrapped task rather than the wrapper. `onlyIf` skips the task it is set on and nothing it depends on, so a wrapper that skipped itself would still have run the publish underneath it. * fix(release): publish all three npm packages, not just the runner The workflow published `@drownek/plugwright` and stopped there, so `@plugwright/auth-authme` and `@plugwright/console-rcon` — which a build asks for by name as soon as it declares an AuthMe or RCON plugin — were never on npmjs at all. Anyone following the docs got a 404 from `npm install`. It now runs the same `npm run publish:packages` a maintainer would run locally, which covers all three and keeps provenance on. The gradle step moves to `publishToPublicRepository`, which is `publishPlugins` under the name the docs use. * chore(publish): write down the environment a private registry needs The registry URL and its credentials are deliberately not in any package.json or build script, which leaves nowhere that says they exist. `.env.example` is that place: it lists every variable both publish paths read, and says which of them a public release needs, which is none of them. `.env` itself is ignored, along with `*.local.md` for notes kept next to the checked-in docs. * docs: document publishing, public and private Neither destination was written down anywhere. The release workflow was the only record of how a public release happens, and the private path — which exists precisely so an organisation can run one without the public registries — had no record at all beyond the environment variables the code reads. `docs/publishing.mdx` covers both, alongside what carries a version and why the tag comes before the publish. * refactor(npm): publish the runner as @plugwright/runner The two reference plugins publish under `@plugwright`, so leaving the runtime they load into on `@drownek/plugwright` splits the project across two npm scopes for a reason no user could reconstruct. The org exists now, and 3.0 is already a breaking release with build scripts being edited anyway, so this is the cheapest moment to move. Only the npm name changes. The Gradle plugin id stays `io.github.drownek.plugwright`, the Kotlin package stays `me.drownek.plugwright`, and `repository.url` still points at `Drownek/plugwright`, so provenance is unaffected. `@drownek/plugwright` keeps its 2.x releases and wants an `npm deprecate` pointing at the new name once 3.0 ships. * docs(npm): point the registry examples at the scope that now exists Every `npm { }` example routes `@drownek` to a private mirror, a scope that stops holding anything the moment the runner moves. `@plugwright` keeps the illustration true: mirroring the project's own packages is the setup these examples are for. * ci(release): give the first @plugwright publish a token to use A trusted publisher is configured per package, and every name under the new scope is unpublished, so there is nothing for OIDC to authenticate against on the first release. The publish step reads `NODE_AUTH_TOKEN` from an `NPM_TOKEN` secret — a granular token scoped to the org — and the block comes out once each package exists and `npm trust` can take over. Provenance is unaffected: `--provenance` and `id-token: write` stay where they are, and the attestation is signed from the job's OIDC token whichever credential does the publishing. --- .env.example | 33 ++++ .github/workflows/release.yml | 20 +- .gitignore | 6 + README.md | 22 ++- auth-authme-package/auth.spec.ts | 2 +- auth-authme-package/index.ts | 2 +- auth-authme-package/package-lock.json | 8 +- auth-authme-package/package.json | 4 +- console-rcon-package/index.ts | 2 +- console-rcon-package/package-lock.json | 8 +- console-rcon-package/package.json | 4 +- docs/configuration.mdx | 6 +- docs/core-concepts.mdx | 2 +- docs/custom-modes.mdx | 2 +- docs/docs.json | 6 + docs/plugins.mdx | 8 +- docs/publishing.mdx | 149 +++++++++++++++ docs/writing-tests.mdx | 6 +- example_plugin/src/test/e2e/package-lock.json | 14 +- example_plugin/src/test/e2e/package.json | 2 +- .../src/test/e2e/plugins/stand-reset.ts | 2 +- .../src/test/e2e/tests/commands.spec.ts | 2 +- .../src/test/e2e/tests/describe.spec.ts | 2 +- .../src/test/e2e/tests/economy.spec.ts | 2 +- .../src/test/e2e/tests/events.spec.ts | 2 +- .../src/test/e2e/tests/kits.spec.ts | 2 +- .../src/test/e2e/tests/message-buffer.spec.ts | 2 +- .../src/test/e2e/tests/minigame.spec.ts | 2 +- .../src/test/e2e/tests/multi-bot.spec.ts | 2 +- .../src/test/e2e/tests/pagination.spec.ts | 2 +- .../src/test/e2e/tests/player-wrapper.spec.ts | 2 +- .../src/test/e2e/tests/shop.spec.ts | 2 +- .../src/test/e2e/tests/simple-ts.spec.ts | 2 +- .../src/test/e2e/tests/teleport.spec.ts | 2 +- .../me/drownek/plugwright/api/NpmSpec.kt | 6 +- .../plugwright/api/RunnerPackageRef.kt | 2 +- .../plugwright-bundle/build.gradle.kts | 126 ++++++++++++- .../me/drownek/plugwright/RunnerLauncher.kt | 2 +- .../plugwright-init/example-plugin.ts | 4 +- .../resources/plugwright-init/example.spec.ts | 2 +- .../resources/plugwright-init/package.json | 2 +- .../plugwright/external/ExternalMode.kt | 2 +- .../me/drownek/plugwright/local/LocalMode.kt | 2 +- package.json | 3 +- runner-package/README.md | 6 +- runner-package/lib/config.ts | 2 +- runner-package/lib/plugin-host.ts | 2 +- runner-package/package-lock.json | 4 +- runner-package/package.json | 2 +- scripts/bump-version.js | 59 +++--- scripts/publish.js | 171 ++++++++++++++++++ 51 files changed, 627 insertions(+), 104 deletions(-) create mode 100644 .env.example create mode 100644 docs/publishing.mdx create mode 100644 scripts/publish.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c684647 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Settings for publishing to a registry other than the public ones. Copy to `.env` and fill +# in what applies; `.env` is ignored by git so the passwords stay out of the repository. +# +# None of this is needed for a public release. `npm run publish:packages` with nothing set +# publishes to npmjs.com, and `./gradlew publishToPublicRepository` publishes the plugin to +# the Gradle Plugin Portal using the credentials that plugin already looks for +# (GRADLE_PUBLISH_KEY and GRADLE_PUBLISH_SECRET). + +# --- npm packages ------------------------------------------------------------------------- +# Where `npm run publish:packages` sends @plugwright/runner and the plugin packages. +# Leave the registry unset to publish to npmjs.com instead. +PLUGWRIGHT_NPM_REGISTRY=https://registry.example.com/repository/npm-hosted/ +PLUGWRIGHT_NPM_USER= +PLUGWRIGHT_NPM_PASSWORD= + +# Optional. The dist-tag to publish under, and the npm access level. +# PLUGWRIGHT_NPM_TAG=latest +# PLUGWRIGHT_NPM_ACCESS=public + +# --- gradle plugin ------------------------------------------------------------------------ +# Where `./gradlew publishToPrivateRepository` sends the plugin. The same three values can be +# given as gradle properties instead: plugwright.publish.url, .user and .password. +# Leave the user and password empty for a repository that accepts anonymous deploys. +# +# Naming a URL here is what turns private publishing on. Without one the task succeeds and +# publishes nothing, so leaving this whole section blank is a valid setup. +PLUGWRIGHT_PUBLISH_URL=https://repo.example.com/repository/maven-releases/ +PLUGWRIGHT_PUBLISH_USER= +PLUGWRIGHT_PUBLISH_PASSWORD= + +# Optional. Force either destination off, whatever the rest of this file says. +# PLUGWRIGHT_PUBLISH_PUBLIC_ENABLED=false +# PLUGWRIGHT_PUBLISH_PRIVATE_ENABLED=false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce50921..0c293b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # for creating GitHub releases - id-token: write # required for OIDC trusted publishing + id-token: write # signs the provenance attestation steps: - name: Checkout repository uses: actions/checkout@v4 @@ -31,11 +31,19 @@ jobs: - name: Upgrade npm run: npm install -g npm@11.5.1 - - name: Publish NPM package - working-directory: runner-package + # The plugin packages link the runner through `file:../runner-package`, so they are + # installed together rather than one directory at a time. Each package builds itself + # from `prepublishOnly`, and the script publishes all three. + - name: Publish NPM packages + env: + # A trusted publisher is configured per package, and no name under @plugwright + # exists yet, so there is nothing for OIDC to authenticate against on the first + # release. A granular token scoped to the org carries it; once all three names + # exist, `npm trust` takes over and this block comes out. + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - npm ci - npm publish --access public --provenance + npm run install:packages + npm run publish:packages -- --provenance - name: Publish Gradle plugin working-directory: gradle-plugin @@ -44,5 +52,5 @@ jobs: GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} run: | chmod +x gradlew - ./gradlew publishPlugins + ./gradlew publishToPublicRepository \ No newline at end of file diff --git a/.gitignore b/.gitignore index 815d8e8..adc4348 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,9 @@ release.properties dependency-reduced-pom.xml .serena/ + +# Publishing credentials for a private registry. See .env.example. +.env + +# Local, per-developer notes kept beside the checked-in docs. +*.local.md diff --git a/README.md b/README.md index da8600f..867ae28 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,17 @@ This framework has been renamed from Paperwright to Plugwright. If you are upgra 1. Change `id("io.github.drownek.paperwright")` to `id("io.github.drownek.plugwright")`. 2. Rename your `paperwright { ... }` configuration block to `plugwright { ... }` and Gradle tasks (e.g. `./gradlew paperwrightTest` to `./gradlew plugwrightTest`). -3. In your `package.json`, change `@drownek/paperwright` to `@drownek/plugwright` and run `npm install`. -4. Update your test files: `import { test } from '@drownek/paperwright'` to `import { test } from '@drownek/plugwright'`. +3. In your `package.json`, change `@drownek/paperwright` to `@plugwright/runner` and run `npm install`. +4. Update your test files: `import { test } from '@drownek/paperwright'` to `import { test } from '@plugwright/runner'`. 5. Change your CI to use `drownek/plugwright-action@v1`. +
+⚠️ Upgrading from Plugwright 2.x? The npm package moved. +
+The runner is published as @plugwright/runner from 3.0 onwards; @drownek/plugwright stops receiving releases at 2.x. Change the dependency in your package.json, run npm install, and update the import in your test files. Nothing else moves: the Gradle plugin id stays io.github.drownek.plugwright. +
+ ## Features `🚀` **Setup** – Automated server lifecycle management with Paper server downloads. @@ -177,6 +183,18 @@ jobs: - uses: drownek/plugwright-action@v1 ``` +## Publishing + +Releasing Plugwright itself is two commands — `npm run publish:packages` for the npm +packages and `./gradlew publishToPublicRepository` for the gradle plugin. Both go to their +public homes, npmjs.com and the Gradle Plugin Portal, and tagging a commit `v*` runs them +for you. + +The same two commands publish to a registry of your own instead, for an organisation whose +builds cannot reach the public ones. The URL and its credentials come from the environment +rather than from any file in the repository — see [`.env.example`](.env.example) and the +[publishing guide](https://plugwright.dev/publishing). + ## Documentation & Examples For full examples on how to test **GUIs**, **multi-bot interactions**, **NMS**, and the complete **API Reference**, visit our official documentation site: diff --git a/auth-authme-package/auth.spec.ts b/auth-authme-package/auth.spec.ts index 879f4da..9745250 100644 --- a/auth-authme-package/auth.spec.ts +++ b/auth-authme-package/auth.spec.ts @@ -1,4 +1,4 @@ -import { test } from '@drownek/plugwright'; +import { test } from '@plugwright/runner'; // If the login/register handshake in `onPlayerCreate` failed or timed out, `createPlayer()` // would already have thrown before this test body ever runs — so reaching here at all is diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index 122ff6e..36ced8f 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -1,6 +1,6 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { definePlugin, poll } from '@drownek/plugwright'; +import { definePlugin, poll } from '@plugwright/runner'; const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/auth-authme-package/package-lock.json b/auth-authme-package/package-lock.json index 15a0e5f..2be319e 100644 --- a/auth-authme-package/package-lock.json +++ b/auth-authme-package/package-lock.json @@ -9,7 +9,7 @@ "version": "3.0.0-dev.0", "license": "MIT", "devDependencies": { - "@drownek/plugwright": "file:../runner-package", + "@plugwright/runner": "file:../runner-package", "@types/node": "^22.10.5", "rimraf": "^6.1.3", "typescript": "^5.7.3" @@ -18,11 +18,11 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@drownek/plugwright": ">=3.0.0-dev.0" + "@plugwright/runner": ">=3.0.0-dev.0" } }, "../runner-package": { - "name": "@drownek/plugwright", + "name": "@plugwright/runner", "version": "3.0.0-dev.0", "dev": true, "license": "MIT", @@ -46,7 +46,7 @@ "node": ">=16.0.0" } }, - "node_modules/@drownek/plugwright": { + "node_modules/@plugwright/runner": { "resolved": "../runner-package", "link": true }, diff --git a/auth-authme-package/package.json b/auth-authme-package/package.json index 0a92837..830d05e 100644 --- a/auth-authme-package/package.json +++ b/auth-authme-package/package.json @@ -32,10 +32,10 @@ "url": "https://github.com/Drownek/plugwright/issues" }, "peerDependencies": { - "@drownek/plugwright": ">=3.0.0-dev.0" + "@plugwright/runner": ">=3.0.0-dev.0" }, "devDependencies": { - "@drownek/plugwright": "file:../runner-package", + "@plugwright/runner": "file:../runner-package", "@types/node": "^22.10.5", "rimraf": "^6.1.3", "typescript": "^5.7.3" diff --git a/console-rcon-package/index.ts b/console-rcon-package/index.ts index 36073d5..fa0939b 100644 --- a/console-rcon-package/index.ts +++ b/console-rcon-package/index.ts @@ -1,4 +1,4 @@ -import type { ServerConsole } from '@drownek/plugwright'; +import type { ServerConsole } from '@plugwright/runner'; import { RconConnection } from './lib/rcon-connection.js'; export interface RconConsoleConfig { diff --git a/console-rcon-package/package-lock.json b/console-rcon-package/package-lock.json index c14af36..4864e28 100644 --- a/console-rcon-package/package-lock.json +++ b/console-rcon-package/package-lock.json @@ -9,7 +9,7 @@ "version": "3.0.0-dev.0", "license": "MIT", "devDependencies": { - "@drownek/plugwright": "file:../runner-package", + "@plugwright/runner": "file:../runner-package", "@types/node": "^22.10.5", "rimraf": "^6.1.3", "typescript": "^5.7.3" @@ -18,11 +18,11 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@drownek/plugwright": ">=3.0.0-dev.0" + "@plugwright/runner": ">=3.0.0-dev.0" } }, "../runner-package": { - "name": "@drownek/plugwright", + "name": "@plugwright/runner", "version": "3.0.0-dev.0", "dev": true, "license": "MIT", @@ -46,7 +46,7 @@ "node": ">=16.0.0" } }, - "node_modules/@drownek/plugwright": { + "node_modules/@plugwright/runner": { "resolved": "../runner-package", "link": true }, diff --git a/console-rcon-package/package.json b/console-rcon-package/package.json index 40f3621..3f09fe2 100644 --- a/console-rcon-package/package.json +++ b/console-rcon-package/package.json @@ -32,10 +32,10 @@ "url": "https://github.com/Drownek/plugwright/issues" }, "peerDependencies": { - "@drownek/plugwright": ">=3.0.0-dev.0" + "@plugwright/runner": ">=3.0.0-dev.0" }, "devDependencies": { - "@drownek/plugwright": "file:../runner-package", + "@plugwright/runner": "file:../runner-package", "@types/node": "^22.10.5", "rimraf": "^6.1.3", "typescript": "^5.7.3" diff --git a/docs/configuration.mdx b/docs/configuration.mdx index f892a59..9418f8d 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -207,8 +207,8 @@ plugwright { authToken(secret.env("NPM_TOKEN")) } - // Only @drownek packages come from here; everything else uses the registry above. - scope("@drownek", "https://nexus.corp/repository/npm-private/") { + // Only @plugwright packages come from here; everything else uses the registry above. + scope("@plugwright", "https://nexus.corp/repository/npm-private/") { username(secret.env("NPM_USER")) password(secret.env("NPM_PASS")) } @@ -240,7 +240,7 @@ The `.npmrc` carries a marker on its first line: # Generated by plugwright - do not edit # Edit the npm { } block in your build script instead. registry=https://nexus.corp/repository/npm-group/ -@drownek:registry=https://nexus.corp/repository/npm-private/ +@plugwright:registry=https://nexus.corp/repository/npm-private/ //nexus.corp/repository/npm-private/:username=ci //nexus.corp/repository/npm-private/:_password=Y2ktcGFzcw== strict-ssl=false diff --git a/docs/core-concepts.mdx b/docs/core-concepts.mdx index 081dc37..26aef18 100644 --- a/docs/core-concepts.mdx +++ b/docs/core-concepts.mdx @@ -8,7 +8,7 @@ description: "Understand the fundamentals of Plugwright." In Plugwright, every test runs with a pre-configured context. You use `test()` to define a scenario and `expect()` to make assertions. ```javascript -import { expect, test } from '@drownek/plugwright'; +import { expect, test } from '@plugwright/runner'; test('Basic test', async ({ player }) => { // Your test logic here diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx index d871026..1a7bebc 100644 --- a/docs/custom-modes.mdx +++ b/docs/custom-modes.mdx @@ -136,7 +136,7 @@ plugwright { The npm package named in `runnerPackages` exports a factory. It takes the `environment.config` object your `serialize` wrote and returns an `Environment`: ```ts -import type { Environment, EnvironmentCapabilities, BotConnectionOptions } from '@drownek/plugwright'; +import type { Environment, EnvironmentCapabilities, BotConnectionOptions } from '@plugwright/runner'; export function velocityEnvironment(config: VelocityConfig): Environment { return new VelocityEnvironment(config); diff --git a/docs/docs.json b/docs/docs.json index 08d7e9b..ba45477 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -46,6 +46,12 @@ "reports", "custom-modes" ] + }, + { + "group": "Maintaining", + "pages": [ + "publishing" + ] } ] } diff --git a/docs/plugins.mdx b/docs/plugins.mdx index f09dfb9..d4519db 100644 --- a/docs/plugins.mdx +++ b/docs/plugins.mdx @@ -59,7 +59,7 @@ Matchers are merged into the shared prototype before the first spec file is impo ## Authentication is a hook, not a test ```ts -import { definePlugin, poll } from '@drownek/plugwright'; +import { definePlugin, poll } from '@plugwright/runner'; export default definePlugin({ name: 'authme', @@ -114,7 +114,7 @@ extendContext(ctx) { ``` ```ts -declare module '@drownek/plugwright' { +declare module '@plugwright/runner' { interface TestContext { auth: AuthApi; } @@ -152,7 +152,7 @@ A plugin built against a newer contract than the runner supports fails to load w A plugin is an npm package (or a single compiled file) whose default export implements the interface: ```ts -import { definePlugin } from '@drownek/plugwright'; +import { definePlugin } from '@plugwright/runner'; export default definePlugin<{ resetCommand?: string }>({ name: 'staging-reset', @@ -168,6 +168,6 @@ export default definePlugin<{ resetCommand?: string }>({ }); ``` -`definePlugin` is an identity function; it exists so TypeScript infers your options type at the definition site. Depend on `@drownek/plugwright` as a peer dependency, ship compiled JavaScript, and point `main` at it. +`definePlugin` is an identity function; it exists so TypeScript infers your options type at the definition site. Depend on `@plugwright/runner` as a peer dependency, ship compiled JavaScript, and point `main` at it. `@plugwright/auth-authme` in this repository is a complete, working example: a hook, an options interface, a preflight test, and a README. diff --git a/docs/publishing.mdx b/docs/publishing.mdx new file mode 100644 index 0000000..fae3405 --- /dev/null +++ b/docs/publishing.mdx @@ -0,0 +1,149 @@ +--- +title: "Publishing Plugwright" +description: "Release the packages and the gradle plugin to npmjs and the Plugin Portal, or to a registry of your own." +--- + +This page is for people releasing Plugwright itself, or running a fork of it inside an +organisation. If you are writing tests for your own plugin you want [Quickstart](/quickstart) +instead. + +Plugwright ships as four artifacts that move as one version: + +| Artifact | Kind | Public home | +| --- | --- | --- | +| `@plugwright/runner` | npm | npmjs.com | +| `@plugwright/auth-authme` | npm | npmjs.com | +| `@plugwright/console-rcon` | npm | npmjs.com | +| `io.github.drownek.plugwright` | gradle plugin | Gradle Plugin Portal | + +Both destinations — the public one and a private one — use the same two commands. What +changes is the environment they run in. + +## A public release + +Tagging a commit `v*` runs `.github/workflows/release.yml`, which does the whole thing. To +do it by hand: + +```bash +npm run install:packages +npm run publish:packages + +cd gradle-plugin +./gradlew publishToPublicRepository +``` + +`publish:packages` with nothing configured publishes all three npm packages to npmjs.com. It +uses whatever credentials npm already has, so an `npm login` session or an `NPM_TOKEN` is +enough. In CI it is an `NPM_TOKEN` secret rather than the job's OIDC token: a trusted +publisher is configured per package, and the `@plugwright` names have never been published, +so there is nothing to authenticate against until the first release has gone out. Provenance +is signed from the OIDC token either way, so `--provenance` works with both. Once all three +packages exist, `npm trust github --file release.yml` replaces the secret. + +`publishToPublicRepository` is the Gradle Plugin Portal, and reads `GRADLE_PUBLISH_KEY` and +`GRADLE_PUBLISH_SECRET` the way the `plugin-publish` plugin always has. + +## Publishing to your own registry + +An organisation that cannot reach npmjs.com or the Plugin Portal — an air-gapped build farm, +or one that only resolves through a mirror — needs these artifacts somewhere its builds can +reach. Nothing about that registry is written into the repository: a URL in a `package.json` +or a build script would send the *public* release there too, and a password in either would +be a password in version control. Both come from the environment instead. + +Copy `.env.example` to `.env` and fill in what applies. `.env` is ignored by git. + +```bash +PLUGWRIGHT_NPM_REGISTRY=https://registry.example.com/repository/npm-hosted/ +PLUGWRIGHT_NPM_USER=deploy +PLUGWRIGHT_NPM_PASSWORD=... + +PLUGWRIGHT_PUBLISH_URL=https://repo.example.com/repository/maven-releases/ +PLUGWRIGHT_PUBLISH_USER=deploy +PLUGWRIGHT_PUBLISH_PASSWORD=... +``` + +Then the same two commands, pointed elsewhere: + +```bash +npm run publish:packages + +cd gradle-plugin +./gradlew publishToPrivateRepository +``` + + +Leave the user and password unset for a registry that accepts anonymous deploys, or one that +authenticates through an `.npmrc` you already have. The credentials are only used when both +are given. + + +### Switching a destination off + +Neither destination is mandatory, and neither being available is a normal state rather than a +failure. + +Private publishing is off until `plugwright.publish.url` names a repository. Without one, +`publishToPrivateRepository` succeeds, publishes nothing, and says why — so a build script or a +CI job can name the task unconditionally without every un-configured checkout failing on it. + +Public publishing is on by default, since that is where a release goes. Turn either off +explicitly when the implicit rule gets it wrong — a fork that publishes only inside a company +wants the public one off, and a machine that has the private URL in its environment for +*resolving* may still want to publish nowhere: + +```bash +./gradlew publishToPublicRepository -Pplugwright.publish.public.enabled=false +./gradlew publishToPrivateRepository -Pplugwright.publish.private.enabled=false +``` + +Both also read `PLUGWRIGHT_PUBLISH_PUBLIC_ENABLED` and `PLUGWRIGHT_PUBLISH_PRIVATE_ENABLED`. +A switched-off destination reports its publish task as `SKIPPED`. + +On the npm side the same rule falls out of the configuration: with no `PLUGWRIGHT_NPM_REGISTRY` +the packages go to npmjs, and a private registry is used only when one is named. + +The npm credentials are written to a temporary npm config outside the working tree and passed +with `--userconfig`, then deleted whether the publish worked or not. They go in as a Basic +`_auth` pair rather than a bearer `_authToken`, because some registries — Nexus among them — +answer a bearer token with `401`. + +### Settings + +Everything below can be given as an environment variable or as a flag to +`npm run publish:packages -- --flag value`. Flags win. + +| Variable | Flag | Default | +| --- | --- | --- | +| `PLUGWRIGHT_NPM_REGISTRY` | `--registry` | npmjs.com | +| `PLUGWRIGHT_NPM_USER` | `--user` | npm's own credentials | +| `PLUGWRIGHT_NPM_PASSWORD` | `--password` | npm's own credentials | +| `PLUGWRIGHT_NPM_TAG` | `--tag` | `latest` | +| `PLUGWRIGHT_NPM_ACCESS` | `--access` | `public` | +| `PLUGWRIGHT_NPM_PROVENANCE` | `--provenance` | off | + +`--dry-run` packs every package and reports what would be sent, without sending it. Worth +running once against a new registry before the real thing, since most registries refuse to +overwrite a release. + +The gradle side takes gradle properties as well as environment variables: +`plugwright.publish.url`, `plugwright.publish.user`, `plugwright.publish.password`, and the two +`.enabled` switches above. + +## Consuming a private registry + +Publishing is one half. The builds that resolve these artifacts need to be pointed at the +same places — an `npm { registry(...) }` block for the packages and a `pluginManagement` +repository for the plugin. That is covered in +[Private npm registries](/ci-cd#private-npm-registries) and +[Configuration](/configuration). + +## Moving the version + +All four artifacts carry one version, kept in `version.txt`. `npm run bump` moves it +everywhere at once — the three `package.json` files and the lockfiles that record the +runner's version, plus the README, the quickstart and the example plugin for a stable +release — then tags the commit. + +Publish after the tag, not before: most registries refuse to overwrite a release that already +exists, so a version published from a half-finished tree cannot be re-published. diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index 220f403..467034a 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -8,7 +8,7 @@ description: "Learn how to write and structure your Plugwright tests." Tests use a simple API similar to Jest: ```typescript -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('test description', async ({ player }) => { // Your test code here @@ -20,7 +20,7 @@ test('test description', async ({ player }) => { Create `src/test/e2e/tests/first.spec.ts` — specs live in `tests`, in whatever subdirectories you like ([Project Layout](/project-layout)): ```typescript -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('player receives welcome message', async ({ player }) => { await expect(player).toHaveReceivedMessage('Welcome'); @@ -112,7 +112,7 @@ await expect(player).toContainItem('item_name') The framework provides several exported utilities for advanced waiting and polling: ```typescript -import { test, sleep, poll, waitForAssertion, waitUntil, waitForStable } from '@drownek/plugwright'; +import { test, sleep, poll, waitForAssertion, waitUntil, waitForStable } from '@plugwright/runner'; test('advanced waiting', async ({ player }) => { // Sleep for 1 second diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index d4ba49a..25ca997 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@drownek/plugwright": "file:../../../../runner-package", + "@plugwright/runner": "file:../../../../runner-package", "@plugwright/auth-authme": "file:../../../../auth-authme-package", "@plugwright/console-rcon": "file:../../../../console-rcon-package" }, @@ -20,7 +20,7 @@ "version": "3.0.0-dev.0", "license": "MIT", "devDependencies": { - "@drownek/plugwright": "file:../runner-package", + "@plugwright/runner": "file:../runner-package", "@types/node": "^22.10.5", "rimraf": "^6.1.3", "typescript": "^5.7.3" @@ -29,7 +29,7 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@drownek/plugwright": ">=3.0.0-dev.0" + "@plugwright/runner": ">=3.0.0-dev.0" } }, "../../../../console-rcon-package": { @@ -37,7 +37,7 @@ "version": "3.0.0-dev.0", "license": "MIT", "devDependencies": { - "@drownek/plugwright": "file:../runner-package", + "@plugwright/runner": "file:../runner-package", "@types/node": "^22.10.5", "rimraf": "^6.1.3", "typescript": "^5.7.3" @@ -46,11 +46,11 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@drownek/plugwright": ">=3.0.0-dev.0" + "@plugwright/runner": ">=3.0.0-dev.0" } }, "../../../../runner-package": { - "name": "@drownek/plugwright", + "name": "@plugwright/runner", "version": "3.0.0-dev.0", "license": "MIT", "dependencies": { @@ -73,7 +73,7 @@ "node": ">=16.0.0" } }, - "node_modules/@drownek/plugwright": { + "node_modules/@plugwright/runner": { "resolved": "../../../../runner-package", "link": true }, diff --git a/example_plugin/src/test/e2e/package.json b/example_plugin/src/test/e2e/package.json index 05dd568..6cfe34f 100644 --- a/example_plugin/src/test/e2e/package.json +++ b/example_plugin/src/test/e2e/package.json @@ -4,7 +4,7 @@ "build": "rimraf dist && tsc" }, "dependencies": { - "@drownek/plugwright": "file:../../../../runner-package", + "@plugwright/runner": "file:../../../../runner-package", "@plugwright/auth-authme": "file:../../../../auth-authme-package", "@plugwright/console-rcon": "file:../../../../console-rcon-package" }, diff --git a/example_plugin/src/test/e2e/plugins/stand-reset.ts b/example_plugin/src/test/e2e/plugins/stand-reset.ts index cc0877c..4b75bf7 100644 --- a/example_plugin/src/test/e2e/plugins/stand-reset.ts +++ b/example_plugin/src/test/e2e/plugins/stand-reset.ts @@ -1,4 +1,4 @@ -import { definePlugin } from '@drownek/plugwright'; +import { definePlugin } from '@plugwright/runner'; /** * Undoes what one test leaves on a leased account before the next test gets it. diff --git a/example_plugin/src/test/e2e/tests/commands.spec.ts b/example_plugin/src/test/e2e/tests/commands.spec.ts index 676e22b..92edc73 100644 --- a/example_plugin/src/test/e2e/tests/commands.spec.ts +++ b/example_plugin/src/test/e2e/tests/commands.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('help command shows available commands', async ({ player }) => { player.chat('/help'); diff --git a/example_plugin/src/test/e2e/tests/describe.spec.ts b/example_plugin/src/test/e2e/tests/describe.spec.ts index 4801aea..34569f9 100644 --- a/example_plugin/src/test/e2e/tests/describe.spec.ts +++ b/example_plugin/src/test/e2e/tests/describe.spec.ts @@ -2,7 +2,7 @@ * This test is mainly related to core runner package to check if everything executes in right order */ -import { afterEach, beforeEach, describe, test, expect } from "@drownek/plugwright"; +import { afterEach, beforeEach, describe, test, expect } from "@plugwright/runner"; const executionLog: string[] = []; diff --git a/example_plugin/src/test/e2e/tests/economy.spec.ts b/example_plugin/src/test/e2e/tests/economy.spec.ts index 9ee7097..5feedf7 100644 --- a/example_plugin/src/test/e2e/tests/economy.spec.ts +++ b/example_plugin/src/test/e2e/tests/economy.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('player starts with default balance', async ({ player }) => { player.chat('/balance'); diff --git a/example_plugin/src/test/e2e/tests/events.spec.ts b/example_plugin/src/test/e2e/tests/events.spec.ts index cb5bad9..74229f9 100644 --- a/example_plugin/src/test/e2e/tests/events.spec.ts +++ b/example_plugin/src/test/e2e/tests/events.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; // Depends on the join itself, not just on a player's current state, so it always needs a // brand-new connection — reuse would hand it a player who already joined once before. diff --git a/example_plugin/src/test/e2e/tests/kits.spec.ts b/example_plugin/src/test/e2e/tests/kits.spec.ts index 9a79156..9210ca1 100644 --- a/example_plugin/src/test/e2e/tests/kits.spec.ts +++ b/example_plugin/src/test/e2e/tests/kits.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('starter kit gives items', async ({ player }) => { player.chat('/kit starter'); diff --git a/example_plugin/src/test/e2e/tests/message-buffer.spec.ts b/example_plugin/src/test/e2e/tests/message-buffer.spec.ts index 5bfb4b0..345a559 100644 --- a/example_plugin/src/test/e2e/tests/message-buffer.spec.ts +++ b/example_plugin/src/test/e2e/tests/message-buffer.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@drownek/plugwright'; +import { expect, test } from '@plugwright/runner'; test('Cross-bot message separation', async ({ player, createPlayer }) => { const friend = await createPlayer({ username: 'FriendBot' }); diff --git a/example_plugin/src/test/e2e/tests/minigame.spec.ts b/example_plugin/src/test/e2e/tests/minigame.spec.ts index de5c759..35dd034 100644 --- a/example_plugin/src/test/e2e/tests/minigame.spec.ts +++ b/example_plugin/src/test/e2e/tests/minigame.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('join arena game', async ({ player }) => { player.chat('/arena join'); diff --git a/example_plugin/src/test/e2e/tests/multi-bot.spec.ts b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts index 304ab31..d1a6b11 100644 --- a/example_plugin/src/test/e2e/tests/multi-bot.spec.ts +++ b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts @@ -2,7 +2,7 @@ * We test there whether multi-player tests are working as well. */ -import { expect, test } from '@drownek/plugwright'; +import { expect, test } from '@plugwright/runner'; test('multi-bot teleportation', async ({ player, createPlayer }) => { // This executes op server command, and we wait for response from server diff --git a/example_plugin/src/test/e2e/tests/pagination.spec.ts b/example_plugin/src/test/e2e/tests/pagination.spec.ts index d796719..69f9c46 100644 --- a/example_plugin/src/test/e2e/tests/pagination.spec.ts +++ b/example_plugin/src/test/e2e/tests/pagination.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('navigate through paginated GUI', async ({ player }) => { await player.makeOp(); diff --git a/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts b/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts index d354ce6..10485f5 100644 --- a/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts +++ b/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts @@ -2,7 +2,7 @@ * Tests for basic PlayerWrapper methods. */ -import { expect, test } from '@drownek/plugwright'; +import { expect, test } from '@plugwright/runner'; // Needs a player that isn't already op, or the server never sends the "Made ... a server // operator" confirmation this test checks for. diff --git a/example_plugin/src/test/e2e/tests/shop.spec.ts b/example_plugin/src/test/e2e/tests/shop.spec.ts index 5e8de4d..3a56e01 100644 --- a/example_plugin/src/test/e2e/tests/shop.spec.ts +++ b/example_plugin/src/test/e2e/tests/shop.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('shop opens with correct items', async ({ player }) => { player.chat('/shop'); diff --git a/example_plugin/src/test/e2e/tests/simple-ts.spec.ts b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts index 5c02d35..dbcd92c 100644 --- a/example_plugin/src/test/e2e/tests/simple-ts.spec.ts +++ b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts @@ -1,4 +1,4 @@ -import {expect, test} from '@drownek/plugwright'; +import {expect, test} from '@plugwright/runner'; test('command permission works', async ({ player }) => { player.chat('/example gui-settings'); diff --git a/example_plugin/src/test/e2e/tests/teleport.spec.ts b/example_plugin/src/test/e2e/tests/teleport.spec.ts index c0cb9bd..8198dfa 100644 --- a/example_plugin/src/test/e2e/tests/teleport.spec.ts +++ b/example_plugin/src/test/e2e/tests/teleport.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('warp command teleports player', async ({ player }) => { player.chat('/warp spawn'); diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt index 6ed6134..f8de7b4 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt @@ -26,7 +26,7 @@ data class NpmCredentials( /** * A registry npm should fetch from: the default one, or the one a single scope resolves to. * - * @param scope npm scope including the leading `@`, e.g. `@drownek`; null for the default registry + * @param scope npm scope including the leading `@`, e.g. `@plugwright`; null for the default registry * @param url registry URL, e.g. `https://nexus.corp/repository/npm-private/` */ data class NpmRegistry( @@ -139,7 +139,7 @@ class NpmCredentialsSpec { * registry("https://nexus.corp/repository/npm-group/") { * authToken(secret.env("NPM_TOKEN")) * } - * scope("@drownek", "https://nexus.corp/repository/npm-private/") { + * scope("@plugwright", "https://nexus.corp/repository/npm-private/") { * username(secret.env("NPM_USER")) * password(secret.env("NPM_PASS")) * } @@ -162,7 +162,7 @@ class NpmSpec { registries += NpmRegistry(null, url, NpmCredentialsSpec().apply(action).build()) } - /** The registry packages under [scope] (`@drownek`, leading `@` included) come from. */ + /** The registry packages under [scope] (`@plugwright`, leading `@` included) come from. */ @JvmOverloads fun scope(scope: String, url: String, action: NpmCredentialsSpec.() -> Unit = {}) { registries += NpmRegistry(scope, url, NpmCredentialsSpec().apply(action).build()) 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 index a3688cb..4ea170d 100644 --- 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 @@ -9,7 +9,7 @@ import java.io.Serializable * 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 name npm package name, e.g. `@plugwright/runner` * @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 */ diff --git a/gradle-plugin/plugwright-bundle/build.gradle.kts b/gradle-plugin/plugwright-bundle/build.gradle.kts index 71ea4d5..6b6bc54 100644 --- a/gradle-plugin/plugwright-bundle/build.gradle.kts +++ b/gradle-plugin/plugwright-bundle/build.gradle.kts @@ -14,13 +14,21 @@ 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. + // Compile-time only, all four: none of them is published under its own coordinates, and + // their classes reach the runtime classpath through this module's merged jar below. + // + // As `implementation` they would instead be written into the published POM as runtime + // dependencies on io.github.drownek:plugwright-core, -local and -external — coordinates + // that exist in no repository, so every consumer resolving this plugin from a maven + // repository failed with "Could not find io.github.drownek:plugwright-core". + // + // gson and snakeyaml above stay `implementation` deliberately: those are real artifacts + // that are not merged into the jar, so the POM does have to ask for them. compileOnly(project(":plugwright-api")) + compileOnly(project(":plugwright-core")) + compileOnly(project(":plugwright-local")) + compileOnly(project(":plugwright-external")) } // This is the module published under the plugin id, so its jar must carry the api, core and @@ -51,3 +59,111 @@ gradlePlugin { } } } + +// An organisation that cannot reach the Gradle Plugin Portal needs the plugin somewhere it +// can reach, so the plugin is publishable to an arbitrary maven repository as well. +// +// Nothing about that repository is written down here: a URL in this file would tie an +// otherwise public build to one company's infrastructure, and a password in it would be a +// password in version control. All three come from properties or the environment, and the +// repository only exists when the URL does — a build without them publishes exactly where it +// did before. +val publishUrl = providers.gradleProperty("plugwright.publish.url") + .orElse(providers.environmentVariable("PLUGWRIGHT_PUBLISH_URL")) +val publishUser = providers.gradleProperty("plugwright.publish.user") + .orElse(providers.environmentVariable("PLUGWRIGHT_PUBLISH_USER")) +val publishPassword = providers.gradleProperty("plugwright.publish.password") + .orElse(providers.environmentVariable("PLUGWRIGHT_PUBLISH_PASSWORD")) + +publishing { + repositories { + if (publishUrl.isPresent) { + maven { + name = "private" + url = uri(publishUrl.get()) + + // A repository that lets anyone write is its own kind of problem, but it is + // not this build's to solve: an anonymous deploy is still a valid one. + if (publishUser.isPresent && publishPassword.isPresent) { + credentials { + username = publishUser.get() + password = publishPassword.get() + } + } + } + } + } +} + +// Either destination can be switched off, and neither being available is a normal state +// rather than a failure. +// +// The public one is on by default: it is where a release goes. The private one is off until a +// repository is named, because most builds have none — a build that never opted in has nothing +// to publish privately, and treating that as an error would make `publishToPrivateRepository` +// fail on every checkout that has not been set up. +// +// The explicit properties exist for the case the implicit rule gets wrong: a fork that +// publishes only inside a company wants the public one off, and a machine that has the private +// URL in its environment for resolving may still want to publish nowhere. +val publicEnabled = providers.gradleProperty("plugwright.publish.public.enabled") + .orElse(providers.environmentVariable("PLUGWRIGHT_PUBLISH_PUBLIC_ENABLED")) + .map { it.toBoolean() } + .orElse(true) +val privateEnabled = providers.gradleProperty("plugwright.publish.private.enabled") + .orElse(providers.environmentVariable("PLUGWRIGHT_PUBLISH_PRIVATE_ENABLED")) + .map { it.toBoolean() } + .orElse(publishUrl.map { true }) + .orElse(false) + +// The two destinations under one pair of names, so a release reads the same whichever it is +// going to. Both wrap tasks that already exist — `publishPlugins` from the plugin-publish +// plugin, and the publication task Gradle derives from the repository above. +// +// The switch goes on the wrapped task, not on the wrapper: `onlyIf` skips the task it is set +// on and nothing it depends on, so a wrapper that skipped itself would still have run the +// publish underneath it. +tasks.named("publishPlugins") { + onlyIf { publicEnabled.get() } +} + +tasks.register("publishToPublicRepository") { + group = "publishing" + description = "Publishes the plugin to the Gradle Plugin Portal, unless it is switched off." + dependsOn(tasks.named("publishPlugins")) + + doLast { + if (!publicEnabled.get()) { + logger.lifecycle("Public publishing is off (plugwright.publish.public.enabled=false).") + } + } +} + +if (publishUrl.isPresent) { + tasks.named("publishAllPublicationsToPrivateRepository") { + onlyIf { privateEnabled.get() } + } +} + +tasks.register("publishToPrivateRepository") { + group = "publishing" + description = "Publishes the plugin to the maven repository named by plugwright.publish.url, when there is one." + + if (publishUrl.isPresent) { + dependsOn(tasks.named("publishAllPublicationsToPrivateRepository")) + } + + // Says why it did nothing rather than failing. The task is registered whether or not a + // repository is configured, so a build script and a CI job can name it unconditionally. + doLast { + if (!publishUrl.isPresent) { + logger.lifecycle( + "No private repository configured, nothing published. Set plugwright.publish.url " + + "(or PLUGWRIGHT_PUBLISH_URL), plus plugwright.publish.user and " + + "plugwright.publish.password if the repository asks for them." + ) + } else if (!privateEnabled.get()) { + logger.lifecycle("Private publishing is off (plugwright.publish.private.enabled=false).") + } + } +} 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 e699fe8..a32efeb 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 @@ -109,7 +109,7 @@ object RunnerLauncher { /** Resolves `cli.js` relative to the workspace's `node_modules`, falling back to the * in-repo build for `example_plugin`-style development setups. */ fun resolveCliJs(workspaceDir: File): File { - val defaultCliJs = File(workspaceDir, "node_modules/@drownek/plugwright/dist/cli.js") + val defaultCliJs = File(workspaceDir, "node_modules/@plugwright/runner/dist/cli.js") return sequenceOf( // Canonical path resolves npm symlink bugs on CI defaultCliJs.canonicalFile, diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts index 41e7e9b..6de2790 100644 --- a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts @@ -1,4 +1,4 @@ -import { definePlugin } from '@drownek/plugwright'; +import { definePlugin } from '@plugwright/runner'; /** * A runner plugin: hooks that run around every test, plus fixtures the tests can @@ -27,7 +27,7 @@ export default definePlugin({ }); // Without this block the fixture still works and TypeScript still complains. -declare module '@drownek/plugwright' { +declare module '@plugwright/runner' { interface TestContext { say: (message: string) => void; } diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts index 7c57f24..a8df0f0 100644 --- a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts @@ -1,4 +1,4 @@ -import {expect, test} from '@drownek/plugwright'; +import {expect, test} from '@plugwright/runner'; test('help displays message', async ({ player, server }) => { player.chat('/help'); diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json index b24a898..01a05a4 100644 --- a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json @@ -4,7 +4,7 @@ "build": "rimraf dist && tsc" }, "dependencies": { - "@drownek/plugwright": "@runnerVersion@" + "@plugwright/runner": "@runnerVersion@" }, "devDependencies": { "@types/node": "^22.10.5", 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 index cbf5573..97b1094 100644 --- 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 @@ -20,7 +20,7 @@ object ExternalMode : PlugwrightMode { ExternalEnvironmentSpec(name, objects) override fun runnerPackages(spec: ExternalEnvironmentSpec): List = buildList { - add(RunnerPackageRef("@drownek/plugwright", export = "externalEnvironment")) + add(RunnerPackageRef("@plugwright/runner", export = "externalEnvironment")) val needsRcon = spec.consoleSpec?.channels?.any { it is ConsoleChannelSpec.Rcon } == true if (needsRcon) { add(RunnerPackageRef("@plugwright/console-rcon", export = "rconConsole")) 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 index d673876..663efd0 100644 --- 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 @@ -28,7 +28,7 @@ object LocalMode : PlugwrightMode { LocalEnvironmentSpec(name, objects) override fun runnerPackages(spec: LocalEnvironmentSpec): List = - listOf(RunnerPackageRef("@drownek/plugwright", export = "localEnvironment")) + listOf(RunnerPackageRef("@plugwright/runner", export = "localEnvironment")) override fun validate(spec: LocalEnvironmentSpec, ctx: ValidationContext) { if (spec.minecraftVersion.get().isBlank()) { diff --git a/package.json b/package.json index 9653ab4..56c469f 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "scripts": { "bump": "node scripts/bump-version.js", "install:packages": "npm install --prefix runner-package && npm install --prefix auth-authme-package && npm install --prefix console-rcon-package", - "build:packages": "npm run build --prefix runner-package && npm run build --prefix auth-authme-package && npm run build --prefix console-rcon-package" + "build:packages": "npm run build --prefix runner-package && npm run build --prefix auth-authme-package && npm run build --prefix console-rcon-package", + "publish:packages": "node scripts/publish.js" } } diff --git a/runner-package/README.md b/runner-package/README.md index 540433b..0e8bb26 100644 --- a/runner-package/README.md +++ b/runner-package/README.md @@ -1,17 +1,17 @@ -# @drownek/plugwright +# @plugwright/runner End-to-end testing runner for Paper/Spigot Minecraft plugins. ## Installation ```bash -npm install @drownek/plugwright +npm install @plugwright/runner ``` ## Quick Start ```javascript -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('player can join server', async ({ player }) => { player.chat('/help'); diff --git a/runner-package/lib/config.ts b/runner-package/lib/config.ts index 57e8c4b..6e6d853 100644 --- a/runner-package/lib/config.ts +++ b/runner-package/lib/config.ts @@ -142,7 +142,7 @@ function readConfigFile(path: string): RunnerConfig { 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.` + `${SUPPORTED_CONFIG_VERSION}. Update @plugwright/runner in your test project.` ); } if (!parsed.environment || typeof parsed.environment.mode !== 'string') { diff --git a/runner-package/lib/plugin-host.ts b/runner-package/lib/plugin-host.ts index 974169d..13485d4 100644 --- a/runner-package/lib/plugin-host.ts +++ b/runner-package/lib/plugin-host.ts @@ -40,7 +40,7 @@ export class PluginHost { 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 runner supports up to v${PLUGIN_API_VERSION}. Update @plugwright/runner.` ); } diff --git a/runner-package/package-lock.json b/runner-package/package-lock.json index 0fd1f73..597751e 100644 --- a/runner-package/package-lock.json +++ b/runner-package/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@drownek/plugwright", + "name": "@plugwright/runner", "version": "3.0.0-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@drownek/plugwright", + "name": "@plugwright/runner", "version": "3.0.0-dev.0", "license": "MIT", "dependencies": { diff --git a/runner-package/package.json b/runner-package/package.json index 4593c44..dfa14c4 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -1,5 +1,5 @@ { - "name": "@drownek/plugwright", + "name": "@plugwright/runner", "version": "3.0.0-dev.0", "description": "End-to-end testing framework for Paper/Spigot Minecraft plugins", "type": "module", diff --git a/scripts/bump-version.js b/scripts/bump-version.js index c373c8f..d3aaf65 100644 --- a/scripts/bump-version.js +++ b/scripts/bump-version.js @@ -4,6 +4,15 @@ const { execSync } = require("child_process"); const fs = require("fs"); const readline = require("readline"); +// Every npm package published out of this repo. They move as one version: a plugin package +// and the runner it is written against are only recognisable as a matching pair if their +// version numbers say so, and the plugin packages are useless on their own anyway. +const NPM_PACKAGES = [ + "runner-package", + "auth-authme-package", + "console-rcon-package", +]; + function prompt(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => rl.question(question, (ans) => { rl.close(); resolve(ans.trim()); })); @@ -41,13 +50,6 @@ function bumpVersionFiles(newVersion, isPrerelease) { `id("io.github.drownek.plugwright") version "${newVersion}"` ); } - - // Matches any version after the package name, e.g., "@drownek/plugwright": "^1.x.x" - replaceRegexInFile( - "gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt", - /"@drownek\/plugwright": "\^[^"]+"/g, - `"@drownek/plugwright": "^${newVersion}"` - ); } replaceRegexInFile( @@ -84,20 +86,35 @@ async function main() { // update version.txt fs.writeFileSync("version.txt", newVersion + "\n"); - // update runner-package/package.json - execSync( - `npm version ${newVersion} --no-git-tag-version --allow-same-version`, - { cwd: "runner-package", stdio: "inherit" } - ); + // update each published package's package.json and its lockfile's own version field + for (const pkg of NPM_PACKAGES) { + console.log(`\nBumping ${pkg}...`); + execSync( + `npm version ${newVersion} --no-git-tag-version --allow-same-version`, + { cwd: pkg, stdio: "inherit" } + ); + } - // update the lockfile in the example plugin - console.log("\nUpdating lockfile in example_plugin..."); - execSync( - `npm install --package-lock-only`, - { cwd: "example_plugin/src/test/e2e", stdio: "inherit" } - ); + // Refresh every lockfile that records the runner's version rather than its own. + // + // The plugin packages depend on the runner through `file:../runner-package`, and npm + // copies the linked package's version into their lockfiles. `npm version` does not + // rewrite that copy — only an install does — so without this the plugin packages ship a + // lockfile still naming the previous runner version. + const LOCKFILE_ONLY = [ + ...NPM_PACKAGES.filter((pkg) => pkg !== "runner-package"), + "example_plugin/src/test/e2e", + ]; + + for (const dir of LOCKFILE_ONLY) { + console.log(`\nUpdating lockfile in ${dir}...`); + execSync( + `npm install --package-lock-only`, + { cwd: dir, stdio: "inherit" } + ); + } - // bump version references in source files (docs and templates only for stable releases) + // bump version references in source files (docs only for stable releases) const changedSourceFiles = [ "example_plugin/build.gradle.kts", ]; @@ -106,15 +123,13 @@ async function main() { changedSourceFiles.push( "README.md", "docs/quickstart.mdx", - "gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt", ); } // commit version files (+ source files if updated) const filesToCommit = [ "version.txt", - "runner-package/package.json", - "runner-package/package-lock.json", + ...NPM_PACKAGES.flatMap((pkg) => [`${pkg}/package.json`, `${pkg}/package-lock.json`]), "example_plugin/src/test/e2e/package-lock.json", ...changedSourceFiles, ].join(" "); diff --git a/scripts/publish.js b/scripts/publish.js new file mode 100644 index 0000000..33c435a --- /dev/null +++ b/scripts/publish.js @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +// Publishes every npm package in this repository. +// +// There are two places these packages need to reach, and they differ only in where they are +// sent and how the request is authenticated: +// +// - npmjs.com, the public release. This is what the release workflow runs, and what +// anyone reproducing a release runs locally. It is also the default here, so a bare +// `npm run publish:packages` does the public thing. +// +// - a registry of your own. An organisation behind a proxy, or one that mirrors its +// dependencies, needs these packages somewhere its builds can reach. Pointing +// `publishConfig.registry` at that registry inside each package.json would send the +// public release there too, so the registry and its credentials live outside the +// packages, in the environment. +// +// Configuration, every entry optional: +// +// PLUGWRIGHT_NPM_REGISTRY registry URL; unset means npmjs.com +// PLUGWRIGHT_NPM_USER registry username, for a registry that wants a password +// PLUGWRIGHT_NPM_PASSWORD registry password +// PLUGWRIGHT_NPM_TAG dist-tag to publish under (default: latest) +// PLUGWRIGHT_NPM_ACCESS npm access level (default: public) +// PLUGWRIGHT_NPM_PROVENANCE set to publish with --provenance +// +// The same settings can be given as flags: --registry, --user, --password, --tag, --access, +// --provenance. Flags win over the environment. `--dry-run` packs each package and reports +// what would be sent without sending it. +// +// A username and password are only used when both are given. Without them the publish uses +// whatever credentials npm already has — an `npm login` session, an `NPM_TOKEN` in `.npmrc`, +// or the OIDC token a CI job was issued. That covers npmjs.com and every registry that +// authenticates the same way. +// +// When a username and password are given they are written to a temporary npm config outside +// the working tree and passed with `--userconfig`, so nothing lands in a file the repository +// could commit. They go in as the Basic `_auth` pair rather than a bearer `_authToken`, +// because some registries (Nexus among them) answer a bearer token with 401. + +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +// Every npm package published out of this repository, in dependency order: the plugin +// packages are written against the runner, so a consumer resolving them wants the runner to +// already be there. +const PACKAGES = [ + "runner-package", + "auth-authme-package", + "console-rcon-package", +]; + +const PUBLIC_REGISTRY = "https://registry.npmjs.org/"; + +// Reads `--name value` and bare `--name` switches out of argv. +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith("--")) continue; + const name = arg.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { + args[name] = next; + i++; + } else { + args[name] = true; + } + } + return args; +} + +// How to run npm as a child process. +// +// Windows npm is a `.cmd` shim, and Node refuses to spawn one without a shell — a shell that +// would then reinterpret what it is handed. When npm runs this script it points +// `npm_execpath` at its own entry point, so the shim can be stepped over entirely; the shell +// is only the fallback for a run straight through node. +function npmInvocation() { + const execpath = process.env.npm_execpath; + if (execpath && execpath.endsWith(".js")) { + return { command: process.execPath, prefix: [execpath], shell: false }; + } + return { + command: process.platform === "win32" ? "npm.cmd" : "npm", + prefix: [], + shell: process.platform === "win32", + }; +} + +// Writes a throwaway npm config holding the credentials, and returns its path. +// +// The path in the auth key has to match the registry's, minus the protocol — npm looks the +// credentials up by that path and silently sends none when it does not match. +function writeAuthConfig(registry, user, password) { + const authKey = registry.replace(/^https?:/, ""); + const npmrc = [ + `registry=${registry}`, + `${authKey}:_auth=${Buffer.from(`${user}:${password}`).toString("base64")}`, + `${authKey}:always-auth=true`, + "", + ].join("\n"); + + const configFile = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "plugwright-publish-")), + "npmrc" + ); + fs.writeFileSync(configFile, npmrc, { mode: 0o600 }); + return configFile; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + const registry = args.registry || process.env.PLUGWRIGHT_NPM_REGISTRY || PUBLIC_REGISTRY; + const user = args.user || process.env.PLUGWRIGHT_NPM_USER; + const password = args.password || process.env.PLUGWRIGHT_NPM_PASSWORD; + const tag = args.tag || process.env.PLUGWRIGHT_NPM_TAG || "latest"; + const access = args.access || process.env.PLUGWRIGHT_NPM_ACCESS || "public"; + const provenance = Boolean(args.provenance || process.env.PLUGWRIGHT_NPM_PROVENANCE); + const dryRun = Boolean(args["dry-run"]); + + if (Boolean(user) !== Boolean(password)) { + console.error( + "A username without a password, or the other way round. Set both " + + "PLUGWRIGHT_NPM_USER and PLUGWRIGHT_NPM_PASSWORD, or neither." + ); + process.exit(1); + } + + const version = fs.readFileSync("version.txt", "utf8").trim(); + console.log( + `${dryRun ? "Dry run: would publish" : "Publishing"} ${version} to ${registry} ` + + `under the "${tag}" tag\n` + ); + + const configFile = user ? writeAuthConfig(registry, user, password) : null; + const npm = npmInvocation(); + + try { + for (const pkg of PACKAGES) { + const name = JSON.parse(fs.readFileSync(path.join(pkg, "package.json"), "utf8")).name; + console.log(`\n=== ${name}@${version}`); + execFileSync( + npm.command, + [ + ...npm.prefix, + "publish", + ...(configFile ? ["--userconfig", configFile] : []), + "--registry", registry, + "--tag", tag, + "--access", access, + ...(provenance ? ["--provenance"] : []), + ...(dryRun ? ["--dry-run"] : []), + ], + { cwd: pkg, stdio: "inherit", shell: npm.shell } + ); + } + } finally { + // Credentials, so they go whether the publish worked or not. + if (configFile) { + fs.rmSync(path.dirname(configFile), { recursive: true, force: true }); + } + } + + console.log(`\n${dryRun ? "Dry run complete for" : "Published"} ${version} to ${registry}`); +} + +main(); From 562467fcc3c592a76f34022816503a5990b73062 Mon Sep 17 00:00:00 2001 From: Drownek Date: Thu, 27 Aug 2026 19:41:29 +0200 Subject: [PATCH 057/125] chore: bump to 3.0.0-dev.1 --- auth-authme-package/package-lock.json | 6 +++--- auth-authme-package/package.json | 2 +- console-rcon-package/package-lock.json | 6 +++--- console-rcon-package/package.json | 2 +- example_plugin/build.gradle.kts | 2 +- example_plugin/src/test/e2e/package-lock.json | 18 +++++++++--------- runner-package/package-lock.json | 4 ++-- runner-package/package.json | 2 +- version.txt | 2 +- 9 files changed, 22 insertions(+), 22 deletions(-) diff --git a/auth-authme-package/package-lock.json b/auth-authme-package/package-lock.json index 2be319e..157d1cf 100644 --- a/auth-authme-package/package-lock.json +++ b/auth-authme-package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@plugwright/auth-authme", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@plugwright/auth-authme", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "license": "MIT", "devDependencies": { "@plugwright/runner": "file:../runner-package", @@ -23,7 +23,7 @@ }, "../runner-package": { "name": "@plugwright/runner", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "dev": true, "license": "MIT", "dependencies": { diff --git a/auth-authme-package/package.json b/auth-authme-package/package.json index 830d05e..4753065 100644 --- a/auth-authme-package/package.json +++ b/auth-authme-package/package.json @@ -1,6 +1,6 @@ { "name": "@plugwright/auth-authme", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "description": "Reference plugwright authentication plugin for an AuthMe-style login/register flow", "type": "module", "main": "dist/index.js", diff --git a/console-rcon-package/package-lock.json b/console-rcon-package/package-lock.json index 4864e28..db49a3f 100644 --- a/console-rcon-package/package-lock.json +++ b/console-rcon-package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "license": "MIT", "devDependencies": { "@plugwright/runner": "file:../runner-package", @@ -23,7 +23,7 @@ }, "../runner-package": { "name": "@plugwright/runner", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "dev": true, "license": "MIT", "dependencies": { diff --git a/console-rcon-package/package.json b/console-rcon-package/package.json index 3f09fe2..fa5c159 100644 --- a/console-rcon-package/package.json +++ b/console-rcon-package/package.json @@ -1,6 +1,6 @@ { "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "description": "RCON server console for plugwright's \"external\" mode", "type": "module", "main": "dist/index.js", diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index d1e9668..ec182fb 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -6,7 +6,7 @@ plugins { `java-library` id("de.eldoria.plugin-yml.bukkit") version "0.8.0" id("com.gradleup.shadow") version "9.0.0" - id("io.github.drownek.plugwright") version "3.0.0-dev.0" + id("io.github.drownek.plugwright") version "3.0.0-dev.1" } // Password every bot on the local server registers with. It guards a server that lives for diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index 25ca997..93e29f4 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -5,9 +5,9 @@ "packages": { "": { "dependencies": { - "@plugwright/runner": "file:../../../../runner-package", "@plugwright/auth-authme": "file:../../../../auth-authme-package", - "@plugwright/console-rcon": "file:../../../../console-rcon-package" + "@plugwright/console-rcon": "file:../../../../console-rcon-package", + "@plugwright/runner": "file:../../../../runner-package" }, "devDependencies": { "@types/node": "^22.10.5", @@ -17,7 +17,7 @@ }, "../../../../auth-authme-package": { "name": "@plugwright/auth-authme", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "license": "MIT", "devDependencies": { "@plugwright/runner": "file:../runner-package", @@ -34,7 +34,7 @@ }, "../../../../console-rcon-package": { "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "license": "MIT", "devDependencies": { "@plugwright/runner": "file:../runner-package", @@ -51,7 +51,7 @@ }, "../../../../runner-package": { "name": "@plugwright/runner", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", @@ -73,10 +73,6 @@ "node": ">=16.0.0" } }, - "node_modules/@plugwright/runner": { - "resolved": "../../../../runner-package", - "link": true - }, "node_modules/@plugwright/auth-authme": { "resolved": "../../../../auth-authme-package", "link": true @@ -85,6 +81,10 @@ "resolved": "../../../../console-rcon-package", "link": true }, + "node_modules/@plugwright/runner": { + "resolved": "../../../../runner-package", + "link": true + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", diff --git a/runner-package/package-lock.json b/runner-package/package-lock.json index 597751e..2c41c6b 100644 --- a/runner-package/package-lock.json +++ b/runner-package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@plugwright/runner", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@plugwright/runner", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", diff --git a/runner-package/package.json b/runner-package/package.json index dfa14c4..46a2728 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -1,6 +1,6 @@ { "name": "@plugwright/runner", - "version": "3.0.0-dev.0", + "version": "3.0.0-dev.1", "description": "End-to-end testing framework for Paper/Spigot Minecraft plugins", "type": "module", "main": "dist/runner.js", diff --git a/version.txt b/version.txt index 2442953..2813f3b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.0.0-dev.0 +3.0.0-dev.1 From 535f8c6de45a21da0feed6cd9e51db58e7ef127b Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 27 Aug 2026 21:11:14 +0300 Subject: [PATCH 058/125] fix(authme): hardcode joinIndex to 0 to avoid missing login prompt Login prompt can arrive during handshake, before player.getMessageBufferIndex() is read. Reading it too late means the poll below scans from after the prompt and never finds it. Fixes part of #54 --- auth-authme-package/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index 36ced8f..a3bedcf 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -89,7 +89,13 @@ export default definePlugin({ // pool account outlives the run that created it. So wait for either prompt and answer // the one that actually arrived. Register is tested first because AuthMe's register // prompt names the password too, and would otherwise match the login pattern. - const joinIndex = player.getMessageBufferIndex(); + // + // Hardcoded to 0 rather than `player.getMessageBufferIndex()`: the login prompt can + // arrive during the handshake, before this handler even runs, so reading the buffer + // index here can already be past it. Scanning from 0 risks matching a stale prompt from + // a previous connection, but this buffer is fresh per player and the loss of precision + // is worth never missing the real prompt. + const joinIndex = 0; const since = (index: number, pattern: RegExp): string | undefined => player.messageBuffer.slice(index).find((m: string) => pattern.test(m)); From fc2cdb872deaa67f7d9cbab455d8a0e793217464 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 27 Aug 2026 21:11:17 +0300 Subject: [PATCH 059/125] fix(local-mode): write configured port to server.properties spec.port was already threaded into the runner config so bots connect to it, but PaperProvisionTask never wrote it into server.properties, so the locally provisioned server always came up on the Paper default (25565) regardless. Fixes part of #54 --- .../me/drownek/plugwright/local/LocalMode.kt | 1 + .../plugwright/local/PaperProvisionTask.kt | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) 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 index 663efd0..c0ff091 100644 --- 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 @@ -78,6 +78,7 @@ object LocalMode : PlugwrightMode { dependsOn(clean) runDir.set(spec.runDir) minecraftVersion.set(spec.minecraftVersion) + port.set(spec.port) pluginJar.set(ctx.projectPluginJar) pluginUrls.set(spec.pluginUrls) runDirFiles.set(spec.runDirFiles) diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt index b7587d9..ddff596 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt @@ -31,6 +31,9 @@ abstract class PaperProvisionTask : DefaultTask() { @get:Input abstract val minecraftVersion: Property + @get:Input + abstract val port: Property + @get:Input @get:Optional abstract val pluginJar: Property @@ -102,6 +105,16 @@ abstract class PaperProvisionTask : DefaultTask() { lines.add("connection-throttle=0") } + val portLine = "server-port=${port.get()}" + val hasPort = lines.any { it.trim().startsWith("server-port=") } + if (hasPort) { + lines = lines.map { line -> + if (line.trim().startsWith("server-port=")) portLine else line + }.toMutableList() + } else { + lines.add(portLine) + } + // Disable spawn protection so tests can damage players near spawn val hasSpawnProtection = lines.any { it.trim().startsWith("spawn-protection=") } if (hasSpawnProtection) { @@ -114,8 +127,11 @@ abstract class PaperProvisionTask : DefaultTask() { 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")) + logger.lifecycle("Creating server.properties with online-mode=false, connection-throttle=0, spawn-protection=0 and server-port=${port.get()}") + Files.write( + serverProperties.toPath(), + listOf("online-mode=false", "connection-throttle=0", "spawn-protection=0", "server-port=${port.get()}") + ) } configureBukkitSettings(runDirectory) From 6bfd12094d82dcebb2afea612d9a619ef24df539 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 27 Aug 2026 21:11:19 +0300 Subject: [PATCH 060/125] fix(stand-reset): use player.deOp() to clear tracked ability state Raw 'minecraft:deop' command removed op server-side but left player.abilities still marking 'op', so later assertions against tracked state stayed wrong even though the server had already deopped the player. Fixes part of #54 --- example_plugin/src/test/e2e/plugins/stand-reset.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example_plugin/src/test/e2e/plugins/stand-reset.ts b/example_plugin/src/test/e2e/plugins/stand-reset.ts index 4b75bf7..61e4958 100644 --- a/example_plugin/src/test/e2e/plugins/stand-reset.ts +++ b/example_plugin/src/test/e2e/plugins/stand-reset.ts @@ -18,7 +18,7 @@ export default definePlugin({ // and the tests that depend on this reset are excluded there anyway. if (!server.session.env.capabilities.console) return; - await server.executeAndWait(`minecraft:deop ${player.username}`); + await player.deOp(); await server.executeAndWait(`minecraft:clear ${player.username}`); }, }); From f55ee81ec4117286cd3beb320599bc6c7fd81a98 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 27 Aug 2026 22:26:32 +0300 Subject: [PATCH 061/125] refactor: drop player reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse handed a bot to the next test with whatever state it had left on it — an open GUI, a stale permission, a spent balance — and the ability-label matching meant to keep that honest only described what the core happened to know about. The risk outweighed the reconnects it saved. Gone: player-registry.ts, TestOptions.reuse, reuseTest/reuseTestRegistry, capabilities.playerReuse, tests.reuse and the PLUGWRIGHT_REUSE env overrides, the onPlayerReuse plugin hook, PluginTestRef.reuse, TestResult.reuse, and ReuseSpec.kt with its wiring through the extension, tasks and config writer. Every test connects a fresh bot again, the way the runner worked before reuse landed. player.abilities/mark/unmark stay: with no matcher reading them they are now just a note a test can leave on a player. Refs #53 --- auth-authme-package/index.ts | 4 +- .../src/test/e2e/tests/events.spec.ts | 6 +- .../src/test/e2e/tests/kits.spec.ts | 4 +- .../src/test/e2e/tests/player-wrapper.spec.ts | 4 +- .../src/test/e2e/tests/shop.spec.ts | 4 +- .../plugwright/PlugwrightCorePlugin.kt | 9 - .../drownek/plugwright/PlugwrightExtension.kt | 8 - .../plugwright/PlugwrightMatrixTask.kt | 6 - .../drownek/plugwright/PlugwrightTestTask.kt | 20 -- .../kotlin/me/drownek/plugwright/ReuseSpec.kt | 32 --- .../me/drownek/plugwright/RunnerLauncher.kt | 14 - runner-package/lib/account.ts | 3 +- runner-package/lib/config.ts | 47 +--- runner-package/lib/environment.ts | 7 - runner-package/lib/player-registry.ts | 220 --------------- runner-package/lib/player.ts | 6 +- runner-package/lib/plugin-host.ts | 10 +- runner-package/lib/plugin.ts | 8 - runner-package/lib/reporter.ts | 1 - runner-package/lib/session.ts | 12 +- runner-package/lib/skip-reason.ts | 7 +- runner-package/lib/test-registry.ts | 80 +----- runner-package/lib/test-runner.ts | 256 ++---------------- runner-package/lib/types.ts | 11 +- runner-package/runner.ts | 77 +----- 25 files changed, 60 insertions(+), 796 deletions(-) delete mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt delete mode 100644 runner-package/lib/player-registry.ts diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index a3bedcf..b615c9f 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -59,9 +59,7 @@ let resolved: Required> & { password?: strin export default definePlugin({ name: 'authme', apiVersion: 1, - // Preflight exists to prove the login/register flow actually runs — a reused, already - // authenticated player would skip straight past what this test checks. - tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight', reuse: false }], + tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight' }], setup({ options }) { resolved = { ...DEFAULTS, ...options }; diff --git a/example_plugin/src/test/e2e/tests/events.spec.ts b/example_plugin/src/test/e2e/tests/events.spec.ts index 74229f9..da4110f 100644 --- a/example_plugin/src/test/e2e/tests/events.spec.ts +++ b/example_plugin/src/test/e2e/tests/events.spec.ts @@ -1,8 +1,8 @@ import { test, expect } from '@plugwright/runner'; -// Depends on the join itself, not just on a player's current state, so it always needs a -// brand-new connection — reuse would hand it a player who already joined once before. -test('player receives item on first join', { reuse: false }, async ({ player }) => { +// Depends on the join itself, not just on a player's current state: it only holds for an +// account the server has never seen before. +test('player receives item on first join', async ({ player }) => { await expect(player).toHaveReceivedMessage('Welcome'); await expect(player).toContainItem('wooden_sword'); }); diff --git a/example_plugin/src/test/e2e/tests/kits.spec.ts b/example_plugin/src/test/e2e/tests/kits.spec.ts index 9210ca1..dd250c7 100644 --- a/example_plugin/src/test/e2e/tests/kits.spec.ts +++ b/example_plugin/src/test/e2e/tests/kits.spec.ts @@ -15,8 +15,8 @@ test('kit has cooldown', async ({ player }) => { }); // Op bypasses permission checks in Bukkit by default, so this only proves anything against a -// player that isn't one. -test('VIP kit requires permission', { reuse: { excludeAbilities: ['op'] } }, async ({ player }) => { +// player that isn't one — which every test gets, since each one connects a fresh bot. +test('VIP kit requires permission', async ({ player }) => { player.chat('/kit vip'); await expect(player).toHaveReceivedMessage('no permission'); }); diff --git a/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts b/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts index 10485f5..34ccd22 100644 --- a/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts +++ b/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts @@ -4,9 +4,7 @@ import { expect, test } from '@plugwright/runner'; -// Needs a player that isn't already op, or the server never sends the "Made ... a server -// operator" confirmation this test checks for. -test('makeOp', { reuse: { excludeAbilities: ['op'] } }, async ({ player }) => { +test('makeOp', async ({ player }) => { // This executes op server command, and we wait for response from server // so when await completes, we are sure player is op. await player.makeOp(); diff --git a/example_plugin/src/test/e2e/tests/shop.spec.ts b/example_plugin/src/test/e2e/tests/shop.spec.ts index 3a56e01..b2f8e5b 100644 --- a/example_plugin/src/test/e2e/tests/shop.spec.ts +++ b/example_plugin/src/test/e2e/tests/shop.spec.ts @@ -19,9 +19,7 @@ test('purchase item from shop', async ({ player }) => { await expect(player).toContainItem('diamond'); }); -// Depends on starting with no currency, which a reused player carried over from an earlier -// test can't promise — a fresh connection is the only way to guarantee it. -test('cannot buy without money', { reuse: false }, async ({ player }) => { +test('cannot buy without money', async ({ player }) => { player.chat('/shop'); const gui = await player.gui({ title: 'Shop' }); await gui.locator(item => item.name === 'diamond').click(); 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 4f3b717..0232ee2 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 @@ -210,12 +210,6 @@ class PlugwrightCorePlugin : Plugin { runtimePackage.set(ref.name) ref.export?.let { runtimeExport.set(it) } } - if (extension.reuse.enabled.get()) { - reuseEnabled.set(true) - extension.reuse.maxPlayers.orNull?.let { reuseMaxPlayers.set(it) } - reuseStay.set(extension.reuse.stay) - } - if (project.hasProperty("testFiles")) testFiles.set(project.property("testFiles") as String) if (project.hasProperty("testNames")) testNames.set(project.property("testNames") as String) } @@ -266,9 +260,6 @@ class PlugwrightCorePlugin : Plugin { journalFile = journalFilePath, runtimePackage = runtimeRef?.name, runtimeExport = runtimeRef?.export, - reuseEnabled = extension.reuse.enabled.get().takeIf { it }, - reuseMaxPlayers = extension.reuse.maxPlayers.orNull, - reuseStay = extension.reuse.stay.orNull, ) ctx.prepareTaskRef?.let { matrixPrepareTasks += it } } 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 4dc1c4e..b6adc04 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 @@ -59,14 +59,6 @@ abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperti matrix.action() } - /** Settings for reusing a connected bot across test boundaries. See [reuse]. */ - val reuse: ReuseSpec = project.objects.newInstance(ReuseSpec::class.java) - - /** Configures reuse: `reuse { enabled.set(true); maxPlayers.set(4); stay.set(true) }`. */ - fun reuse(action: ReuseSpec.() -> Unit) { - reuse.action() - } - /** Registries the workspace installs from, and the credentials for them. See [npm]. */ val npm: NpmSpec = NpmSpec() 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 fc4f107..ad3e9fa 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 @@ -30,9 +30,6 @@ internal data class MatrixEnvironmentInput( val journalFile: File?, val runtimePackage: String? = null, val runtimeExport: String? = null, - val reuseEnabled: Boolean? = null, - val reuseMaxPlayers: Int? = null, - val reuseStay: Boolean? = null, ) private data class EnvironmentSummary(val total: Int, val passed: Int, val failed: Int, val skipped: Int, val durationMs: Long) @@ -131,9 +128,6 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { journalFile = env.journalFile, runtimePackage = env.runtimePackage, runtimeExport = env.runtimeExport, - reuseEnabled = env.reuseEnabled, - reuseMaxPlayers = env.reuseMaxPlayers, - reuseStay = env.reuseStay, ) RunnerLauncher.writeConfig(entry) val cliJs = RunnerLauncher.resolveCliJs(env.workspaceDir) 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 06d81ac..0578217 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 @@ -49,23 +49,6 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { @get:Optional abstract val excludeTests: ListProperty - /** From `plugwright.reuse.enabled`. Unset means "reuse off", matching a config with no - * `tests.reuse` key at all. */ - @get:Input - @get:Optional - abstract val reuseEnabled: Property - - /** From `plugwright.reuse.maxPlayers`. Unset means "runner default". */ - @get:Input - @get:Optional - abstract val reuseMaxPlayers: Property - - /** From `plugwright.reuse.stay`. Unset means "runner default" — the bot stays connected - * between the tests that borrow it. */ - @get:Input - @get:Optional - abstract val reuseStay: Property - /** * 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 @@ -147,9 +130,6 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { journalFile = journalFile.orNull?.asFile, runtimePackage = runtimePackage.orNull, runtimeExport = runtimeExport.orNull, - reuseEnabled = reuseEnabled.orNull, - reuseMaxPlayers = reuseMaxPlayers.orNull, - reuseStay = reuseStay.orNull, ) RunnerLauncher.writeConfig(entry) logger.lifecycle("Runner config: ${configDestination.absolutePath}") diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt deleted file mode 100644 index ba075f3..0000000 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ReuseSpec.kt +++ /dev/null @@ -1,32 +0,0 @@ -package me.drownek.plugwright - -import org.gradle.api.provider.Property - -/** - * Settings for reusing a connected bot across test boundaries instead of reconnecting for - * every test. Written into every environment's `tests.reuse`, the same way `MatrixSpec` - * configures the matrix run rather than any one environment. - */ -abstract class ReuseSpec { - - /** Off by default: a project turns this on deliberately, so an existing suite that - * depends on a fresh player per test (a unique nick, no leftover op) keeps working - * unchanged until it opts in. */ - abstract val enabled: Property - - /** Live registry entries allowed at once. Unset means "runner default" — 4, or the - * environment's account pool capacity minus one when it has a pool. */ - abstract val maxPlayers: Property - - /** Whether a reused bot keeps its connection between the tests that borrow it. On by - * default, which is what reuse meant before this existed. `false` keeps the identity — - * account, nick, ability labels — but drops the connection at the end of every test and - * rejoins when a later one takes it: the only form of reuse a server that kicks idle bots - * allows. A single test can still override this with `reuse: { stay }`. */ - abstract val stay: Property - - init { - enabled.convention(false) - stay.convention(true) - } -} 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 a32efeb..f10d09c 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 @@ -38,13 +38,6 @@ object RunnerLauncher { val runtimeExport: String? = null, /** Crash-recovery journal path for `Session.journal`; null disables on-disk persistence. */ val journalFile: File? = null, - /** null means "reuse off", matching a config with no `tests.reuse` key at all. */ - val reuseEnabled: Boolean? = null, - /** null means "runner default" — only meaningful when [reuseEnabled] is true. */ - val reuseMaxPlayers: Int? = null, - /** Whether a reused bot stays connected between tests; null means "runner default" - * (true). Only meaningful when [reuseEnabled] is true. */ - val reuseStay: Boolean? = null, ) fun writeConfig(entry: Entry) { @@ -73,13 +66,6 @@ object RunnerLauncher { if (entry.excludeTests.isNotEmpty()) putStrings("exclude", entry.excludeTests) else putNull("exclude") // null means "runner default", which TEST_TIMEOUT can still override. putNull("timeoutMs") - if (entry.reuseEnabled != null) { - obj("reuse") { - put("enabled", entry.reuseEnabled) - entry.reuseMaxPlayers?.let { put("maxPlayers", it) } - entry.reuseStay?.let { put("stay", it) } - } - } } if (entry.jsonReportFile != null || entry.junitReportFile != null) { obj("reports") { diff --git a/runner-package/lib/account.ts b/runner-package/lib/account.ts index 0e39f3b..c3715cf 100644 --- a/runner-package/lib/account.ts +++ b/runner-package/lib/account.ts @@ -84,8 +84,7 @@ export class AccountPool { } /** Total configured slots (pool + microsoft + autoRegister's max), not the number - * currently free. Used to size a fixed-slot consumer (e.g. reuse's `maxPlayers` - * default) before anything has been leased. */ + * currently free. */ capacity(): number { return this.queue.length + (this.autoRegister?.max ?? 0); } diff --git a/runner-package/lib/config.ts b/runner-package/lib/config.ts index 6e6d853..cd1f791 100644 --- a/runner-package/lib/config.ts +++ b/runner-package/lib/config.ts @@ -31,24 +31,6 @@ export interface EnvironmentConfig { config: Record; } -/** Settings for reusing a connected bot across test boundaries instead of reconnecting for - * every test. Absent, or `enabled: false`, is the pre-reuse behavior: connect → test → - * disconnect, every time. */ -export interface ReuseConfig { - enabled: boolean; - /** Live registry entries allowed at once. Defaults to 4, or `AccountPool` capacity minus - * one when the environment has a pool — one slot is kept free for a test's own - * `createPlayer()` call. */ - maxPlayers?: number | null; - /** Whether a reused bot keeps its connection between the tests that borrow it. Defaults to - * `true`, which is what reuse meant before this setting existed. `false` parks each entry - * instead — the identity (account, nick, ability labels) is what carries over, and the bot - * rejoins when a later test takes it. A single test can override this through - * `reuse: { stay }`; an environment declaring `playerReuse: 'rejoin'` can't be overridden - * upwards by either. */ - stay?: boolean | null; -} - export interface TestsConfig { /** Directory scanned for compiled spec files. Defaults to the working directory. */ dir?: string | null; @@ -60,7 +42,6 @@ export interface TestsConfig { names?: string[] | null; /** Per-test timeout; falls back to TEST_TIMEOUT and then to 30s. */ timeoutMs?: number | null; - reuse?: ReuseConfig | null; } export interface ReportsConfig { @@ -207,12 +188,9 @@ function configFromEnvironment(): RunnerConfig { */ export function loadRunnerConfig(argv: string[] = process.argv.slice(2)): RunnerConfig { const flagPath = readConfigFlag(argv); - const config = flagPath + return flagPath ? readConfigFile(isAbsolute(flagPath) ? flagPath : resolve(process.cwd(), flagPath)) : loadDefaultOrLegacyConfig(); - - applyReuseEnvOverride(config); - return config; } function loadDefaultOrLegacyConfig(): RunnerConfig { @@ -225,29 +203,6 @@ function loadDefaultOrLegacyConfig(): RunnerConfig { } } -/** `1`/`true` and `0`/`false`; anything else, including unset, reads as "not specified". */ -function booleanEnv(raw: string | undefined): boolean | null { - if (raw === undefined) return null; - if (raw === '1' || raw.toLowerCase() === 'true') return true; - if (raw === '0' || raw.toLowerCase() === 'false') return false; - return null; -} - -/** `PLUGWRIGHT_REUSE` and `PLUGWRIGHT_REUSE_STAY` override `tests.reuse` from a committed config - * file — the toggle for a dev's own edit loop, so neither reuse nor the choice between a parked - * and a rejoining bot has to live in a checked-in build script just to be tried locally. */ -function applyReuseEnvOverride(config: RunnerConfig): void { - const enabled = booleanEnv(process.env.PLUGWRIGHT_REUSE); - const stay = booleanEnv(process.env.PLUGWRIGHT_REUSE_STAY); - if (enabled === null && stay === null) return; - - config.tests.reuse = { - ...config.tests.reuse, - enabled: enabled ?? config.tests.reuse?.enabled ?? false, - ...(stay === null ? {} : { stay }), - }; -} - /** 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'; diff --git a/runner-package/lib/environment.ts b/runner-package/lib/environment.ts index a578c76..61e43c6 100644 --- a/runner-package/lib/environment.ts +++ b/runner-package/lib/environment.ts @@ -12,13 +12,6 @@ export interface EnvironmentCapabilities { arbitraryUsernames: boolean; lifecycle: boolean; cleanupStrategy: 'wipe' | 'compensating' | 'none'; - /** Absent or `true` means "allowed". `false` turns reuse off here entirely — an environment - * that breaks under a bot surviving a test boundary at all (a world reset between tests). - * `'rejoin'` is the middle ground for a server that only objects to the bot *sitting* there: - * entries are reused, but each one leaves at the end of its test and rejoins when a later - * test takes it. It caps `tests.reuse.stay` — a test asking for `stay: true` still gets a - * rejoin, the same way `false` outranks the config today. */ - playerReuse?: boolean | 'rejoin'; } export interface BotConnectionOptions { diff --git a/runner-package/lib/player-registry.ts b/runner-package/lib/player-registry.ts deleted file mode 100644 index cfb4d37..0000000 --- a/runner-package/lib/player-registry.ts +++ /dev/null @@ -1,220 +0,0 @@ -import pc from 'picocolors'; -import type { Bot } from 'mineflayer'; -import type { PlayerWrapper } from './player.js'; -import type { Account, AccountPool } from './account.js'; -import type { Session } from './session.js'; - -/** What a test asks for when it wants a long-lived player instead of a fresh connection. - * `key` names an identity directly; without it, matching goes by ability labels. */ -export interface ReuseOptions { - /** Explicit identity. Needed when a test cares that it gets the *same* bot back — - * the second player in a multiplayer test, for instance. */ - key?: string; - /** Labels the player must carry. */ - abilities?: string[]; - /** Labels the player must not carry. */ - excludeAbilities?: string[]; - /** The player's label set must equal `abilities` exactly — no extras allowed. */ - strict?: boolean; - /** Whether the bot keeps its connection once the test that borrowed it finishes. `false` - * parks the entry instead: the account, the nick and the labels survive, the connection - * doesn't, and a later test that takes the entry gets a `rejoin()` first. That's the shape - * a server which kicks an idle bot needs. Defaults to the run's `tests.reuse.stay`. */ - stay?: boolean; -} - -export interface ConnectedPlayer { - player: PlayerWrapper; - account: Account; - pool: AccountPool | null; -} - -export interface ResolveResult { - player: PlayerWrapper; - key: string; - reused: boolean; -} - -interface RegistryEntry { - key: string; - player: PlayerWrapper; - account: Account; - pool: AccountPool | null; - /** Held by the current test: not handed out a second time, not evicted by LRU. */ - checkedOut: boolean; - /** Released with `stay: false`, so the bot left the server but the entry stayed. Checking - * it out again rejoins first. */ - parked: boolean; - lastUsedAt: number; -} - -/** Implicit key for a request with no explicit `key`: the normalized requirement set, so two - * requests asking for the same shape of player land on the same entry. */ -function derivedKey(options: ReuseOptions): string { - const abilities = [...(options.abilities ?? [])].sort().join(','); - const exclude = [...(options.excludeAbilities ?? [])].sort().join(','); - return `auto:[${abilities}]!(${exclude})${options.strict ? ':strict' : ''}`; -} - -function matches(entry: RegistryEntry, options: ReuseOptions): boolean { - const abilities = entry.player.abilities; - if ((options.abilities ?? []).some(a => !abilities.has(a))) return false; - if ((options.excludeAbilities ?? []).some(a => abilities.has(a))) return false; - if (options.strict && abilities.size !== (options.abilities?.length ?? 0)) return false; - return true; -} - -/** - * Long-lived bots that survive test boundaries within one run. Entries are matched by the - * ability labels a player carries (see `PlayerWrapper.abilities`) rather than by resetting - * server state back to a known baseline — the core has no way to undo what a plugin's own - * commands changed, so it doesn't pretend to. - * - * `resolve()` never connects a bot itself; it calls the `connect` callback it's given, so the - * caller keeps ownership of connection options, throttling and account leasing. - * - * An entry surviving a test boundary does not have to stay *connected* across it: released with - * `stay: false`, it parks — the bot leaves, the identity (account, nick, labels) stays, and the - * next test to take the entry gets a rejoin. What's reused there is the identity, not the - * connection, which is the only form of reuse a server that kicks idle bots allows. - */ -export class PlayerRegistry { - private readonly entries: RegistryEntry[] = []; - - constructor( - private readonly session: Session, - private readonly maxPlayers: number, - ) {} - - /** Every bot this registry currently owns — checked out by the running test or sitting - * free for the next one. Callers pass this to `Session.disconnectAllBots` as the "keep" - * list, so a per-test sweep doesn't take down an entry no test happened to touch this - * time. */ - ownedBots(): Bot[] { - return this.entries.map(e => e.player.bot); - } - - /** `onFreshEntry` fires exactly when this call ends up creating a brand-new entry — - * first-ever request for a key, or a rebuild after a drop — never on a plain checkout of - * an entry that's already live. A rejected `onFreshEntry` discards the entry it just built, - * same as a broken connection would, and the rejection propagates to the caller. */ - async resolve( - options: ReuseOptions, - connect: () => Promise, - onFreshEntry?: (key: string, player: PlayerWrapper) => Promise, - ): Promise { - if (options.key) { - const existing = this.entries.find(e => e.key === options.key); - if (existing) { - if (matches(existing, options)) return this.checkout(existing, connect); - await this.drop(existing, `abilities don't match a new request for key "${options.key}"`); - } - return this.createEntry(options.key, connect, onFreshEntry); - } - - const free = this.entries.find(e => !e.checkedOut && matches(e, options)); - if (free) return this.checkout(free, connect); - - if (this.entries.length >= this.maxPlayers) { - const victim = this.entries - .filter(e => !e.checkedOut) - .sort((a, b) => a.lastUsedAt - b.lastUsedAt)[0]; - if (!victim) { - throw new Error( - `PlayerRegistry: maxPlayers=${this.maxPlayers} reached and every entry is checked out ` + - 'by the current test. Request fewer simultaneous players, or raise tests.reuse.maxPlayers.' - ); - } - await this.drop(victim, `evicted: maxPlayers=${this.maxPlayers} reached`); - } - - return this.createEntry(derivedKey(options), connect, onFreshEntry); - } - - /** Returns a checked-out entry to the free pool. `stay: false` parks it on the way out — - * the connection goes, the entry stays, and the next checkout rejoins it. No-op for a - * player this registry doesn't own. */ - async release(player: PlayerWrapper, stay: boolean = true): Promise { - const entry = this.entries.find(e => e.player === player); - if (!entry) return; - entry.checkedOut = false; - entry.lastUsedAt = Date.now(); - - if (stay || entry.parked) return; - entry.parked = true; - console.log(pc.dim(`[Reuse] ${entry.player.username} parked (stay: false — rejoins when a later test takes it)`)); - await this.session.disconnectBot(entry.player.bot, entry.player.username); - this.session.removeBot(entry.player.bot); - } - - /** Drops a broken or disqualified entry: disconnects it, returns its account, forgets it. - * No-op for a player this registry doesn't own. */ - async invalidate(player: PlayerWrapper, reason: string = 'invalidated'): Promise { - const entry = this.entries.find(e => e.player === player); - if (entry) await this.drop(entry, reason); - } - - /** Disconnects and forgets every entry — end-of-run teardown. */ - async disconnectAll(): Promise { - for (const entry of [...this.entries]) await this.drop(entry, 'session teardown'); - } - - /** An entry that isn't connected — parked on purpose, or dropped by the server — is - * transparently rejoined before it's handed out: a bot picked back up by the registry is - * otherwise indistinguishable from one that's still live, and the test has no reason to - * expect it might not be. A failed rejoin falls back to a fresh entry under the same key, - * same as a first-time miss. */ - private async checkout(entry: RegistryEntry, connect: () => Promise): Promise { - const parked = entry.parked; - if (parked || (entry.player.bot as any)._client?.ended) { - try { - // The same gate a first connection passes through: a rejoin is just another - // login as far as a shared server's join throttle is concerned, and `stay: false` - // turns every single test into one. - await this.session.env.beforeJoin?.(); - await entry.player.rejoin(); - } catch (error) { - await this.drop(entry, `${parked ? 'parked' : 'dead connection'}, rejoin failed: ${(error as Error).message}`); - return this.createEntry(entry.key, connect); - } - entry.parked = false; - } - - entry.checkedOut = true; - const labels = [...entry.player.abilities].join(', ') || '-'; - const how = parked ? 'from registry, rejoined' : 'from registry'; - console.log(pc.dim(`[Reuse] ${entry.player.username} ${how} (key "${entry.key}", abilities: ${labels})`)); - return { player: entry.player, key: entry.key, reused: true }; - } - - private async createEntry( - key: string, - connect: () => Promise, - onFreshEntry?: (key: string, player: PlayerWrapper) => Promise, - ): Promise { - const { player, account, pool } = await connect(); - const entry: RegistryEntry = { key, player, account, pool, checkedOut: true, parked: false, lastUsedAt: Date.now() }; - this.entries.push(entry); - console.log(pc.dim(`[Reuse] ${player.username} new player (key "${key}")`)); - - if (onFreshEntry) { - try { - await onFreshEntry(key, player); - } catch (error) { - await this.drop(entry, `reuseTest failed: ${(error as Error).message}`); - throw error; - } - } - - return { player, key, reused: false }; - } - - private async drop(entry: RegistryEntry, reason: string): Promise { - const idx = this.entries.indexOf(entry); - if (idx !== -1) this.entries.splice(idx, 1); - console.log(pc.dim(`[Reuse] ${entry.player.username} discarded (${reason})`)); - await this.session.disconnectBot(entry.player.bot, entry.player.username); - this.session.removeBot(entry.player.bot); - entry.pool?.release(entry.account); - } -} diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index c38ed81..fc2e1e8 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -48,9 +48,9 @@ export class PlayerWrapper { private _account?: Account; /** Labels describing server state this player is known to carry — set automatically by * `makeOp`/`deOp`/`setGameMode`, and by hand via `mark`/`unmark` for anything else. Survives - * `rejoin()`: it describes server state, which a reconnect doesn't touch. Used by - * `PlayerRegistry` to match a reused player against a test's requirements; the core never - * parses or verifies a label's meaning. */ + * `rejoin()`: it describes server state, which a reconnect doesn't touch. Nothing in the + * core reads a label's meaning; they exist for a test (or a plugin) to leave a note on a + * player one step of a `describe.serial` block can read in the next. */ private readonly _abilities = new Set(); constructor(bot: Bot, session: Session) { diff --git a/runner-package/lib/plugin-host.ts b/runner-package/lib/plugin-host.ts index 13485d4..d1c6da6 100644 --- a/runner-package/lib/plugin-host.ts +++ b/runner-package/lib/plugin-host.ts @@ -76,12 +76,6 @@ export class PluginHost { } } - async onPlayerReuse(player: PlayerWrapper, ctx: { account: Account; env: Environment }): Promise { - for (const { plugin } of this.plugins) { - await plugin.onPlayerReuse?.(player, ctx); - } - } - async beforeEach(ctx: TestContext): Promise { for (const { plugin } of this.plugins) { await plugin.beforeEach?.(ctx); @@ -111,13 +105,13 @@ export class PluginHost { /** 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; reuse?: false }[] { + 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, reuse: t.reuse })) + .map(t => ({ file: t.file, pluginName: plugin.name })) ); } diff --git a/runner-package/lib/plugin.ts b/runner-package/lib/plugin.ts index 3f1aec4..352666c 100644 --- a/runner-package/lib/plugin.ts +++ b/runner-package/lib/plugin.ts @@ -30,10 +30,6 @@ export interface PluginTestRef { * `suite` runs alongside user specs as regular tests, tagged with the plugin's name * in reports. */ mode: 'preflight' | 'suite'; - /** How this file relates to reuse. Absent means "follow the run's general rule". `false` - * forces every test in the file onto a fresh connection — the shape a preflight auth - * check needs, since it exists to prove the login flow, not to skip it. */ - reuse?: false; } export type MatcherFn = (this: any, ...args: any[]) => unknown; @@ -50,10 +46,6 @@ export interface PlugwrightPlugin { * 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; - /** Fired before a reused player is handed to the next test — never on the first connection, - * where `onPlayerCreate` already runs. The core has already done its own safe minimum - * (closing a leftover open window); anything beyond that is the plugin's call. */ - onPlayerReuse?(player: PlayerWrapper, ctx: { account: Account; env: Environment }): Promise | void; beforeEach?(ctx: TestContext): Promise | void; afterEach?(ctx: TestContext): Promise | void; extendContext?(ctx: TestContext): Record | void; diff --git a/runner-package/lib/reporter.ts b/runner-package/lib/reporter.ts index 16bad53..8211386 100644 --- a/runner-package/lib/reporter.ts +++ b/runner-package/lib/reporter.ts @@ -126,7 +126,6 @@ export function writeJsonReport(path: string, environmentName: string, testResul error: r.error ? r.error.message : null, skipReason: r.skipReason ?? null, plugin: r.plugin ?? null, - reuse: r.reuse ?? null, })), }; diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index 5c6374f..8e1525d 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -1,7 +1,6 @@ import mineflayer, { Bot } from 'mineflayer'; import pc from 'picocolors'; import { CleanupJournal } from './journal.js'; -import { PlayerRegistry } from './player-registry.js'; import type { Environment, BotConnectionOptions } from './environment.js'; import type { ServerConsole } from './console.js'; import type { PlayerWrapper } from './player.js'; @@ -56,19 +55,15 @@ export class Session { readonly bots: Bot[] = []; readonly consoleLog = new MessageBuffer(); readonly journal: CleanupJournal; - /** Players that survive a test boundary instead of disconnecting in `finally`. Always - * present; unused unless a test actually asks for reuse (`tests.reuse.enabled`). */ - readonly players: PlayerRegistry; /** 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, reuseMaxPlayers: number = 4) { + constructor(env: Environment, journalPath: string | null = null) { this.env = env; this.journal = new CleanupJournal(journalPath); - this.players = new PlayerRegistry(this, reuseMaxPlayers); } /** Pulls the console channel from the environment. Called once `env.setup()` has produced one. */ @@ -168,9 +163,8 @@ export class Session { }); } - /** Disconnects every bot except those in `keep` (registry-owned bots a test released - * rather than dropped, typically). Called with no argument, this is a full teardown — - * the shape every caller before player reuse existed relied on. + /** Disconnects every bot except those in `keep`. Called with no argument, this is a full + * teardown, which is what the end of a test does. * * Each bot goes through `disconnectBot`, which is also what strips its listeners: a kept * bot is still connected and still listening, so tearing the others down must not be a diff --git a/runner-package/lib/skip-reason.ts b/runner-package/lib/skip-reason.ts index 077d91c..554dbf6 100644 --- a/runner-package/lib/skip-reason.ts +++ b/runner-package/lib/skip-reason.ts @@ -18,10 +18,9 @@ export function missingCapabilities(env: Environment, required: string[]): strin }); } -/** Shared by `runFile`'s `skipReasonFor` (for a normal `TestCase`) and `reuseTest` execution - * (for a `ReuseTestCase`) — both check the same two `TestOptions` fields, `environments` and - * `requires`, against the same running environment. Name filters (`tests.names`/`exclude`) stay - * local to `runFile`: they're a run-level concern, not part of what a test itself declares. */ +/** The two `TestOptions` fields a test itself declares — `environments` and `requires` — + * checked against the running environment. Name filters (`tests.names`/`exclude`) stay local + * to `runFile`: they're a run-level concern, not part of what a test declares. */ export function skipReasonForOptions( env: Environment, environmentName: string, diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 612bd43..eec7f74 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -1,5 +1,4 @@ import type { TestContext } from './types.js'; -import type { ReuseOptions } from './player-registry.js'; export type Hook = (context: TestContext) => Promise | void; type TestFn = (context: TestContext) => Promise; @@ -15,13 +14,6 @@ type TestFn = (context: TestContext) => Promise; export interface TestOptions { requires?: string[]; environments?: string[]; - /** How this test wants its player resolved when `tests.reuse` is on. `false` forces a - * fresh connection regardless of the run's reuse setting — for a test that depends on a - * brand-new nick or the absence of a label another test might have left behind. A string - * is shorthand for `{ key }`. Omitted means "match by ability labels", the default. - * `{ stay }` decides whether the player this test used keeps its connection afterwards, - * overriding the run's `tests.reuse.stay` for this test alone. */ - reuse?: false | string | ReuseOptions; } interface DescribeScope { @@ -40,43 +32,22 @@ export interface TestCase { afterHooks: Hook[]; requires: string[]; environments: string[] | null; - reuse?: false | string | ReuseOptions; } export const testRegistry: TestCase[] = []; export const scopeStack: DescribeScope[] = [{ label: '', beforeHooks: [], afterHooks: [] }]; -/** A reuseTest's body, keyed by the reuse `key` ("pool") it initializes. Carries the same - * `describe`-scoped hooks and `requires`/`environments` filters a regular `TestCase` does — - * a reuseTest is a real test in every way but how it gets triggered. */ -export interface ReuseTestCase { - pool: string; - name: string; - fn: TestFn; - beforeHooks: Hook[]; - afterHooks: Hook[]; - requires: string[]; - environments: string[] | null; -} - -/** One reuseTest per pool, kept for the whole run rather than reset per spec file — - * `PlayerRegistry` entries live for the whole run too, so a pool declared in one file must - * still be found when a later file is the first to actually create that entry. */ -export const reuseTestRegistry = new Map(); - /** 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. `reuseTestRegistry` - * is deliberately NOT cleared here — see its own comment. */ + * 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: [] }); } -/** Everything a registered test needs from the current `describe` scope, shared by `test`/ - * `opTest` (pushed into `testRegistry`) and `reuseTest` (kept in `reuseTestRegistry` instead). */ -function scopedEntry(name: string, options: TestOptions | Omit) { +/** Everything a registered test needs from the current `describe` scope. */ +function scopedEntry(name: string, options: TestOptions) { const labels = scopeStack.map(s => s.label).filter(l => l); return { name: [...labels, name].join(' > '), @@ -88,7 +59,7 @@ function scopedEntry(name: string, options: TestOptions | Omit { - // Only when the resolved player doesn't already carry it: resolution above already - // matched on `op`, so a reused player skips straight to the test body. + registerTest(name, options, async (context: TestContext) => { + // The label is what a player already opped earlier in the same block carries, so a + // second `opTest` in one `describe.serial` doesn't re-run the command. if (!context.player.abilities.has('op')) await context.player.makeOp(); await fn(context); }); } -/** - * Registers a one-time initializer for a reuse pool. `pool` is the same string a test passes - * as `reuse: 'poolName'` (or `reuse: { key: 'poolName' }`) — `reuseTest` runs `fn` against that - * pool's player right when `PlayerRegistry` (re)creates its entry: the very first time any test - * asks for `poolName`, or later if that entry was dropped (rejoin failed, abilities stopped - * matching) and needs to be built again. It does NOT run on an ordinary checkout of an - * already-live entry — that's every other call, which is the common case. - * - * Takes the same scope as `test`/`opTest`: `describe` nesting names it and contributes its - * `beforeEach`/`afterEach` hooks, `requires`/`environments` skip it the same way (reported - * `skipped`, not run — same as a regular test would be), and plugin `beforeEach`/`afterEach` - * and fixtures wrap it too. `reuse` isn't accepted — a reuseTest initializes a pool, it doesn't - * resolve into one itself. - * - * Runs as its own reported test, right before whichever test triggered the (re)creation. If - * `fn` throws, that test fails as a dependency failure and the entry is discarded, so the next - * attempt runs `reuseTest` again instead of handing out a half-initialized player. - */ -export function reuseTest(pool: string, fn: TestFn): void; -export function reuseTest(pool: string, options: Omit, fn: TestFn): void; -export function reuseTest(pool: string, fnOrOptions: TestFn | Omit, maybeFn?: TestFn): void { - const options = typeof fnOrOptions === 'function' ? {} : fnOrOptions; - const fn = typeof fnOrOptions === 'function' ? fnOrOptions : maybeFn!; - - if (reuseTestRegistry.has(pool)) { - throw new Error(`reuseTest: pool "${pool}" is already registered (reuseTest can only be declared once per pool)`); - } - reuseTestRegistry.set(pool, { pool, ...scopedEntry(`reuse:${pool}`, options), fn }); -} - export function describe(label: string, fn: () => void): void { scopeStack.push({ label, beforeHooks: [], afterHooks: [] }); try { diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index b7cbbd4..0e58c33 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -8,11 +8,8 @@ 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'; -import { reuseTestRegistry } from './test-registry.js'; -import { skipReasonForOptions } from './skip-reason.js'; import type { TestCase } from './test-registry.js'; import type { TestContext, TestResult } from './types.js'; -import type { ConnectedPlayer, ReuseOptions } from './player-registry.js'; export interface RunTestCaseParams { file: string; @@ -21,49 +18,18 @@ export interface RunTestCaseParams { plugins: PluginHost; connOpts: BotConnectionOptions; timeoutMs: number; - /** The running environment's configured name — `TestOptions.environments` on a `reuseTest` - * is checked against this, same as `runFile`'s own `skipReasonFor` checks it for a - * regular `TestCase`. */ - environmentName: string; /** Set when this test came from a plugin's inherited `tests`, for report labeling. */ pluginName?: string | null; - /** Whole-run setting: `tests.reuse.enabled` narrowed by the environment's - * `capabilities.playerReuse`. `false` reproduces the pre-reuse behavior exactly, down to - * the absence of `TestResult.reuse`. */ - reuseEnabled?: boolean; - /** Whole-run default for `ReuseOptions.stay`: does a registry player keep its connection - * once this test is done, or does it park until a later test rejoins it. `'rejoin'` is the - * environment's own `capabilities.playerReuse` saying it can't hold an idle bot at all — - * a test asking for `stay: true` doesn't get to lift that. */ - reuseStay?: boolean | 'rejoin'; - /** Set for a plugin test file declaring `PluginTestRef.reuse === false` — forces a fresh - * connection for every test in the file regardless of `reuseEnabled` or the test's own - * `reuse` option. */ - forceReuseOff?: boolean; - /** Reports a `reuseTest`'s own result, whenever one runs as a dependency of this test case. - * Called before `runTestCase` resolves, so the caller can append it to the report ahead of - * the test case's own result. */ - onExtraResult?: (result: TestResult) => void; -} - -/** Normalizes a `TestOptions.reuse` value to what `PlayerRegistry.resolve` takes. */ -function normalizeReuse(reuse: false | string | ReuseOptions | undefined): false | ReuseOptions { - if (reuse === false) return false; - if (reuse === undefined) return {}; - if (typeof reuse === 'string') return { key: reuse }; - return reuse; } /** - * Runs one test case end to end: resolves the primary bot (a fresh connection, or — when - * reuse applies — a registry lookup that may hand back a player from an earlier test), 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. + * Runs one test case end to end: connects the primary bot, 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, environmentName, pluginName = null, reuseEnabled = false, reuseStay = true, forceReuseOff = false, onExtraResult } = params; + const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null } = params; console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); session.consoleLog.clear(); @@ -71,26 +37,12 @@ export async function runTestCase(params: RunTestCaseParams): Promise void | Promise> = []; - // Accounts leased outside the registry (a bypass `createPlayer({ username })`, or any - // player created while reuse doesn't apply to this test) — returned in `finally` below, - // same as before player reuse existed. - const adhocAccounts: Array<{ account: Account; pool: AccountPool }> = []; - // Players this test drew from the registry, with the `stay` each was taken under, so - // `finally` knows what to release, what to park and what to drop. - const registryPlayers: Array<{ player: PlayerWrapper; stay: boolean }> = []; - const invalidated = new Set(); - const reuseEffective = reuseEnabled && !forceReuseOff; - let primaryReuse: { key: string; reused: boolean; stay: boolean } | null = null; - - /** The run's `stay` unless the request overrides it — except under `'rejoin'`, where the - * environment has said an idle bot doesn't survive and no test gets to disagree. */ - const stayFor = (options: ReuseOptions): boolean => - reuseStay === 'rejoin' ? false : options.stay ?? reuseStay; + // Accounts leased for this test, returned to the pool in `finally` below. + const leasedAccounts: Array<{ account: Account; pool: AccountPool }> = []; // The actual connect: leases an account (or generates a throwaway identity), joins the - // server, and returns the wrapper. Used directly for a fresh connection, and passed to - // the registry as the "nothing free matched" fallback. - const connectNewPlayer = async (options?: { username?: string }): Promise => { + // server, and returns the wrapper. + const connectNewPlayer = async (options?: { username?: string }): Promise => { const pool = options?.username ? null : session.env.accounts?.() ?? null; const account: Account = pool ? await pool.lease() @@ -115,186 +67,42 @@ export async function runTestCase(params: RunTestCaseParams): Promise => { - const reuseCase = reuseTestRegistry.get(poolKey); - if (!reuseCase) return; - - const skipReason = skipReasonForOptions(session.env, environmentName, reuseCase.requires, reuseCase.environments); - if (skipReason) { - // Same as a filtered-out regular test: skipped, not failed. A player handed out - // under a pool whose reuseTest doesn't apply here still connects — it's just never - // initialized, same as if no reuseTest had been declared for it at all. - console.log(` Test: ${reuseCase.name} - SKIPPED (${skipReason})`); - onExtraResult?.({ file, testName: reuseCase.name, passed: true, durationMs: 0, skipped: true, skipReason, plugin: pluginName }); - return; - } - - console.log(` ${pc.bold(`Test: ${reuseCase.name}`)}`); - const reuseAbort = new AbortController(); - const reuseFinalizers: Array<() => void | Promise> = []; - const reuseCtx: TestContext = { - player: reusePlayer, - server, - createPlayer, - invalidatePlayer: (p: PlayerWrapper) => { invalidated.add(p); }, - signal: reuseAbort.signal, - cleanup: (fn: () => void | Promise) => { reuseFinalizers.push(fn); }, - }; - plugins.extendContext(reuseCtx); - - const start = Date.now(); - let timeoutHandle: ReturnType; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => { - reuseAbort.abort(); - reject(new Error(`reuseTest "${poolKey}" timed out after ${timeoutMs}ms`)); - }, timeoutMs); - }); - - // Same hook order as a regular test's body: plugin beforeEach → spec beforeEach → fn → - // finalizers → spec afterEach → plugin afterEach. - const body = (async (): Promise => { - await plugins.beforeEach(reuseCtx); - for (const hook of reuseCase.beforeHooks) await hook(reuseCtx); - - let testError: unknown; - try { - await reuseCase.fn(reuseCtx); - } catch (e) { - testError = e; - } finally { - for (const finalizer of [...reuseFinalizers].reverse()) { - try { - await finalizer(); - } catch (e) { - console.error(pc.red(`[cleanup] reuseTest "${poolKey}" finalizer error: ${(e as Error).message}`)); - } - } - for (const hook of reuseCase.afterHooks) { - try { - await hook(reuseCtx); - } catch (e) { - testError ??= e; - console.error(pc.red(`[afterEach] reuseTest "${poolKey}" hook error: ${(e as Error).message}`)); - } - } - await plugins.afterEach(reuseCtx); - } - if (testError) throw testError; - })().finally(() => clearTimeout(timeoutHandle)); - - try { - await Promise.race([body, timeoutPromise]); - const durationMs = Date.now() - start; - console.log(` ${pc.green(pc.bold('PASSED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}\n`); - onExtraResult?.({ file, testName: reuseCase.name, passed: true, durationMs, plugin: pluginName }); - } catch (error) { - const durationMs = Date.now() - start; - const errorMsg = (error as Error).message; - console.log(` ${pc.red(pc.bold('FAILED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}: ${pc.red(errorMsg)}\n`); - onExtraResult?.({ file, testName: reuseCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName }); - throw error; - } - }; - - /** `ctx.player` and `ctx.createPlayer` both funnel through here. `usernameOverride` is - * `createPlayer({ username })` — a specific identity, which always bypasses both the - * account pool and the registry, same as before reuse existed. */ - const resolvePlayer = async ( - usernameOverride: string | undefined, - reuseRequest: false | string | ReuseOptions | undefined, - ): Promise<{ player: PlayerWrapper; key: string; reused: boolean } | { player: PlayerWrapper; key: null; reused: false }> => { - if (usernameOverride) { - const { player } = await connectNewPlayer({ username: usernameOverride }); - return { player, key: null, reused: false }; - } - - const normalized = normalizeReuse(reuseRequest); - if (!reuseEffective || normalized === false) { - const { player, account, pool } = await connectNewPlayer(); - if (pool) adhocAccounts.push({ account, pool }); - return { player, key: null, reused: false }; - } - - const result = await session.players.resolve( - normalized, - () => connectNewPlayer(), - normalized.key ? (key, p) => runReuseTestCase(key, p) : undefined, - ); - registryPlayers.push({ player: result.player, stay: stayFor(normalized) }); - - if (result.reused) { - // Core's own safe minimum for a player coming back from a previous test — anything - // beyond this is the plugin's domain via onPlayerReuse. - const openWindow = result.player.bot.currentWindow; - if (openWindow) { - try { result.player.bot.closeWindow(openWindow); } catch { /* best effort */ } - } - - // `rejoin` clears the buffer on its way back in, but a player checked out under - // `stay` never left and so never rejoined: without this, the chat it saw in the - // previous test would still satisfy assertions in this one. - result.player.clearMessages(); - await plugins.onPlayerReuse(result.player, { account: result.player.account!, env: session.env }); - } - - return { player: result.player, key: result.key, reused: result.reused }; - }; - - const createPlayer = async (options?: { username?: string; reuse?: false | string | ReuseOptions }): Promise => { - const { player } = await resolvePlayer(options?.username, options?.reuse); - return player; - }; + const createPlayer = async (options?: { username?: string }): Promise => + connectNewPlayer({ username: options?.username }); const testStartTime = Date.now(); let player: PlayerWrapper; try { - const resolved = await resolvePlayer(undefined, testCase.reuse); - player = resolved.player; - if (resolved.key !== null) { - const normalized = normalizeReuse(testCase.reuse); - primaryReuse = { key: resolved.key, reused: resolved.reused, stay: stayFor(normalized === false ? {} : normalized) }; - } + player = await connectNewPlayer(); } catch (error) { - // The player never resolved — most likely this test's `reuseTest` dependency just - // failed (see `runReuseTestCase`) and `PlayerRegistry` already discarded the half-built - // entry. Reported as this test failing too, same as any other dependency failure. 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, - reuse: reuseEnabled ? { key: 'none', reused: false, stay: false, abilities: [] } : undefined, - }; + for (const { account, pool } of leasedAccounts) pool.release(account); + return { file, testName: testCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName }; } + const abortController = new AbortController(); const ctx: TestContext = { player, server, createPlayer, - invalidatePlayer: (p: PlayerWrapper) => { invalidated.add(p); }, signal: abortController.signal, cleanup: (fn: () => void | Promise) => { finalizers.push(fn); }, }; plugins.extendContext(ctx); - let testPassed = false; - try { let timeoutHandle: ReturnType; const timeoutPromise = new Promise((_, reject) => { @@ -338,38 +146,16 @@ export async function runTestCase(params: RunTestCaseParams): Promise clearTimeout(timeoutHandle)), timeoutPromise]); - testPassed = true; 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, reuse: reportedReuse() }; + 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, reuse: reportedReuse() }; + return { file, testName: testCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName }; } finally { - // A failed or timed-out test hands nothing forward: one bad test turning into a - // cascade of unrelated failures would put the real cause somewhere other than the - // report points at. - for (const { player: p, stay } of registryPlayers) { - const dead = !!(p.bot as any)._client?.ended; - if (!testPassed || dead || invalidated.has(p)) { - await session.players.invalidate(p, !testPassed ? 'test failed' : dead ? 'connection dead' : 'invalidated by test'); - } else { - // `stay: false` disconnects here too, but keeps the entry: the next test that - // asks for this shape of player gets the same identity back, rejoined. - await session.players.release(p, stay); - } - } - // Keep every bot the registry owns, not just the ones this test happened to touch — - // a free entry another test will pick up later is not this test's to disconnect. - await session.disconnectAllBots(session.players.ownedBots()); - for (const { account, pool } of adhocAccounts) pool.release(account); - } - - function reportedReuse(): TestResult['reuse'] { - if (!reuseEnabled) return undefined; - if (!primaryReuse) return { key: 'none', reused: false, stay: false, abilities: [] }; - return { key: primaryReuse.key, reused: primaryReuse.reused, stay: primaryReuse.stay, abilities: [...player.abilities] }; + await session.disconnectAllBots(); + for (const { account, pool } of leasedAccounts) pool.release(account); } } diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index a0a2195..3409ccf 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -1,14 +1,10 @@ import type { PlayerWrapper } from './player.js'; import type { ServerWrapper } from './server.js'; -import type { ReuseOptions } from './player-registry.js'; export interface TestContext { player: PlayerWrapper; server: ServerWrapper; - createPlayer: (options?: { username?: string; reuse?: false | string | ReuseOptions }) => Promise; - /** Marks a player unfit for the next test: it disconnects instead of being handed out - * again. No-op for a player reuse never picked up (a plain fresh connection). */ - invalidatePlayer: (player: PlayerWrapper) => void; + 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. */ @@ -27,7 +23,4 @@ export interface TestResult { skipReason?: string; /** Name of the plugin this test was inherited from, or null for a user spec. */ plugin?: string | null; - /** How the primary player was obtained, and whether it stayed connected afterwards. - * Absent when reuse is off for this run. */ - reuse?: { key: string; reused: boolean; stay: boolean; abilities: string[] }; -} \ No newline at end of file +} diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 351ae2e..d3c712a 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -29,14 +29,12 @@ installSourceMapSupport(); export { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator }; export { PlayerWrapper }; export { ServerWrapper } from './lib/server.js'; -export { test, opTest, reuseTest, describe, beforeEach, afterEach } from './lib/test-registry.js'; +export { test, opTest, describe, beforeEach, afterEach } 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, PluginConfig, ReuseConfig } from './lib/config.js'; +export type { RunnerConfig, EnvironmentConfig, TestsConfig, LocalEnvironmentConfig, SecretRef, PluginConfig } from './lib/config.js'; export type { TestContext, TestResult } from './lib/types.js'; -export { PlayerRegistry } from './lib/player-registry.js'; -export type { ReuseOptions } from './lib/player-registry.js'; export type { Environment, EnvironmentCapabilities, BotConnectionOptions } from './lib/environment.js'; export type { ServerConsole } from './lib/console.js'; export { Session } from './lib/session.js'; @@ -94,37 +92,6 @@ async function findSpecFiles(dir: string): Promise { return results; } -/** `tests.reuse`, narrowed by the environment's own `capabilities.playerReuse`. An environment - * that can't tolerate a long-lived bot always wins over the config — outright when it declares - * `false`, and down to a rejoin per test when it declares `'rejoin'`. */ -function resolveReuse(config: RunnerConfig, env: Environment): { enabled: boolean; maxPlayers: number; stay: boolean | 'rejoin' } { - const requested = config.tests.reuse?.enabled ?? false; - if (!requested) return { enabled: false, maxPlayers: 4, stay: true }; - - if (env.capabilities.playerReuse === false) { - console.log(pc.yellow( - `[Reuse] tests.reuse.enabled is true, but environment "${config.environment.name}" declares ` + - 'capabilities.playerReuse = false — running with reuse off for this environment.' - )); - return { enabled: false, maxPlayers: 4, stay: true }; - } - - const maxPlayers = config.tests.reuse?.maxPlayers - ?? Math.max(1, (env.accounts?.()?.capacity() ?? 5) - 1); - - if (env.capabilities.playerReuse === 'rejoin') { - if (config.tests.reuse?.stay ?? true) { - console.log(pc.yellow( - `[Reuse] environment "${config.environment.name}" declares capabilities.playerReuse = 'rejoin' — ` + - 'players leave at the end of every test and rejoin when a later one takes them, whatever tests.reuse.stay says.' - )); - } - return { enabled: true, maxPlayers, stay: 'rejoin' }; - } - - return { enabled: true, maxPlayers, stay: config.tests.reuse?.stay ?? true }; -} - export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): Promise { const testFileFilters = config.tests.include ?? null; const testNameFilters = config.tests.names ?? null; @@ -134,12 +101,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): const testResults: TestResult[] = []; const env = await resolveEnvironment(config.environment); - const reuse = resolveReuse(config, env); - const session = new Session(env, config.journal ?? null, reuse.maxPlayers); - if (reuse.enabled) { - const stayLabel = reuse.stay === 'rejoin' ? "stay=false (environment's own 'rejoin')" : `stay=${reuse.stay}`; - console.log(pc.dim(`[Reuse] enabled, maxPlayers=${reuse.maxPlayers}, ${stayLabel}`)); - } + 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. @@ -174,7 +136,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): /** 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, forceReuseOff: boolean = false): Promise { + async function runFile(file: string, pluginName: string | null): Promise { resetRegistry(); await import(pathToFileURL(file).href); @@ -186,22 +148,17 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): continue; } - const result = await runTestCase({ - file, testCase, session, plugins, connOpts, timeoutMs, pluginName, - environmentName: config.environment.name, - reuseEnabled: reuse.enabled, reuseStay: reuse.stay, forceReuseOff, - onExtraResult: r => testResults.push(r), - }); + 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, reuse: fileReuse } of plugins.testFiles('preflight')) { + 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, fileReuse === false); + 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'}`); @@ -230,33 +187,17 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): } // Suite: plugin tests that run alongside user specs, tagged with the plugin's name. - for (const { file, pluginName, reuse: fileReuse } of plugins.testFiles('suite')) { + 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, fileReuse === false); + await runFile(file, pluginName); } } finally { await plugins.runCleanup(session, 'session'); await plugins.teardown(); - // Registry-owned bots first: it disconnects and forgets each entry, so the plain - // sweep after it only has to deal with whatever was never handed to the registry. - await session.players.disconnectAll(); await session.disconnectAllBots(); await env.teardown(); - if (reuse.enabled) { - const reusedCount = testResults.filter(r => r.reuse?.reused).length; - if (reusedCount > 0) { - // Under `stay: false` the connection is not what carried over — the identity is, - // and the bot rejoined under it — so the count is worth reporting either way. - const rejoined = testResults.filter(r => r.reuse?.reused && !r.reuse.stay).length; - const how = rejoined === reusedCount ? 'a registry player, rejoined' - : rejoined > 0 ? `a registry player (${rejoined} of them rejoined)` - : 'an existing connection instead of reconnecting'; - console.log(pc.dim(`[Reuse] ${reusedCount} test(s) reused ${how}`)); - } - } - if (config.reports?.json) { writeJsonReport(config.reports.json, config.environment.name, testResults); console.log(pc.dim(`JSON report: ${config.reports.json}`)); From eb4717920b62543c44f1363e40e2a4fa040307b4 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 27 Aug 2026 22:28:44 +0300 Subject: [PATCH 062/125] feat(accounts): let a name come from the environment, not a counter Where a bot's identity comes from is the environment's business. A local server lives for one run and can hand out a name nobody has used before; a stand runs on accounts somebody provisioned, and cannot invent one. Local bots are now pw_ instead of Test_, short enough to read in a log and in the same shape as a stand account. For autoRegister the placeholder in usernamePattern decides: %d keeps today's fixed slots, which a test inherits whatever the last one left on, and %s generates a fresh name per lease that the pool never hands out again. max stays the number of bots connected at once either way. Refs #53 --- .../src/test/e2e/tests/economy.spec.ts | 4 +- .../plugwright/external/AccountsSpec.kt | 19 ++++- runner-package/lib/account.ts | 70 ++++++++++++++----- runner-package/lib/test-runner.ts | 5 +- 4 files changed, 72 insertions(+), 26 deletions(-) diff --git a/example_plugin/src/test/e2e/tests/economy.spec.ts b/example_plugin/src/test/e2e/tests/economy.spec.ts index 5feedf7..c86a76a 100644 --- a/example_plugin/src/test/e2e/tests/economy.spec.ts +++ b/example_plugin/src/test/e2e/tests/economy.spec.ts @@ -7,7 +7,7 @@ test('player starts with default balance', async ({ player }) => { test('player can send money', async ({ player, server }) => { server.execute(`eco give ${player.username} 500`); - player.chat('/pay Test_xx 100'); + player.chat('/pay pw_dummy 100'); await expect(player).toHaveReceivedMessage('Sent $100'); player.chat('/balance'); @@ -15,6 +15,6 @@ test('player can send money', async ({ player, server }) => { }); test('cannot send more money than balance', async ({ player }) => { - player.chat('/pay Test_xx 999999'); + player.chat('/pay pw_dummy 999999'); await expect(player).toHaveReceivedMessage('insufficient'); }); 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 index 90b039f..edf5a39 100644 --- 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 @@ -20,12 +20,25 @@ class PoolSpec(private val objects: ObjectFactory) { } /** `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. */ + * accounts on demand, up to [max] connected 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. */ + /** + * Must start with `pw_` — generated accounts have to be recognizable as test accounts, the + * same convention the cleanup journal requires of entities it creates. + * + * The placeholder decides what happens to a name once the test holding it finishes. + * `%d` (optionally zero-padded, `%04d`) numbers a fixed set of accounts the run keeps coming + * back to: cheap, but every test inherits whatever the last one left on that account, so + * anything the stand cannot reset has to stay out of the suite. `%s` puts a random suffix + * there instead (`pw_%s` → `pw_a8f2`) and never reuses a name, which is the only way a test + * gets an account with no history — at the cost of a registration the server keeps after the + * run, so a stand on this shape needs its own way to prune old test accounts. + */ val usernamePattern: Property = objects.property(String::class.java).convention("pw_%04d") val password: Property = objects.property(SecretRef::class.java) + + /** How many generated accounts can be connected at the same time. With `%d` it is also the + * total number of accounts that will ever exist; with `%s` the run keeps making new ones. */ val max: Property = objects.property(Int::class.java).convention(4) } diff --git a/runner-package/lib/account.ts b/runner-package/lib/account.ts index c3715cf..f649573 100644 --- a/runner-package/lib/account.ts +++ b/runner-package/lib/account.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { resolveSecret } from './config.js'; import type { SecretRef } from './config.js'; @@ -16,14 +17,20 @@ export interface Account { } /** - * 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. + * Stand-in used when an environment has no [AccountPool] of its own — a `local` bot is a + * fresh offline-mode connection under a name the server has never seen. */ export function syntheticAccount(username: string): Account { return { username, auth: 'offline', justCreated: true }; } +/** A short random identity suffix. Four hex digits: long enough that two names in a run + * colliding is not a realistic worry, short enough to leave room under Minecraft's 16-character + * username limit for whatever prefix the pattern puts in front of it. */ +export function randomSuffix(): string { + return randomUUID().slice(0, 4); +} + /** 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. */ @@ -35,20 +42,34 @@ export interface AccountsConfig { 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. */ +/** True for a pattern that asks for a random suffix (`pw_%s`) rather than a sequence number + * (`pw_%04d`). The difference is what the pool does with a name once a test is done with it — + * see [AccountPool.release]. */ +function isUniquePattern(pattern: string): boolean { + return pattern.includes('%s'); +} + +/** Formats an auto-register username. `%s` becomes a random suffix, `%d` (optionally + * zero-padded, `%04d`) the sequence number. No other printf feature is supported. */ 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; - }); + return pattern + .replace(/%s/, randomSuffix()) + .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`. + * `autoRegister` names, and `microsoft` accounts for an online-mode server. Accounts are leased + * per test and returned in `finally` — see `test-runner.ts`. + * + * `autoRegister` has two shapes, told apart by the pattern. A numbered one (`pw_%04d`) is a + * fixed set of slots: a name comes back to the pool when the test that held it is done, and the + * next test gets that same account, already registered. A `%s` pattern generates a name per + * lease and never hands it out again, so a test starts on an account the server has never seen — + * at the price of a registration the server keeps. * * 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 @@ -60,7 +81,10 @@ export class AccountPool { * 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: SecretRef; max: number } | null; + private readonly autoRegister: { usernamePattern: string; password: SecretRef; max: number; unique: boolean } | null; + /** Names handed out by a `%s` pattern and still checked out. Kept so [release] can tell a + * one-shot identity from a numbered slot without a flag on [Account] itself. */ + private readonly uniqueOut = new Set(); constructor(config: AccountsConfig | null | undefined) { for (const entry of config?.pool ?? []) { @@ -79,12 +103,13 @@ export class AccountPool { usernamePattern: config.autoRegister.usernamePattern, password: config.autoRegister.password, max: config.autoRegister.max, + unique: isUniquePattern(config.autoRegister.usernamePattern), } : null; } - /** Total configured slots (pool + microsoft + autoRegister's max), not the number - * currently free. */ + /** Total slots that can be checked out at once: pool + microsoft + `autoRegister`'s max. + * Not the number currently free. */ capacity(): number { return this.queue.length + (this.autoRegister?.max ?? 0); } @@ -98,9 +123,13 @@ export class AccountPool { : account; } + // For a numbered pattern `autoRegisterIssued` counts names that exist; for a `%s` + // pattern it counts names currently checked out, since released ones are never + // handed back. Either way `max` is the number of bots that can be connected at once. if (this.autoRegister && this.autoRegisterIssued < this.autoRegister.max) { this.autoRegisterIssued++; const username = formatUsername(this.autoRegister.usernamePattern, this.autoRegisterIssued); + if (this.autoRegister.unique) this.uniqueOut.add(username); return { username, password: resolveSecret(this.autoRegister.password), auth: 'offline', justCreated: true }; } @@ -109,10 +138,15 @@ export class AccountPool { ); } - /** 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. */ + /** Returns a leased account, `finally`-style. A numbered `autoRegister` account comes back + * with `justCreated: false` — the server registered it on its first lease, so the auth + * plugin logs in on every lease after. A `%s` account is dropped instead: its name is spent, + * and what comes back is only the slot it occupied. */ release(account: Account): void { + if (this.uniqueOut.delete(account.username)) { + this.autoRegisterIssued--; + return; + } this.queue.push(account.justCreated ? { ...account, justCreated: false } : account); } } diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index 0e58c33..ef909e1 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -1,9 +1,8 @@ -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 { randomSuffix, syntheticAccount } from './account.js'; import type { Account, AccountPool } from './account.js'; import type { Session } from './session.js'; import type { PluginHost } from './plugin-host.js'; @@ -46,7 +45,7 @@ export async function runTestCase(params: RunTestCaseParams): Promise Date: Thu, 27 Aug 2026 22:33:33 +0300 Subject: [PATCH 063/125] feat(runner): add describe.serial() for tests that share state on purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Claim a kit, see it on cooldown, see the cooldown expire" is one scenario, not three tests, and three independent bots cannot express it however they are scheduled. A serial block runs its tests in declaration order against one player that stays connected for the block, and releases the account on the way out. The block is the unit, not the test in it. Filters — requires, environments, tests.names, tests.exclude — are checked against every test and skip the whole block if any one of them is out, since a chain missing a link asserts against state nothing produced. A failing or timed-out test skips the rest of its block for the same reason, and ctx.invalidatePlayer() lets a test say so itself. Plugin beforeEach/afterEach run once around the block: a reset plugin firing between the steps would undo what they are built on. Spec beforeEach/afterEach still run per test, and each test starts with a clear message buffer, so chat from an earlier step cannot satisfy an assertion about this one. describe.serial('...', { account: 'pw_0001' }) pins the block to one pool account — an error, not a silent substitution, when that account is taken or the environment has no pool at all. ctx.createPlayer({ as: 'buyer' }) names a second bot so a later test in the block gets the same one back. Refs #53 --- .../src/test/e2e/tests/kits.spec.ts | 34 +- runner-package/lib/account.ts | 26 +- runner-package/lib/test-registry.ts | 90 ++++- runner-package/lib/test-runner.ts | 346 ++++++++++++++---- runner-package/lib/types.ts | 9 +- runner-package/runner.ts | 38 +- 6 files changed, 442 insertions(+), 101 deletions(-) diff --git a/example_plugin/src/test/e2e/tests/kits.spec.ts b/example_plugin/src/test/e2e/tests/kits.spec.ts index dd250c7..93b3310 100644 --- a/example_plugin/src/test/e2e/tests/kits.spec.ts +++ b/example_plugin/src/test/e2e/tests/kits.spec.ts @@ -1,17 +1,27 @@ -import { test, expect } from '@plugwright/runner'; +import { describe, test, expect, sleep } from '@plugwright/runner'; -test('starter kit gives items', async ({ player }) => { - player.chat('/kit starter'); - - await expect(player).toHaveReceivedMessage('Received starter kit'); - await expect(player).toContainItem('diamond_sword'); - await expect(player).toContainItem('bread'); -}); +// One player, three steps: what the second and third assert only exists because the first ran. +// Three independent bots could not express it, however they were scheduled. +describe.serial('kit lifecycle', () => { + test('claims the starter kit', async ({ player }) => { + player.chat('/kit starter'); + + await expect(player).toHaveReceivedMessage('Received starter kit'); + await expect(player).toContainItem('diamond_sword'); + await expect(player).toContainItem('bread'); + }); + + test('is on cooldown right after', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('cooldown'); + }); + + test('can claim again once the cooldown expires', async ({ player }) => { + await sleep(5000); -test('kit has cooldown', async ({ player }) => { - player.chat('/kit starter'); - player.chat('/kit starter'); - await expect(player).toHaveReceivedMessage('cooldown'); + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Received starter kit'); + }); }); // Op bypasses permission checks in Bukkit by default, so this only proves anything against a diff --git a/runner-package/lib/account.ts b/runner-package/lib/account.ts index f649573..0c4685e 100644 --- a/runner-package/lib/account.ts +++ b/runner-package/lib/account.ts @@ -85,12 +85,17 @@ export class AccountPool { /** Names handed out by a `%s` pattern and still checked out. Kept so [release] can tell a * one-shot identity from a numbered slot without a flag on [Account] itself. */ private readonly uniqueOut = new Set(); + /** Every declared `pool`/`microsoft` name, free or not — so a request for one by name can + * say whether it is taken or was never configured at all. */ + private readonly declaredNames = new Set(); constructor(config: AccountsConfig | null | undefined) { for (const entry of config?.pool ?? []) { + this.declaredNames.add(entry.username); this.queue.push({ username: entry.username, secret: entry.password, auth: 'offline', justCreated: false }); } for (const username of config?.microsoft?.accounts ?? []) { + this.declaredNames.add(username); this.queue.push({ username, auth: 'microsoft', @@ -114,7 +119,13 @@ export class AccountPool { return this.queue.length + (this.autoRegister?.max ?? 0); } - async lease(): Promise { + /** Leases the next free account, or the one named by [username] — a `describe.serial` block + * that has to run as one specific account. A name that is taken, or was never declared, + * throws: quietly substituting another account is how a test ends up asserting against + * state that belongs to somebody else. */ + async lease(username?: string): Promise { + if (username !== undefined) return this.leaseNamed(username); + const entry = this.queue.shift(); if (entry) { const { secret, ...account } = entry; @@ -138,6 +149,19 @@ export class AccountPool { ); } + private leaseNamed(username: string): Account { + const idx = this.queue.findIndex(e => e.username === username); + if (idx === -1) { + throw new Error(this.declaredNames.has(username) + ? `Account "${username}" is already leased by another test` + : `Account "${username}" is not in this environment's accounts pool`); + } + const { secret, ...account } = this.queue.splice(idx, 1)[0]; + return secret && account.password === undefined + ? { ...account, password: resolveSecret(secret) } + : account; + } + /** Returns a leased account, `finally`-style. A numbered `autoRegister` account comes back * with `justCreated: false` — the server registered it on its first lease, so the auth * plugin logs in on every lease after. A `%s` account is dropped instead: its name is spent, diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index eec7f74..6ec04ec 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -34,9 +34,35 @@ export interface TestCase { environments: string[] | null; } -export const testRegistry: TestCase[] = []; +/** What a `describe.serial` block accepts beyond the usual filters. */ +export interface SerialOptions extends TestOptions { + /** Run the whole block on this pool account instead of whichever one is free. For a stand + * where one specific account is the one carrying the state a test needs — a permission + * group, a starting balance. Fails the block on an environment with no account pool. */ + account?: string; +} + +/** A `describe.serial` block: its tests run in declaration order, on one player, and the block + * is what the runner schedules and filters — not the tests inside it. */ +export interface SerialBlock { + name: string; + account: string | null; + tests: TestCase[]; + requires: string[]; + environments: string[] | null; +} + +export type RegistryItem = + | { kind: 'test'; testCase: TestCase } + | { kind: 'serial'; block: SerialBlock }; + +export const testRegistry: RegistryItem[] = []; export const scopeStack: DescribeScope[] = [{ label: '', beforeHooks: [], afterHooks: [] }]; +/** The `describe.serial` block being registered, if any. Tests declared while it is set go + * into it instead of straight into `testRegistry`. */ +let currentBlock: SerialBlock | null = null; + /** 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. */ @@ -44,6 +70,7 @@ export function resetRegistry(): void { testRegistry.length = 0; scopeStack.length = 0; scopeStack.push({ label: '', beforeHooks: [], afterHooks: [] }); + currentBlock = null; } /** Everything a registered test needs from the current `describe` scope. */ @@ -59,7 +86,12 @@ function scopedEntry(name: string, options: TestOptions) { } function registerTest(name: string, options: TestOptions, fn: TestFn): void { - testRegistry.push({ ...scopedEntry(name, options), fn }); + const testCase = { ...scopedEntry(name, options), fn }; + if (currentBlock) { + currentBlock.tests.push(testCase); + } else { + testRegistry.push({ kind: 'test', testCase }); + } } export function test(name: string, fn: TestFn): void; @@ -85,7 +117,7 @@ export function opTest(name: string, fnOrOptions: TestFn | TestOptions, maybeFn? }); } -export function describe(label: string, fn: () => void): void { +function describeImpl(label: string, fn: () => void): void { scopeStack.push({ label, beforeHooks: [], afterHooks: [] }); try { fn(); @@ -94,6 +126,58 @@ export function describe(label: string, fn: () => void): void { } } +/** + * Registers a block whose tests run in the order they are declared, against one player that + * stays connected for the whole block — the shape a scenario needs when one step only means + * something after the one before it ("claim a kit, see it on cooldown, see the cooldown + * expire"). Everything outside such a block is still an independent test with its own bot. + * + * The block, not the test, is what filters apply to: `requires`, `environments` and the run's + * name filters are checked against every test in it, and one exclusion skips the whole block + * rather than leaving a broken chain behind. A failing test skips the rest of its block for the + * same reason — the tests after it were written to run on what it was supposed to leave. + * + * Plugin `beforeEach`/`afterEach` run once around the block, not around each test in it: a + * plugin that resets an account between tests would undo exactly what the block is built on. + * `beforeEach`/`afterEach` declared in the spec still run for every test. + */ +function serialImpl(label: string, optionsOrFn: SerialOptions | (() => void), maybeFn?: () => void): void { + const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn; + const fn = typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn!; + + if (currentBlock) { + throw new Error(`describe.serial: "${label}" is nested inside serial block "${currentBlock.name}" — a block cannot contain another`); + } + + const labels = scopeStack.map(s => s.label).filter(l => l); + const block: SerialBlock = { + name: [...labels, label].join(' > '), + account: options.account ?? null, + tests: [], + requires: options.requires ?? [], + environments: options.environments ?? null, + }; + + currentBlock = block; + scopeStack.push({ label, beforeHooks: [], afterHooks: [] }); + try { + fn(); + } finally { + scopeStack.pop(); + currentBlock = null; + } + + testRegistry.push({ kind: 'serial', block }); +} + +interface DescribeApi { + (label: string, fn: () => void): void; + serial(label: string, fn: () => void): void; + serial(label: string, options: SerialOptions, fn: () => void): void; +} + +export const describe: DescribeApi = Object.assign(describeImpl, { serial: serialImpl }); + export function beforeEach(hook: Hook): void { scopeStack[scopeStack.length - 1].beforeHooks.push(hook); } diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index ef909e1..a492ca2 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -7,7 +7,7 @@ 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'; -import type { TestCase } from './test-registry.js'; +import type { SerialBlock, TestCase } from './test-registry.js'; import type { TestContext, TestResult } from './types.js'; export interface RunTestCaseParams { @@ -21,30 +21,44 @@ export interface RunTestCaseParams { pluginName?: string | null; } -/** - * Runs one test case end to end: connects the primary bot, 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(); +export interface RunSerialBlockParams { + file: string; + block: SerialBlock; + session: Session; + plugins: PluginHost; + connOpts: BotConnectionOptions; + timeoutMs: number; + pluginName?: string | null; +} - const server = new ServerWrapper(session); - const finalizers: Array<() => void | Promise> = []; +/** Bots created while one test, or one `describe.serial` block, is running: who leased what, + * which player answers to which `as` name, and how to give it all back. */ +interface BotScope { + connect(options?: { username?: string; account?: string }): Promise; + /** `ctx.createPlayer`. `as` names the player so a later call — a later test, inside a block — + * gets the same bot back instead of connecting a second one. */ + createPlayer(options?: { username?: string; as?: string }): Promise; + /** Every player connected in this scope, in the order they joined. */ + players(): PlayerWrapper[]; + /** Disconnects every bot in the scope and returns the accounts they held. */ + close(): Promise; +} - // Accounts leased for this test, returned to the pool in `finally` below. - const leasedAccounts: Array<{ account: Account; pool: AccountPool }> = []; +function createBotScope(session: Session, server: ServerWrapper, connOpts: BotConnectionOptions): BotScope { + const leased: Array<{ account: Account; pool: AccountPool }> = []; + const named = new Map(); + const connected: PlayerWrapper[] = []; - // The actual connect: leases an account (or generates a throwaway identity), joins the - // server, and returns the wrapper. - const connectNewPlayer = async (options?: { username?: string }): Promise => { + const connect = async (options?: { username?: string; account?: string }): Promise => { const pool = options?.username ? null : session.env.accounts?.() ?? null; + if (options?.account && !pool) { + throw new Error( + `account "${options.account}" was requested, but environment "${session.env.id}" has no accounts pool ` + + 'to take it from — a named account needs one the build script declares.' + ); + } const account: Account = pool - ? await pool.lease() + ? await pool.lease(options?.account) : syntheticAccount(options?.username || `pw_${randomSuffix()}`); try { @@ -66,7 +80,8 @@ export async function runTestCase(params: RunTestCaseParams): Promise => - connectNewPlayer({ username: options?.username }); + return { + connect, + async createPlayer(options): Promise { + const handle = options?.as; + if (handle) { + const existing = named.get(handle); + if (existing) return existing; + } + const player = await connect({ username: options?.username }); + if (handle) named.set(handle, player); + return player; + }, + players: () => [...connected], + async close(): Promise { + await session.disconnectAllBots(); + for (const { account, pool } of leased) pool.release(account); + leased.length = 0; + named.clear(); + connected.length = 0; + }, + }; +} + +interface ExecuteParams { + testCase: TestCase; + ctx: TestContext; + finalizers: Array<() => void | Promise>; + abort: AbortController; + plugins: PluginHost; + timeoutMs: number; + /** Plugin `beforeEach`/`afterEach` wrap a whole `describe.serial` block rather than each of + * its tests, so a block runs them around its first and last test only. */ + pluginBeforeEach: boolean; + pluginAfterEach: boolean; +} + +/** + * Runs one test body with its hooks, and throws whatever failed it. Order is plugin beforeEach → + * spec beforeEach → body → cleanup finalizers → spec afterEach → plugin afterEach. Finalizer + * errors are logged but never flip the result; spec afterEach errors do, matching the runner's + * pre-plugin-host behavior. + */ +async function executeTest(params: ExecuteParams): Promise { + const { testCase, ctx, finalizers, abort, plugins, timeoutMs, pluginBeforeEach, pluginAfterEach } = params; + + let timeoutHandle: ReturnType; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + abort.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 => { + if (pluginBeforeEach) 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 { + 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}`)); + } + } + if (pluginAfterEach) await plugins.afterEach(ctx); + } + if (testError) throw testError; + }; + + await Promise.race([body().finally(() => clearTimeout(timeoutHandle)), timeoutPromise]); +} + +function reportPassed(durationMs: number): void { + console.log(` ${pc.green(pc.bold('PASSED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}\n`); +} + +function reportFailed(durationMs: number, error: Error): void { + console.log(` ${pc.red(pc.bold('FAILED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}: ${pc.red(error.message)}\n`); +} + +/** + * Runs one standalone test case end to end: connects its own bot, builds `TestContext`, runs the + * body, and disconnects everything it created on the way out. + */ +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 testStartTime = Date.now(); + const server = new ServerWrapper(session); + const bots = createBotScope(session, server, connOpts); + const finalizers: Array<() => void | Promise> = []; + const startedAt = Date.now(); let player: PlayerWrapper; try { - player = await connectNewPlayer(); + player = await bots.connect(); } 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`); - for (const { account, pool } of leasedAccounts) pool.release(account); + const durationMs = Date.now() - startedAt; + reportFailed(durationMs, error as Error); + await bots.close(); return { file, testName: testCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName }; } - const abortController = new AbortController(); - + const abort = new AbortController(); const ctx: TestContext = { player, server, - createPlayer, - signal: abortController.signal, + createPlayer: options => bots.createPlayer(options), + invalidatePlayer: () => { /* nothing follows this test — see the serial-block runner */ }, + signal: abort.signal, cleanup: (fn: () => void | Promise) => { finalizers.push(fn); }, }; - plugins.extendContext(ctx); 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); + await executeTest({ + testCase, ctx, finalizers, abort, plugins, timeoutMs, + pluginBeforeEach: true, pluginAfterEach: true, }); + const durationMs = Date.now() - startedAt; + reportPassed(durationMs); + return { file, testName: testCase.name, passed: true, durationMs, plugin: pluginName }; + } catch (error) { + const durationMs = Date.now() - startedAt; + reportFailed(durationMs, error as Error); + return { file, testName: testCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName }; + } finally { + await bots.close(); + } +} - const body = async (): Promise => { - await plugins.beforeEach(ctx); - for (const hook of testCase.beforeHooks) await hook(ctx); +/** + * Runs a `describe.serial` block: one player, one connection, its tests in declaration order. + * + * The block stops at the first test that fails, times out, or calls `invalidatePlayer` — every + * test after it is reported skipped rather than failed, because what they were written against + * is a state the block never reached. Plugin `beforeEach`/`afterEach` wrap the block, not each + * test: a plugin that resets an account between tests would undo what the block is built on. + */ +export async function runSerialBlock(params: RunSerialBlockParams): Promise { + const { file, block, session, plugins, connOpts, timeoutMs, pluginName = null } = params; - 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; - }; + console.log(` ${pc.bold(`Serial block: ${block.name}`)}${block.account ? pc.dim(` (account ${block.account})`) : ''}`); + session.consoleLog.clear(); - await Promise.race([body().finally(() => clearTimeout(timeoutHandle)), timeoutPromise]); + const server = new ServerWrapper(session); + const bots = createBotScope(session, server, connOpts); + const results: TestResult[] = []; - 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 }; + let player: PlayerWrapper; + try { + player = await bots.connect({ account: block.account ?? undefined }); } 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 }; + // Nothing in the block ever ran: the first test carries the failure, the rest are + // skipped the same way they would be after a failure further in. + reportFailed(0, error as Error); + await bots.close(); + return block.tests.map((testCase, index) => index === 0 + ? { file, testName: testCase.name, passed: false, durationMs: 0, error: error as Error, plugin: pluginName } + : { + file, testName: testCase.name, passed: true, durationMs: 0, skipped: true, + skipReason: `serial block "${block.name}" never got its player: ${(error as Error).message}`, + plugin: pluginName, + }); + } + + let stopReason: string | null = null; + // The context object is rebuilt per test — `cleanup` and `signal` are per-test — but every + // one of them carries the same player and the same bot scope. + let lastCtx: TestContext | null = null; + + try { + for (const [index, testCase] of block.tests.entries()) { + if (stopReason) { + console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (${stopReason})`)); + results.push({ + file, testName: testCase.name, passed: true, durationMs: 0, skipped: true, + skipReason: stopReason, plugin: pluginName, + }); + continue; + } + + console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); + // What the block shares is server state, not chat history: a message from the step + // before would otherwise satisfy an assertion about this one. + session.consoleLog.clear(); + for (const p of bots.players()) p.clearMessages(); + + const finalizers: Array<() => void | Promise> = []; + const abort = new AbortController(); + let invalidatedBy: string | null = null; + + const ctx: TestContext = { + player, + server, + createPlayer: options => bots.createPlayer(options), + invalidatePlayer: (p, reason) => { + if (p === player) invalidatedBy = reason ?? `invalidated by "${testCase.name}"`; + }, + signal: abort.signal, + cleanup: (fn: () => void | Promise) => { finalizers.push(fn); }, + }; + plugins.extendContext(ctx); + lastCtx = ctx; + + const startedAt = Date.now(); + try { + await executeTest({ + testCase, ctx, finalizers, abort, plugins, timeoutMs, + pluginBeforeEach: index === 0, + // Once around the block: see the `finally` below, which runs it whether the + // block finished its tests or stopped partway. + pluginAfterEach: false, + }); + const durationMs = Date.now() - startedAt; + reportPassed(durationMs); + results.push({ file, testName: testCase.name, passed: true, durationMs, plugin: pluginName }); + } catch (error) { + const durationMs = Date.now() - startedAt; + reportFailed(durationMs, error as Error); + results.push({ file, testName: testCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName }); + stopReason = `serial block "${block.name}" stopped at "${testCase.name}"`; + continue; + } + + const dead = !!(player.bot as any)._client?.ended; + if (invalidatedBy) { + stopReason = `serial block "${block.name}" stopped: ${invalidatedBy}`; + } else if (dead) { + stopReason = `serial block "${block.name}" stopped: ${player.username} lost its connection`; + } + } } finally { - await session.disconnectAllBots(); - for (const { account, pool } of leasedAccounts) pool.release(account); + if (lastCtx) await plugins.afterEach(lastCtx); + await bots.close(); } + + return results; } diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 3409ccf..6c979f4 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -4,7 +4,14 @@ import type { ServerWrapper } from './server.js'; export interface TestContext { player: PlayerWrapper; server: ServerWrapper; - createPlayer: (options?: { username?: string }) => Promise; + /** Connects an extra bot. Inside a `describe.serial` block, `as` names it: the same name in + * a later test of that block returns the same bot instead of connecting another. Outside a + * block the name is scoped to the one test, which is as long as the bot lives anyway. */ + createPlayer: (options?: { username?: string; as?: string }) => Promise; + /** Says the player is in a state the tests after this one were not written for. Inside a + * `describe.serial` block that stops the block: the rest is reported skipped. Outside one + * it does nothing — the bot is disconnected at the end of the test either way. */ + invalidatePlayer: (player: PlayerWrapper, reason?: string) => void; signal: AbortSignal; /** Registers a LIFO finalizer that always runs after the test body, before afterEach. * Errors are logged but never override the test result. */ diff --git a/runner-package/runner.ts b/runner-package/runner.ts index d3c712a..07f14c7 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -7,7 +7,7 @@ import { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator } from './lib/wr 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 { runSerialBlock, runTestCase } from './lib/test-runner.js'; import { skipReasonForOptions } from './lib/skip-reason.js'; import { LocalEnvironment } from './lib/environments/local.js'; import { externalEnvironment } from './lib/environments/external.js'; @@ -19,7 +19,7 @@ import type { Environment } from './lib/environment.js'; import type { EnvironmentConfig, LocalEnvironmentConfig, RunnerConfig } from './lib/config.js'; import type { ExternalEnvironmentConfig } from './lib/environments/external.js'; import type { TestResult } from './lib/types.js'; -import type { TestCase } from './lib/test-registry.js'; +import type { SerialBlock, TestCase } from './lib/test-registry.js'; import type { Account, AccountPool } from './lib/account.js'; // Enable source map support for accurate TypeScript stack traces @@ -30,7 +30,7 @@ export { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator }; export { PlayerWrapper }; export { ServerWrapper } from './lib/server.js'; export { test, opTest, describe, beforeEach, afterEach } from './lib/test-registry.js'; -export type { TestOptions, TestCase } from './lib/test-registry.js'; +export type { TestOptions, TestCase, SerialOptions, SerialBlock } 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, PluginConfig } from './lib/config.js'; @@ -133,6 +133,20 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): return skipReasonForOptions(env, config.environment.name, testCase.requires, testCase.environments); } + /** Why a whole `describe.serial` block should not run. The block's own `requires` / + * `environments` come first, then the tests inside it: a filter that takes out one step + * of a chain leaves the rest asserting against state nothing produced, so it takes out + * the block instead. */ + function blockSkipReason(block: SerialBlock): string | null { + const own = skipReasonForOptions(env, config.environment.name, block.requires, block.environments); + if (own) return own; + for (const testCase of block.tests) { + const reason = skipReasonFor(testCase); + if (reason) return `"${testCase.name}" ${reason}, and a serial block runs whole or not at all`; + } + return null; + } + /** 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. */ @@ -140,7 +154,23 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): resetRegistry(); await import(pathToFileURL(file).href); - for (const testCase of testRegistry) { + for (const item of testRegistry) { + if (item.kind === 'serial') { + const { block } = item; + const skipReason = blockSkipReason(block); + if (skipReason) { + console.log(pc.dim(` Serial block: ${block.name} - SKIPPED (${skipReason})`)); + for (const testCase of block.tests) { + testResults.push({ file, testName: testCase.name, passed: true, durationMs: 0, skipped: true, skipReason, plugin: pluginName }); + } + continue; + } + + testResults.push(...await runSerialBlock({ file, block, session, plugins, connOpts, timeoutMs, pluginName })); + continue; + } + + const { testCase } = item; const skipReason = skipReasonFor(testCase); if (skipReason) { console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (${skipReason})`)); From d3df705717e962e9ed48cc67e158d3e5bab2eb7b Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 27 Aug 2026 22:35:05 +0300 Subject: [PATCH 064/125] feat(example): reset balance and kit cooldowns on the stand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The excludeTests list on the stand environment was working around a reset that stopped at op and inventory, not around anything a test could not do there. A balance spent by one test stayed spent for the next, so every test that touched one was left out. ExamplePlugin gains the two commands that were missing: `eco set` (give only ever adds) and `kit reset`, admin-only, which clears the cooldown for an online player. stand-reset calls both in its beforeEach, and balance, kit, shop and buy tests come off excludeTests — arena, first join and multi-bot stay, since no command puts back a filled arena slot or an account the server has never seen, and multi-bot's named second bot has no pool password. Refs #53 --- example_plugin/build.gradle.kts | 13 ++++++------ .../me/drownek/example/ExamplePlugin.java | 20 +++++++++++++++++++ .../src/test/e2e/plugins/stand-reset.ts | 11 +++++++++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index ec182fb..3fa7933 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -126,13 +126,14 @@ plugwright { local("stand-reset") } - // Matched against test names. What is left out here is what the stand cannot give - // back: a balance, a kit or an arena slot that is spent once and stays spent. Op - // and inventory are reset per test by the stand-reset plugin instead. multi-bot is - // out for a different reason — it names its second bot, and a named bot is not a - // pool account, so nothing knows its password. + // Matched against test names. What is left out here is what no command puts back: + // an arena slot that is filled once and stays filled, and a first join, which only + // happens on an account the server has never seen. Op, inventory, balance and kit + // cooldowns are reset per test by the stand-reset plugin instead. multi-bot is out + // for a different reason — it names its second bot, and a named bot is not a pool + // account, so nothing knows its password. excludeTests.set(listOf( - "balance", "send money", "kit", "arena", "shop", "buy", "first join", "multi-bot" + "arena", "first join", "multi-bot" )) } } diff --git a/example_plugin/src/main/java/me/drownek/example/ExamplePlugin.java b/example_plugin/src/main/java/me/drownek/example/ExamplePlugin.java index 1707560..27e9d2a 100644 --- a/example_plugin/src/main/java/me/drownek/example/ExamplePlugin.java +++ b/example_plugin/src/main/java/me/drownek/example/ExamplePlugin.java @@ -107,6 +107,12 @@ public boolean onCommand(CommandSender sender, Command command, String label, St String target = args[1]; int amount = Integer.parseInt(args[2]); balances.put(target, balances.getOrDefault(target, 1000) + amount); + } else if (args.length >= 3 && args[0].equalsIgnoreCase("set")) { + // What a stand needs to put an account back where it started: give only ever + // adds, so a balance spent by one test would stay spent for the next one. + String target = args[1]; + balances.put(target, Integer.parseInt(args[2])); + sender.sendMessage("Set balance of " + target + " to $" + args[2]); } return true; } @@ -175,6 +181,20 @@ public boolean onCommand(CommandSender sender, Command command, String label, St p.getInventory().addItem(new ItemStack(Material.BREAD)); } } + } else if (args[0].equalsIgnoreCase("reset") && args.length >= 2) { + // Admin-only, and only meaningful from a console: it exists so a stand can + // hand the next test an account whose kit is claimable again. + if (!sender.isOp()) { + sender.sendMessage("no permission"); + return true; + } + Player target = Bukkit.getPlayerExact(args[1]); + if (target == null) { + sender.sendMessage("Player not found: " + args[1]); + } else { + lastKitUse.remove(target.getUniqueId()); + sender.sendMessage("Kit cooldown reset for " + target.getName()); + } } else if (args[0].equalsIgnoreCase("vip")) { if (!sender.isOp() && !sender.hasPermission("kit.vip")) { sender.sendMessage("no permission"); diff --git a/example_plugin/src/test/e2e/plugins/stand-reset.ts b/example_plugin/src/test/e2e/plugins/stand-reset.ts index 61e4958..ef87768 100644 --- a/example_plugin/src/test/e2e/plugins/stand-reset.ts +++ b/example_plugin/src/test/e2e/plugins/stand-reset.ts @@ -1,11 +1,18 @@ import { definePlugin } from '@plugwright/runner'; +/** What a fresh account starts with, per ExamplePlugin's own default. */ +const STARTING_BALANCE = 1000; + /** * Undoes what one test leaves on a leased account before the next test gets it. * * The local environment never needs this: it hands every test a brand new username on a * server it just created. An external stand has neither — the same four accounts come back - * around all run, still opped and still holding whatever the last test gave them. + * around all run, still opped, still holding whatever the last test gave them. + * + * Everything reset here is state the plugin under test owns, which is why this lives in the + * example project rather than in the runner: only the suite knows what "back to the start" + * means for the plugin it tests, and what commands say it. * * Loaded through `plugins { local(...) }` in build.gradle.kts, for the "stand" environment * only. @@ -20,5 +27,7 @@ export default definePlugin({ await player.deOp(); await server.executeAndWait(`minecraft:clear ${player.username}`); + await server.executeAndWait(`eco set ${player.username} ${STARTING_BALANCE}`); + await server.executeAndWait(`kit reset ${player.username}`); }, }); From 2091ee0c46cab0f6a738708c0b0a4826e3ac40d8 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 27 Aug 2026 22:39:43 +0300 Subject: [PATCH 065/125] docs: replace the reuse pages with describe.serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing Tests loses the Player Reuse section and gains one on serial blocks: what they are for, why the block rather than the test is what filters and failures apply to, how plugin hooks wrap the block, and how { account } and createPlayer({ as }) work. Configuration drops the reuse spec and the PLUGWRIGHT_REUSE variables, Runner Plugins drops onPlayerReuse and PluginTestRef.reuse and explains the hook timing around a block, Reports drops the reuse field, Writing a Mode drops playerReuse, and Test Filtering explains that a serial block is filtered whole. External Servers now covers the two shapes of autoRegister — numbered slots against fresh names — and naming an account from a block. Refs #53 --- docs/configuration.mdx | 24 -------- docs/custom-modes.mdx | 8 +-- docs/external-servers.mdx | 22 ++++++-- docs/matchers.mdx | 4 +- docs/plugins.mdx | 23 ++------ docs/reports.mdx | 10 ++-- docs/test-filtering.mdx | 10 +++- docs/writing-tests.mdx | 114 +++++++++++++++++++++----------------- 8 files changed, 101 insertions(+), 114 deletions(-) diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 9418f8d..f1b92c9 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -273,22 +273,6 @@ matrix { } ``` - - Settings for reusing a connected bot across test boundaries instead of reconnecting for every test. `enabled` is `false` by default — an existing suite that depends on a fresh player per test keeps working unchanged until it opts in. `maxPlayers` caps live registry entries; unset falls back to 4, or the environment's account pool capacity minus one when it has a pool. `stay` decides whether a reused bot keeps its connection between the tests that borrow it; `true` by default. - - -```kotlin -reuse { - enabled.set(true) - maxPlayers.set(4) - stay.set(true) -} -``` - -`stay.set(false)` keeps the reuse but drops the parking: at the end of every test the bot leaves the server, and the entry it came from — same account, same nick, same ability labels — waits offline until a later test takes it and rejoins under that identity. What carries over is the identity, not the connection. That's the only form of reuse a server which kicks idle players allows, and it's what an environment declaring `capabilities.playerReuse = 'rejoin'` forces regardless of this setting. A single test can override it with `reuse: { stay }` — see [Writing Tests](/writing-tests). - -`PLUGWRIGHT_REUSE=1` / `PLUGWRIGHT_REUSE=0` overrides `reuse.enabled` from the environment, and `PLUGWRIGHT_REUSE_STAY` does the same for `reuse.stay`, for trying either in a dev loop without editing a committed build script. - Per-environment, inside `create(...) { }`: @@ -325,12 +309,4 @@ plugins { Set `PLUGWRIGHT_DEBUG=1` in your environment to enable verbose debug logging during test execution. This is particularly useful for troubleshooting GUI flows and inspecting window open/close events from the bot. - - Overrides `reuse.enabled` for this run: `1`/`true` turns it on, `0`/`false` turns it off. Unset, or any other value, leaves the build script's own setting alone. - - - - Overrides `reuse.stay` for this run, same `1`/`true` and `0`/`false` spelling. `0` keeps reuse on but sends each bot off the server at the end of every test, rejoining it when a later test takes its entry. Ignored when reuse itself is off. - - diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx index 1a7bebc..783c9c4 100644 --- a/docs/custom-modes.mdx +++ b/docs/custom-modes.mdx @@ -152,10 +152,6 @@ class VelocityEnvironment implements Environment { arbitraryUsernames: true, lifecycle: true, cleanupStrategy: 'compensating', - // Absent means "allowed". `false` if a bot surviving a test boundary at all would - // break the environment (a per-test world reset); 'rejoin' if only a bot *sitting* - // there is the problem (an idle-kick timeout, an AFK check). - playerReuse: true, }; async setup(session: Session): Promise { /* connect, probe, warm up */ } @@ -167,9 +163,9 @@ class VelocityEnvironment implements Environment { } ``` -Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. `playerReuse: false` overrides `tests.reuse.enabled` for the whole run against this environment — set it when a long-lived bot would break something the environment can't tell tests about any other way. `playerReuse: 'rejoin'` is the softer form for a server that only objects to an *idle* bot: reuse stays on, but every entry leaves at the end of its test and rejoins when a later one takes it, and no `tests.reuse.stay` or per-test `reuse: { stay: true }` can talk it out of that. +Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. -`accounts()` and `beforeJoin()` are optional. Returning no pool means every bot gets a throwaway `Test_` username, which is what `local` does. +`accounts()` and `beforeJoin()` are optional. Returning no pool means every bot gets a throwaway `pw_` username, which is what `local` does. A pool is also what makes `describe.serial('...', { account: 'pw_0001' })` possible: without one, a block asking for a named account fails rather than running as somebody else. ## Checking it works diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index ae078f0..d259184 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -77,7 +77,7 @@ The admin bot connects through the same code path as a test bot, which means it A local server accepts any username; a stand usually does not. `accounts { }` builds a pool that tests lease from and return to, merged from three sources: - **`pool`** — accounts that already exist, with their passwords. -- **`autoRegister`** — generated names from a pattern, marked `justCreated` on their first lease so an authentication plugin registers them instead of logging in. The pattern must start with `pw_`, so test accounts stay recognizable on a server full of real players. +- **`autoRegister`** — generated names from a pattern, marked `justCreated` on their first lease so an authentication plugin registers them instead of logging in. The pattern must start with `pw_`, so test accounts stay recognizable on a server full of real players. The placeholder decides what happens to a name afterwards: `pw_%04d` numbers a fixed set of accounts the run keeps coming back to, while `pw_%s` puts a random suffix there and never hands the same name out twice. See below. - **`microsoft`** — online-mode accounts. No password; mineflayer authenticates with a cached device-code token. Point `cacheDir` somewhere outside `build/`, and warm the cache before CI ever needs it, because the device-code flow is interactive. One account is leased per bot and returned in a `finally`, whatever the test did. When the pool is empty and `autoRegister` has hit `max`, `lease()` throws rather than hand the same identity to two connected bots. @@ -94,11 +94,25 @@ That is a request for a specific identity, not for whatever is free — so nothi A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, exclude what you can't, and treat `capabilities.freshState = false` as the honest description it is. -## Reuse and the account pool +## Numbered slots or fresh names -With `tests.reuse` on ([Configuration](/configuration)), a registry entry holds its leased account for as long as the entry lives, not just for one test — an account checked out by a long-lived player doesn't return to the pool until that player is evicted, invalidated, or the run ends. Size `accounts { }` accordingly: `maxPlayers` defaults to the pool's capacity minus one so a test's own `createPlayer()` still has a spare slot, but a pool exactly as big as `maxPlayers` leaves nothing free for it. +`autoRegister` answers a question the fixed `pool` can't: where does a name come from when the server has never seen this test before? Which form you want depends on what the stand can clean up. -An environment that can't tolerate a bot surviving a test boundary at all — a per-test world reset — should report `capabilities.playerReuse = false` after `setup()` rather than let reuse quietly misbehave. When the problem is narrower than that, and it usually is on a public server, `capabilities.playerReuse = 'rejoin'` keeps the reuse and drops the idling: the bot leaves at the end of every test and rejoins under the same account when a later test takes its entry. `tests.reuse.stay = false` asks for the same thing from the config side. Either way the account stays leased while the entry is parked, so pool sizing doesn't change. See [Writing a Mode](/custom-modes). +`pw_%04d` gives you `pw_0001` … `pw_000N`, leased in turn and returned when a test ends. The set is finite and the accounts are provisioned once, which is what a stand with permission groups or a whitelist needs. The cost is that every test inherits whatever the last one left on that account, so anything you can't reset with a command has to stay out of the suite (`excludeTests`) or be undone in a plugin's `beforeEach`. + +`pw_%s` generates a name per lease — `pw_a8f2` — and never reuses it. Each test starts on an account with no history, which is the closest a stand gets to what `local` hands out for free. The cost is a registration the server keeps: after a few runs the login plugin's database is full of test accounts, and pruning them is on you. `max` still caps how many bots are connected at once. + +## Naming an account from a test + +A `describe.serial` block can ask for one specific pool account: + +```ts +describe.serial('vip shop', { account: 'pw_0001' }, () => { + // ... +}); +``` + +That's for a scenario tied to state somebody provisioned on that account — a permission group, a starting balance. The account has to be in the pool and free; anything else fails the block instead of quietly running as a different player. See [Writing Tests](/writing-tests). ## Checking the stand before you test diff --git a/docs/matchers.mdx b/docs/matchers.mdx index f22d061..5c9034e 100644 --- a/docs/matchers.mdx +++ b/docs/matchers.mdx @@ -80,7 +80,7 @@ Strict equality check using `Object.is()`. Use for primitives. expect(42).toBe(42); expect('hello').toBe('hello'); expect(true).toBe(true); -expect(player.username).toBe('Test_123'); +expect(player.username).toBe('pw_a8f2'); ``` ### `toEqual(value)` @@ -164,7 +164,7 @@ expect(Math.PI).toBeCloseTo(3.14, 2); ```javascript expect('Hello World').toMatch(/World/); expect('Hello World').toMatch('World'); -expect(player.username).toMatch(/Test_\d+/); +expect(player.username).toMatch(/pw_[0-9a-f]+/); ``` ### `toContain(substring)` diff --git a/docs/plugins.mdx b/docs/plugins.mdx index d4519db..5441335 100644 --- a/docs/plugins.mdx +++ b/docs/plugins.mdx @@ -32,12 +32,11 @@ export interface PlugwrightPlugin { apiVersion?: number; setup?(ctx: { session, env, options: O }): Promise | void; onPlayerCreate?(player, ctx: { account, env }): Promise | void; - onPlayerReuse?(player, ctx: { account, env }): Promise | void; beforeEach?(ctx: TestContext): Promise | void; afterEach?(ctx: TestContext): Promise | void; extendContext?(ctx: TestContext): Record | void; matchers?: Record; - tests?: Array<{ file: string; mode: 'preflight' | 'suite'; reuse?: false }>; + tests?: Array<{ file: string; mode: 'preflight' | 'suite' }>; cleanup?(ctx: { session, scope: 'session' | 'manual' }): Promise | void; teardown?(): Promise | void; } @@ -70,25 +69,13 @@ export default definePlugin({ }); ``` -`onPlayerCreate` fires on every connection: the first bot of a test, a second bot from `createPlayer()`, every `player.rejoin()`, and the admin-bot console channel. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook. +`onPlayerCreate` fires on every connection: the bot a test starts with, a second bot from `createPlayer()`, every `player.rejoin()`, and the admin-bot console channel. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook. -## Reuse +## Hooks and `describe.serial` -```ts -export interface PlugwrightPlugin { - onPlayerReuse?(player, ctx: { account, env }): Promise | void; -} -``` - -Fires before a reused player is handed to the next test — never on the first connection, where `onPlayerCreate` already runs. By the time it fires, the core has already done its own safe minimum (closing a leftover open window); anything beyond that — clearing a hotbar, resetting a scoreboard value your plugin tracks — is yours to do here. See [Writing Tests](/writing-tests) for the test-side `reuse` option and ability labels. - -An auth plugin's preflight exists to prove the login flow runs, so a reused, already-authenticated player would defeat the point: - -```ts -tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight', reuse: false }] -``` +`beforeEach` and `afterEach` normally wrap every test. Around a [`describe.serial`](/writing-tests) block they run once instead: before its first test and after its last. -`PluginTestRef.reuse: false` forces every test in that file onto a fresh connection, regardless of the run's own `reuse` setting. +That is deliberate, and it matters most for a plugin that resets an account between tests. A block exists because its second test depends on what its first one did; a reset firing in between would throw that away, and the plugin has no way to tell which state the block was counting on. Anything a plugin needs to do per test inside a block belongs in the spec's own `beforeEach`, where the test author can see it. ## Inherited tests diff --git a/docs/reports.mdx b/docs/reports.mdx index 710ef2e..49b7659 100644 --- a/docs/reports.mdx +++ b/docs/reports.mdx @@ -25,8 +25,7 @@ build/reports/plugwright/.log per-environment output, matrix runs o "durationMs": 63, "error": null, "skipReason": null, - "plugin": null, - "reuse": { "key": "auto:[]!()", "reused": true, "stay": true, "abilities": [] } + "plugin": null }, { "file": "…/dist/simple-ts.spec.js", @@ -35,8 +34,7 @@ build/reports/plugwright/.log per-environment output, matrix runs o "durationMs": 0, "error": null, "skipReason": "requires capability [consoleOutput:full], unavailable on \"staging\"", - "plugin": null, - "reuse": null + "plugin": null } ] } @@ -44,9 +42,9 @@ build/reports/plugwright/.log per-environment output, matrix runs o `status` is `pass`, `fail` or `skip`. `plugin` names the plugin a test came from when it was inherited rather than found in your test directory. -`reuse` is `null` when `tests.reuse` is off for the run. Otherwise it's always present, even for a test that opted out with `reuse: false` (`reused: false`, `key: "none"`). `reused: true` means the player came from an earlier test instead of a fresh connection; `abilities` is the label set it was matched against; `stay` is whether it kept its connection after this test or was parked offline until a later test rejoins it. See [Writing Tests](/writing-tests). +Every skip carries its reason: excluded by name, wrong environment, a capability the environment doesn't have, or an earlier test in the same [`describe.serial`](/writing-tests) block that stopped the chain. A skipped test that doesn't say why is worse than a failing one, because it reads as coverage. -Every skip carries its reason: excluded by name, wrong environment, or a capability the environment doesn't have. A skipped test that doesn't say why is worse than a failing one, because it reads as coverage. +Tests from a serial block appear as ordinary entries, in the order they ran, under their full `describe` path. ## JUnit XML diff --git a/docs/test-filtering.mdx b/docs/test-filtering.mdx index 817eb33..e897b05 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -77,9 +77,15 @@ test('the /debug dev command', { environments: ['local'] }, async ({ player }) = }); ``` -## Reuse is a different axis +## Serial blocks filter as one -`requires` and `environments` decide whether a test runs at all. `reuse` (see [Writing Tests](/writing-tests)) decides which bot it gets once it's already running — a filter never skips a test because of its `reuse` option. The two do interact on one environment setting: `capabilities.playerReuse` disables reuse for the whole environment when it's `false`, or forces every player off the server between tests when it's `'rejoin'` — neither affects anything a test declared through `requires`. +A [`describe.serial`](/writing-tests) block is filtered whole. If any filter — a name pattern, `excludeTests`, `requires`, `environments` — rules out one test in the block, every test in it is skipped and reports the same reason: + +``` + Serial block: kit lifecycle - SKIPPED ("kit lifecycle > is on cooldown right after" excluded by tests.exclude (matches "cooldown"), and a serial block runs whole or not at all) +``` + +Running half a chain is worse than running none of it: the steps left behind assert against state the skipped step was supposed to create. `-PtestNames` against a serial block is an all-or-nothing choice, so match the block's name rather than one test inside it. ## Skips are reported diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index 467034a..9319cc2 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -123,97 +123,107 @@ test('advanced waiting', async ({ player }) => { }); ``` -## Player Reuse +## Tests that share a player: `describe.serial` -By default, every test gets a fresh bot and disconnects it when the test ends. When `tests.reuse` is on ([Configuration](/configuration)), a test can ask for a bot that survived from an earlier test instead of reconnecting: +Every test gets its own bot, connected before it starts and disconnected after it ends. That is the right default, and most tests want nothing else. + +Some scenarios aren't one test, though. "Claim a kit, see it on cooldown, wait, see it claimable again" is three assertions about the same player in a fixed order, and three independent bots can't express it however you schedule them. `describe.serial` is for exactly that: ```typescript -test('shows prices', async ({ player }) => { - // same long-lived player as any other default-reuse test, if one is free -}); +import { describe, test, expect, sleep } from '@plugwright/runner'; -opTest('admin can edit', async ({ player }) => { - // matched to a player already carrying the `op` label — makeOp() only runs if none does -}); +describe.serial('kit lifecycle', () => { + test('claims the starter kit', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Received starter kit'); + }); -test('regular player cannot edit', { reuse: { excludeAbilities: ['op'] } }, async ({ player }) => { - // explicitly asks for a player that is NOT op, even if an op'd one is sitting free -}); + test('is on cooldown right after', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('cooldown'); + }); -test('first join flow', { reuse: false }, async ({ player }) => { - // always a brand-new connection, regardless of the run's reuse setting + test('can claim again once the cooldown expires', async ({ player }) => { + await sleep(5000); + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Received starter kit'); + }); }); ``` -Matching goes by **ability labels**, not by resetting server state — the runner has no way to undo what a command changed, so it doesn't pretend to. `player.makeOp()`, `player.deOp()` and `player.setGameMode()` label the player automatically (`op`, `gamemode:creative`, …); anything else needs an explicit `player.mark('kit:starter')` / `player.unmark(...)`. Read the current set with `player.abilities`. +One player, one connection, one leased account for the whole block. The tests run in the order they are written, and the account goes back to the pool when the block ends. -```typescript -export interface ReuseOptions { - key?: string; // explicit identity — same bot every time, e.g. the second player in a multiplayer test - abilities?: string[]; // player must carry all of these - excludeAbilities?: string[]; // player must carry none of these - strict?: boolean; // player's labels must equal `abilities` exactly, no extras - stay?: boolean; // keep the connection after this test, or park the entry offline -} -``` +### The block is the unit -`reuse` on `test()` accepts `false`, a string (shorthand for `{ key }`), or a `ReuseOptions` object. `ctx.createPlayer({ reuse: … })` takes the same shape for any secondary player a test creates. +Filters apply to the block, not to the tests inside it. If `requires`, `environments`, `tests.names` or `tests.exclude` rules out any test in the block, the whole block is skipped and every test in it is reported skipped with that reason. A chain missing a link is worse than no chain: the steps after it would assert against state that nothing produced. -`stay` is the one option that describes what happens *after* the test rather than which player it gets. Left alone it follows the run's `tests.reuse.stay` (`true` by default): the bot stays on the server, and the next test that matches it skips connecting entirely. `stay: false` sends it off at the end of this test and keeps only the entry — the account, the nick and the labels — so a later test gets the same identity back through a rejoin: +The same logic covers failure. When a test fails or times out, the rest of its block is reported **skipped**, not failed — those tests never ran, and reporting them as failures would bury the one real cause under a pile of noise. A test can also say so itself: ```typescript -test('slow inventory walk', { reuse: { stay: false } }, async ({ player }) => { - // the bot leaves when this test ends; the next test to want this player rejoins it +test('buys the last item in stock', async ({ player, invalidatePlayer }) => { + // ... + if (theShopIsNowEmpty) invalidatePlayer(player, 'shop stock is spent'); }); ``` -Reach for it when a parked bot is the problem — an idle-kick timeout, an AFK check, a server that counts online players. Everything is disconnected at the end of the run either way. An environment can force it for every test by declaring `capabilities.playerReuse = 'rejoin'`, and then `stay: true` here doesn't lift it. +Outside a serial block `invalidatePlayer` does nothing, since the bot is disconnected at the end of the test anyway. + +### Hooks inside a block -A test that cares about a clean nick, the absence of a label, or a first-registration flow declares `reuse: false` (or the right `excludeAbilities`) explicitly — reuse never guesses on a test's behalf. +Plugin `beforeEach`/`afterEach` run **once around the whole block** — before its first test and after its last. A plugin whose job is to reset an account between tests would otherwise undo exactly what the block is built on. Spec-level `beforeEach`/`afterEach` still run for every test, and `describe` nesting works inside a block the way it does anywhere else: ```typescript -test('cleans up on failure', async ({ player, invalidatePlayer }) => { - // ... - if (somethingLeftThePlayerInABadState) invalidatePlayer(player); +describe.serial('shop lifecycle', () => { + beforeEach(async ({ player }) => { + // runs before each of the three tests below + }); + + describe('buying', () => { + test('adds the item', async ({ player }) => { /* ... */ }); + test('takes the money', async ({ player }) => { /* ... */ }); + }); }); ``` -`invalidatePlayer` marks a player unfit for the next test: it disconnects instead of being handed out again. The runner does this automatically for a test that fails or times out — one bad test shouldn't hand its mess to the next one. +Each test starts with a clear message buffer, so a message from an earlier step can't satisfy an assertion about this one. What carries over is server state, which is the point. -### Initializing a reuse pool with `reuseTest` +A block can't contain another `describe.serial` — nesting one ordered chain inside another is a scheduling question the runner deliberately doesn't answer. -`reuseTest(pool, fn)` registers a one-time setup step for a named reuse pool (the same string used as `reuse: 'poolName'` or `reuse: { key: 'poolName' }`). It runs **only** when the pool's registry entry is actually (re)built — the first time any test asks for `'poolName'`, or later if that entry was dropped (a rejoin failed, abilities stopped matching) and needs to be created again. An ordinary checkout of an already-live entry never runs it: +### Naming an account -```typescript -reuseTest('shopkeeper', async ({ player }) => { - await player.chat('/vip add'); - player.mark('vip'); -}); +On a stand where one specific account carries the state a scenario needs — a permission group, a starting balance, a whitelist entry — name it: -test('shop shows vip discount', { reuse: 'shopkeeper' }, async ({ player }) => { - // guaranteed to run after 'shopkeeper' has been initialized at least once +```typescript +describe.serial('vip shop', { account: 'pw_0001' }, async () => { + // every test in the block runs as pw_0001 }); ``` -`reuseTest` is reported as its own test, listed right before whichever test triggered the (re)creation. If its body throws, that test fails too — the entry is discarded so the next attempt runs `reuseTest` again instead of handing out a half-initialized player. +The account must exist in the environment's `accounts { }` pool and be free. If it is already leased, or the environment has no pool at all (`local` invents a name per bot and has none), the block fails with that message rather than quietly running as somebody else. + +### A second bot for the block -It takes the same scope as `test`/`opTest` — everything except `reuse` itself, which doesn't apply to a test that's initializing a pool rather than resolving into one: +`ctx.createPlayer()` connects an extra bot that lives as long as the test does. Inside a block, `as` gives it a name so a later test gets the same bot back: ```typescript -describe('Shop', () => { - reuseTest('shopkeeper', { requires: ['op'] }, async ({ player }) => { - // named "Shop > reuse:shopkeeper" — describe nesting applies same as any other test +describe.serial('trading', () => { + test('sends the payment', async ({ player, createPlayer }) => { + const buyer = await createPlayer({ as: 'buyer' }); + buyer.chat(`/pay ${player.username} 100`); + await expect(player).toHaveReceivedMessage('Received $100'); + }); + + test('the buyer is out of money', async ({ createPlayer }) => { + const buyer = await createPlayer({ as: 'buyer' }); // the same bot as above + buyer.chat('/balance'); + await expect(buyer).toHaveReceivedMessage('$900'); }); }); ``` -- `requires` / `environments` skip it exactly like a regular test would be skipped — reported `skipped`, `fn` never runs. A player handed out under a pool whose `reuseTest` doesn't apply on this environment still connects; it's just never initialized, same as if no `reuseTest` had been declared for that pool at all. -- Spec-level `beforeEach`/`afterEach` from the enclosing `describe` wrap it, and so do plugin `beforeEach`/`afterEach` and `extendContext` fixtures — `ctx.holy`, matchers, everything a normal test body gets. -- It does not accept `reuse` — pass `(pool, fn)` or `(pool, options, fn)` where `options` is `requires`/`environments` only. - ## Best Practices -1. **Keep tests isolated** - Each test gets a fresh bot, unless reuse is on +1. **Keep tests isolated** - Each test gets a fresh bot unless it is in a `describe.serial` block 2. **Use descriptive names** - Make test failures easy to understand 3. **Wait for conditions** - Use assertions that auto-retry 4. **Test one thing** - Each test should verify one behavior From 3a8f2bddd7fc99199098e7e00b73ed61f595e714 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 27 Aug 2026 23:54:01 +0300 Subject: [PATCH 066/125] fix(example): exclude the named-bot test on the stand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Cross-bot message separation" connects a second bot as FriendBot, and a bot named by hand is not a pool account, so nothing on the stand knows its password. It has been failing there for the same reason multi-bot is already excluded — it just never made the list. Refs #53 --- example_plugin/build.gradle.kts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index 3fa7933..0ab41e4 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -129,11 +129,11 @@ plugwright { // Matched against test names. What is left out here is what no command puts back: // an arena slot that is filled once and stays filled, and a first join, which only // happens on an account the server has never seen. Op, inventory, balance and kit - // cooldowns are reset per test by the stand-reset plugin instead. multi-bot is out - // for a different reason — it names its second bot, and a named bot is not a pool - // account, so nothing knows its password. + // cooldowns are reset per test by the stand-reset plugin instead. multi-bot and + // Cross-bot are out for a different reason — they name their second bot, and a named + // bot is not a pool account, so nothing knows its password. excludeTests.set(listOf( - "arena", "first join", "multi-bot" + "arena", "first join", "multi-bot", "Cross-bot" )) } } From 0dc2115087dc0b66bb95b3a6cbab7a057f23be56 Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 28 Aug 2026 10:03:45 +0200 Subject: [PATCH 067/125] build(deps): add idea-ext dependency to plugwright-bundle Add the `org.jetbrains.gradle.plugin.idea-ext` dependency to resolve a Gradle sync failure in IntelliJ IDEA: Failed to apply plugin class 'org.gradle.plugins.ide.idea.IdeaPlugin'. Plugin with id 'org.jetbrains.gradle.plugin.idea-ext' not found. --- gradle-plugin/plugwright-bundle/build.gradle.kts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gradle-plugin/plugwright-bundle/build.gradle.kts b/gradle-plugin/plugwright-bundle/build.gradle.kts index 6b6bc54..b5f514e 100644 --- a/gradle-plugin/plugwright-bundle/build.gradle.kts +++ b/gradle-plugin/plugwright-bundle/build.gradle.kts @@ -14,6 +14,7 @@ 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") // Compile-time only, all four: none of them is published under its own coordinates, and // their classes reach the runtime classpath through this module's merged jar below. @@ -23,8 +24,8 @@ dependencies { // that exist in no repository, so every consumer resolving this plugin from a maven // repository failed with "Could not find io.github.drownek:plugwright-core". // - // gson and snakeyaml above stay `implementation` deliberately: those are real artifacts - // that are not merged into the jar, so the POM does have to ask for them. + // gson, snakeyaml and idea-ext above stay `implementation` deliberately: those are real + // artifacts that are not merged into the jar, so the POM does have to ask for them. compileOnly(project(":plugwright-api")) compileOnly(project(":plugwright-core")) compileOnly(project(":plugwright-local")) From b47d1af1b80a6759ec22976362f4806614375175 Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 28 Aug 2026 10:08:45 +0200 Subject: [PATCH 068/125] ci: trigger example plugin tests on v3-dev branch --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bb8373..c5e1dab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,13 @@ name: CI on: push: - branches: [ "master" ] + branches: [ "master", "v3-dev" ] paths-ignore: - 'docs/**' - 'docs.json' - '**/*.md' pull_request: - branches: [ "master" ] + branches: [ "master", "v3-dev" ] paths-ignore: - 'docs/**' - 'docs.json' From 6fae90d0c4f0d6d0d92e24acb3520db845a6b1c9 Mon Sep 17 00:00:00 2001 From: Nikita Monikonov Date: Fri, 28 Aug 2026 15:00:20 +0300 Subject: [PATCH 069/125] fix(auth-authme): fall back to login on a missing prompt (#60) AuthMe sometimes sends no login/register prompt at all when a pool account reconnects, so the plugin waited out its full timeout and failed the whole test even though the server would have accepted a plain /login. Catch that timeout and assume login (never registration -- a silent reconnect can only happen to an account that already exists). The existing success/authenticated patterns already match "already logged in", so a stale session on the server just costs one extra command instead of stranding the bot online for the rest of the run. Closes #57 --- auth-authme-package/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index b615c9f..ce15b8f 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -97,6 +97,12 @@ export default definePlugin({ const since = (index: number, pattern: RegExp): string | undefined => player.messageBuffer.slice(index).find((m: string) => pattern.test(m)); + // The server occasionally sends no prompt at all on a reconnect (#57) — AuthMe still + // considers the account logged in from a connection that never fully closed. Rather + // than fail the whole test over one missing prompt, assume login (never registration: + // a silent reconnect can only happen to an account that already exists) and let the + // command below run into either a real prompt AuthMe queued up in the meantime, or an + // "already logged in" reply — both success/authenticated patterns already match it. const isRegistration = await poll( () => { if (since(joinIndex, registerPrompt)) return true; @@ -107,7 +113,7 @@ export default definePlugin({ timeout: resolved.timeoutMs, message: `authme: never saw a login or register prompt for "${account.username}"`, }, - ); + ).catch(() => false); // Everything below only looks at messages newer than the command. A server's greeting // often carries a word like "welcome", which would otherwise pass for confirmation From 6eab2269eae181ac752476b368ea7f88f8e0efe2 Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 28 Aug 2026 15:22:07 +0300 Subject: [PATCH 070/125] feat(auth-authme): add failOnMissingPrompt to opt back into strict prompt checks The reconnect fallback added earlier assumes login and sends the command anyway when no login/register prompt arrives. That's the right default, but some servers use the prompt as proof the player isn't already authenticated some other way, and a silent reconnect there is a real failure, not a stale session. failOnMissingPrompt (default false) restores the original throw for that case; the rest of the flow is unchanged. --- auth-authme-package/index.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index ce15b8f..dc1f217 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -28,6 +28,10 @@ export interface AuthAuthmeOptions { authenticatedPattern?: string; /** How long to wait for each prompt/confirmation before giving up. */ timeoutMs?: number; + /** Fail the connection when no login/register prompt arrives at all, instead of assuming + * login and sending the command anyway. Some servers require the prompt as proof the + * player isn't already authenticated by something else; most don't. */ + failOnMissingPrompt?: boolean; /** Password used for accounts that carry none of their own — the throwaway identities an * environment without an account pool generates per bot. Plugin options travel as plain * values, so only use this where the password is worth nothing: a local, disposable @@ -43,6 +47,7 @@ const DEFAULTS: Required> = { successPattern: 'success|welcome|logged in|authenticat', authenticatedPattern: 'success(ful)? login|logged in|authenticat', timeoutMs: 15000, + failOnMissingPrompt: false, }; // `onPlayerCreate` doesn't receive the plugin's options — only `setup()` does — so the @@ -97,13 +102,15 @@ export default definePlugin({ const since = (index: number, pattern: RegExp): string | undefined => player.messageBuffer.slice(index).find((m: string) => pattern.test(m)); - // The server occasionally sends no prompt at all on a reconnect (#57) — AuthMe still + // The server occasionally sends no prompt at all on a reconnect — AuthMe still // considers the account logged in from a connection that never fully closed. Rather // than fail the whole test over one missing prompt, assume login (never registration: // a silent reconnect can only happen to an account that already exists) and let the // command below run into either a real prompt AuthMe queued up in the meantime, or an // "already logged in" reply — both success/authenticated patterns already match it. - const isRegistration = await poll( + // `failOnMissingPrompt` opts back into the strict behavior, for servers where a missing + // prompt is a real problem rather than a stale session. + const promptSeen = poll( () => { if (since(joinIndex, registerPrompt)) return true; if (since(joinIndex, loginPrompt)) return false; @@ -113,7 +120,8 @@ export default definePlugin({ timeout: resolved.timeoutMs, message: `authme: never saw a login or register prompt for "${account.username}"`, }, - ).catch(() => false); + ); + const isRegistration = resolved.failOnMissingPrompt ? await promptSeen : await promptSeen.catch(() => false); // Everything below only looks at messages newer than the command. A server's greeting // often carries a word like "welcome", which would otherwise pass for confirmation From dd0122c54365fec3140d07292992af081f1cf8b6 Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 28 Aug 2026 15:39:21 +0300 Subject: [PATCH 071/125] build(example-plugin): track a stand start.sh launcher outside generated/ generated/ is gitignored, so CI has nothing to launch the stand Paper server with. Commit the launcher example_plugin/README.md already told developers to write by hand, under stand-run/, and pin *.sh to LF via .gitattributes so a Windows checkout doesn't break the shebang. --- .gitattributes | 1 + example_plugin/README.md | 2 +- example_plugin/src/test/e2e/stand-run/start.sh | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100755 example_plugin/src/test/e2e/stand-run/start.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/example_plugin/README.md b/example_plugin/README.md index 30f425d..344d3b4 100644 --- a/example_plugin/README.md +++ b/example_plugin/README.md @@ -24,7 +24,7 @@ This one connects to a server that is already running and leaves it running. Pro cd src/test/e2e/generated/local/run && ./start.sh ``` -`generated/` is not in version control, so `start.sh` is yours to write. Anything that starts the jar with Java 21 will do: +`generated/` is not in version control, so `start.sh` is yours to write. Anything that starts the jar with Java 21 will do — CI keeps a tracked copy at `src/test/e2e/stand-run/start.sh` and copies it in before starting the server: ```sh #!/usr/bin/env sh diff --git a/example_plugin/src/test/e2e/stand-run/start.sh b/example_plugin/src/test/e2e/stand-run/start.sh new file mode 100755 index 0000000..481c160 --- /dev/null +++ b/example_plugin/src/test/e2e/stand-run/start.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +# Tracked copy of the launcher example_plugin/README.md tells you to write yourself for a local +# `stand` run. CI copies this into generated/local/run/ (see ci.yml) since that directory is +# gitignored and can't hold a script of its own. Keep this in sync with the README snippet. +set -e + +cd "$(dirname "$0")" + +JAVA_BIN="${JAVA_BIN:-java}" +JVM_ARGS="${JVM_ARGS:--Xmx2G}" + +exec "$JAVA_BIN" $JVM_ARGS -Dcom.mojang.eula.agree=true -jar server.jar --nogui From 2592328f8eec378567b848f3315cdca507c15fc6 Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 28 Aug 2026 15:39:27 +0300 Subject: [PATCH 072/125] ci(#61): run the stand environment suite after LocalMode test-example-plugin only ever exercised LocalMode - the stand (ExternalMode) env is excluded from the matrix since it needs an already-running server, so the RCON console channel, account-pool leasing and the justCreated registration flow had zero CI coverage. The LocalMode run plugwright-action already does leaves Paper, cache and libraries under generated/local/run/ - stand points at the same localhost:25565. Copy in the tracked launcher, start it in the background, retry plugwrightPingStand until it answers, then run plugwrightTestStand. Kill the server and upload its log on failure regardless of outcome. Closes #61. --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5e1dab..7a108f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,10 @@ jobs: test-example-plugin: if: "!contains(github.event.head_commit.message, 'skip-ci')" runs-on: ubuntu-latest + env: + # Test-only credentials for the Paper server this job starts and tears down itself. + PLUGWRIGHT_RCON_PASSWORD: plugwright + PLUGWRIGHT_BOT_PASSWORD: plugwright steps: - uses: actions/checkout@v4 @@ -32,3 +36,41 @@ jobs: java-version: "17" node-version: "24" working-directory: "./example_plugin" + # The LocalMode run above leaves Paper, cache and libraries under generated/local/run/ - + # stand reuses that same directory (build.gradle.kts points it at localhost:25565). Only + # start.sh is missing: generated/ is gitignored, so we copy in the tracked launcher. + - name: Start the stand Paper server + working-directory: ./example_plugin/src/test/e2e/generated/local/run + run: | + cp "$GITHUB_WORKSPACE/example_plugin/src/test/e2e/stand-run/start.sh" . + chmod +x start.sh + nohup ./start.sh > stand-server.log 2>&1 & + echo $! > stand-server.pid + - name: Wait for the stand server + working-directory: ./example_plugin + run: | + for i in $(seq 1 30); do + if ./gradlew plugwrightPingStand; then + exit 0 + fi + sleep 2 + done + echo "stand server did not come up in time" >&2 + exit 1 + - name: Run the stand suite + working-directory: ./example_plugin + run: ./gradlew plugwrightTestStand + - name: Stop the stand Paper server + if: always() + working-directory: ./example_plugin/src/test/e2e/generated/local/run + run: | + [ -f stand-server.pid ] && kill "$(cat stand-server.pid)" 2>/dev/null || true + - name: Upload stand server log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: stand-server-log + path: | + example_plugin/src/test/e2e/generated/local/run/stand-server.log + example_plugin/build/reports/plugwright/stand.* + if-no-files-found: ignore From 879762101d6fc2532cfee1d2b95070a97c0cdb2e Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 28 Aug 2026 15:50:46 +0300 Subject: [PATCH 073/125] ci(#61): install JDK 21 before starting the stand server start.sh just runs whatever "java" is on PATH, which the earlier plugwright-action step pinned to 17 for the Gradle daemon. Paper 1.21.11 needs 21 (example_plugin/build.gradle.kts pins the toolchain there) - LocalMode never hit this because Gradle resolves and downloads that toolchain JDK itself for its own server launch, but a plain shell script has no such resolution. CI run 33172008840 confirmed the crash: UnsupportedClassVersionError, class file version 65.0 vs runtime's 61.0. --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a108f4..c2edc98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,14 @@ jobs: java-version: "17" node-version: "24" working-directory: "./example_plugin" + # LocalMode's own server launch resolves a Java 21 toolchain (build.gradle.kts pins + # languageVersion 21 for Paper 1.21.11) regardless of the Gradle-daemon JDK above - Gradle + # downloads it on demand. start.sh has no such resolution, it just runs whatever "java" is + # on PATH, so put a real JDK 21 there before it needs one. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" # The LocalMode run above leaves Paper, cache and libraries under generated/local/run/ - # stand reuses that same directory (build.gradle.kts points it at localhost:25565). Only # start.sh is missing: generated/ is gitignored, so we copy in the tracked launcher. From 2ab3e7d4572adb2162a76090f1686ef5948b2eab Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 28 Aug 2026 16:07:39 +0300 Subject: [PATCH 074/125] ci(#61): split stand into its own parallel job test-example-plugin and test-example-plugin-stand now run on separate runners concurrently instead of stand chaining off the end of a single sequential job. Stand can no longer reuse the local job's generated/local/run/ (different runner, different filesystem), so it provisions its own Paper server via plugwright-action's gradle-args input (plugwrightProvisionLocal only - not the full plugwrightTest, which would also run and duplicate the local suite). Everything after that (JDK 21 setup, start.sh, ping retry-loop, plugwrightTestStand, teardown, failure log upload) is unchanged, just moved into the new job. Trades a duplicate Paper/plugin download for roughly half the wall-clock time versus running sequentially in one job. job names: test-example-plugin keeps its existing name (a maintainer required-status-check on it, if any, keeps matching); the new job is test-example-plugin-stand. --- .github/workflows/ci.yml | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2edc98..d6ea046 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,24 @@ jobs: test-example-plugin: if: "!contains(github.event.head_commit.message, 'skip-ci')" runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + # The example links all three by path, and npm cannot build a linked package: it packs + # the directory into a staging copy that never gets its dev dependencies. + - name: Build the local npm packages + run: | + npm run install:packages + npm run build:packages + - uses: drownek/plugwright-action@v1 + with: + java-version: "17" + node-version: "24" + working-directory: "./example_plugin" + + test-example-plugin-stand: + if: "!contains(github.event.head_commit.message, 'skip-ci')" + runs-on: ubuntu-latest env: # Test-only credentials for the Paper server this job starts and tears down itself. PLUGWRIGHT_RCON_PASSWORD: plugwright @@ -25,28 +43,32 @@ jobs: steps: - uses: actions/checkout@v4 - # The example links all three by path, and npm cannot build a linked package: it packs - # the directory into a staging copy that never gets its dev dependencies. + # Same duplicated build as test-example-plugin: this job runs on its own runner, in + # parallel with it, so it can't share that job's npm install/build output. - name: Build the local npm packages run: | npm run install:packages npm run build:packages + # Running in parallel with test-example-plugin means this job can't reuse its + # generated/local/run/ either - provision the same Paper server here instead of just + # running plugwrightTest, which would also run (and duplicate) the local suite. - uses: drownek/plugwright-action@v1 with: java-version: "17" node-version: "24" working-directory: "./example_plugin" - # LocalMode's own server launch resolves a Java 21 toolchain (build.gradle.kts pins - # languageVersion 21 for Paper 1.21.11) regardless of the Gradle-daemon JDK above - Gradle - # downloads it on demand. start.sh has no such resolution, it just runs whatever "java" is - # on PATH, so put a real JDK 21 there before it needs one. + gradle-args: plugwrightProvisionLocal + # The provisioning above resolves a Java 21 toolchain for itself (build.gradle.kts pins + # languageVersion 21 for Paper 1.21.11), but that's Gradle's own on-demand download, not + # something start.sh can reach - it just runs whatever "java" is on PATH, which + # plugwright-action pinned to 17 above for the Gradle daemon. Put a real JDK 21 there. - uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - # The LocalMode run above leaves Paper, cache and libraries under generated/local/run/ - - # stand reuses that same directory (build.gradle.kts points it at localhost:25565). Only - # start.sh is missing: generated/ is gitignored, so we copy in the tracked launcher. + # generated/ is gitignored, so start.sh - the launcher example_plugin/README.md tells + # developers to hand-write - never made it into the provisioned run dir. Copy in the + # tracked copy. - name: Start the stand Paper server working-directory: ./example_plugin/src/test/e2e/generated/local/run run: | From 1051650e66c754c8719846c4fbc389f0916bcb71 Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 28 Aug 2026 16:14:40 +0300 Subject: [PATCH 075/125] ci(#61): install console-rcon before pinging the stand server plugwrightPingStand/plugwrightTestStand don't depend on plugwrightCompileTests (ExternalMode registers no prepareTask - registerTasks in ExternalMode.kt assumes the stand is already up and node_modules already has what it needs). The old sequential job got this for free as a side effect of plugwrightTest's dependsOn chain running first; the new parallel stand job only ran plugwrightProvisionLocal, so @plugwright/console-rcon was never installed and every ping failed with 'no console channel could be reached'. Verified locally: a clean node_modules, then ./gradlew plugwrightCompileTests, installs console-rcon/auth-authme/runner as expected. --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6ea046..9d3509f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,11 +52,16 @@ jobs: # Running in parallel with test-example-plugin means this job can't reuse its # generated/local/run/ either - provision the same Paper server here instead of just # running plugwrightTest, which would also run (and duplicate) the local suite. + # plugwrightCompileTests is the other half: plugwrightPingStand/plugwrightTestStand + # don't depend on it themselves (ExternalMode assumes the stand is already up, nothing + # to provision - see registerTasks in ExternalMode.kt), they just expect node_modules + # to already have what npm(...) plugin refs and console { rcon {} } need. - uses: drownek/plugwright-action@v1 with: java-version: "17" node-version: "24" working-directory: "./example_plugin" + gradle-args: plugwrightProvisionLocal plugwrightCompileTests gradle-args: plugwrightProvisionLocal # The provisioning above resolves a Java 21 toolchain for itself (build.gradle.kts pins # languageVersion 21 for Paper 1.21.11), but that's Gradle's own on-demand download, not From 41d7173c1021769c2598f02442b6d445602204a3 Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 28 Aug 2026 16:16:05 +0300 Subject: [PATCH 076/125] ci(#61): fix duplicate gradle-args key from the previous commit The console-rcon fix's replace missed the pre-existing gradle-args line, leaving two under the same 'with:' block. Plain YAML parsers silently keep the last one (which is why local yaml.safe_load passed), but GitHub Actions' own parser rejects it outright - the run failed in 0s with zero jobs registered, no logs at all. --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d3509f..31fd7bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,6 @@ jobs: node-version: "24" working-directory: "./example_plugin" gradle-args: plugwrightProvisionLocal plugwrightCompileTests - gradle-args: plugwrightProvisionLocal # The provisioning above resolves a Java 21 toolchain for itself (build.gradle.kts pins # languageVersion 21 for Paper 1.21.11), but that's Gradle's own on-demand download, not # something start.sh can reach - it just runs whatever "java" is on PATH, which From b40398b06a3c332dd1537be9205f923c8194b3e1 Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 28 Aug 2026 18:04:32 +0300 Subject: [PATCH 077/125] feat(auth-authme): replace missing-prompt guess with sessionResumedPattern Instead of assuming login and sending a command blind when no prompt shows up in time, wait for AuthMe's own session-resume message alongside the two prompts. A match resolves auth immediately, no command sent; anything else within timeoutMs is now a hard failure instead of a guess. Drops failOnMissingPrompt: a missing prompt has no fallback to opt out of anymore. --- auth-authme-package/README.md | 5 +++- auth-authme-package/index.ts | 43 ++++++++++++++++++----------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/auth-authme-package/README.md b/auth-authme-package/README.md index 07483ec..95ade07 100644 --- a/auth-authme-package/README.md +++ b/auth-authme-package/README.md @@ -31,7 +31,9 @@ The same block works on a `LocalMode` environment. A local server running AuthMe ## Which command it sends -The server decides, not the account. `account.justCreated` is a hint from the account pool, and it is wrong every time a pool account outlives the run that created it — that is the second run against any stand. So the plugin waits for either prompt and answers whichever arrived. The register pattern is tested first, since AuthMe's register prompt mentions the password too and would otherwise look like a login prompt. +The server decides, not the account. `account.justCreated` is a hint from the account pool, and it is wrong every time a pool account outlives the run that created it — that is the second run against any stand. So the plugin waits for whichever of a register prompt, a login prompt, or a session-resume message arrives, and acts on that. The register pattern is tested first, since AuthMe's register prompt mentions the password too and would otherwise look like a login prompt. + +A reconnect within AuthMe's own session timeout gets no prompt at all — AuthMe already considers the account logged in and says so via `sessionResumedPattern` instead. The plugin stops there without sending a command. Nothing matching within `timeoutMs` is treated as a genuine failure, not a stale session. ## Options @@ -43,6 +45,7 @@ The server decides, not the account. `account.justCreated` is a hint from the ac | `registerPromptPattern` | `regist` | Regex identifying the register prompt | | `successPattern` | `success\|welcome\|logged in\|authenticat` | Regex confirming the command was accepted | | `authenticatedPattern` | `logged in\|authenticat` | Narrower regex confirming the player is actually authenticated | +| `sessionResumedPattern` | `Session Reconnection` | Regex confirming AuthMe resumed the session on its own, no prompt needed | | `timeoutMs` | `15000` | How long to wait for each prompt or confirmation | | `password` | — | Fallback password for accounts that carry none | diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index dc1f217..c43618e 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -28,10 +28,11 @@ export interface AuthAuthmeOptions { authenticatedPattern?: string; /** How long to wait for each prompt/confirmation before giving up. */ timeoutMs?: number; - /** Fail the connection when no login/register prompt arrives at all, instead of assuming - * login and sending the command anyway. Some servers require the prompt as proof the - * player isn't already authenticated by something else; most don't. */ - failOnMissingPrompt?: boolean; + /** Regex matched against server messages confirming AuthMe resumed an existing session on + * its own — no login/register needed. AuthMe sends this instead of a prompt when it + * considers the connecting player already authenticated (a reconnect within its session + * timeout). */ + sessionResumedPattern?: string; /** Password used for accounts that carry none of their own — the throwaway identities an * environment without an account pool generates per bot. Plugin options travel as plain * values, so only use this where the password is worth nothing: a local, disposable @@ -47,7 +48,7 @@ const DEFAULTS: Required> = { successPattern: 'success|welcome|logged in|authenticat', authenticatedPattern: 'success(ful)? login|logged in|authenticat', timeoutMs: 15000, - failOnMissingPrompt: false, + sessionResumedPattern: 'Session Reconnection', }; // `onPlayerCreate` doesn't receive the plugin's options — only `setup()` does — so the @@ -85,13 +86,14 @@ export default definePlugin({ const registerPrompt = new RegExp(resolved.registerPromptPattern, 'i'); const loginPrompt = new RegExp(resolved.loginPromptPattern, 'i'); + const sessionResumed = new RegExp(resolved.sessionResumedPattern, 'i'); const successPattern = new RegExp(resolved.successPattern, 'i'); // Which of the two the server asks for is the server's decision, not ours: // `account.justCreated` is a hint from the account pool, and it is wrong whenever a - // pool account outlives the run that created it. So wait for either prompt and answer - // the one that actually arrived. Register is tested first because AuthMe's register - // prompt names the password too, and would otherwise match the login pattern. + // pool account outlives the run that created it. So wait for whichever of the three + // arrives and act on that. Register is tested first because AuthMe's register prompt + // names the password too, and would otherwise match the login pattern. // // Hardcoded to 0 rather than `player.getMessageBufferIndex()`: the login prompt can // arrive during the handshake, before this handler even runs, so reading the buffer @@ -102,26 +104,25 @@ export default definePlugin({ const since = (index: number, pattern: RegExp): string | undefined => player.messageBuffer.slice(index).find((m: string) => pattern.test(m)); - // The server occasionally sends no prompt at all on a reconnect — AuthMe still - // considers the account logged in from a connection that never fully closed. Rather - // than fail the whole test over one missing prompt, assume login (never registration: - // a silent reconnect can only happen to an account that already exists) and let the - // command below run into either a real prompt AuthMe queued up in the meantime, or an - // "already logged in" reply — both success/authenticated patterns already match it. - // `failOnMissingPrompt` opts back into the strict behavior, for servers where a missing - // prompt is a real problem rather than a stale session. - const promptSeen = poll( + // The server occasionally reconnects a player without prompting at all — AuthMe still + // considers the account logged in from a connection that never fully closed, and says + // so with `sessionResumedPattern` instead of a prompt. Trust that message and stop here: + // no command to send, nothing left to confirm. Anything else within `timeoutMs` is a + // genuine miss, not a stale session, and throws. + const promptResult = await poll<'register' | 'login' | 'resumed'>( () => { - if (since(joinIndex, registerPrompt)) return true; - if (since(joinIndex, loginPrompt)) return false; + if (since(joinIndex, registerPrompt)) return 'register'; + if (since(joinIndex, loginPrompt)) return 'login'; + if (since(joinIndex, sessionResumed)) return 'resumed'; return undefined; }, { timeout: resolved.timeoutMs, - message: `authme: never saw a login or register prompt for "${account.username}"`, + message: `authme: never saw a login/register prompt or session-resume message for "${account.username}"`, }, ); - const isRegistration = resolved.failOnMissingPrompt ? await promptSeen : await promptSeen.catch(() => false); + if (promptResult === 'resumed') return; + const isRegistration = promptResult === 'register'; // Everything below only looks at messages newer than the command. A server's greeting // often carries a word like "welcome", which would otherwise pass for confirmation From ede3dda9057c1f7eadab69ec57fda3b80606c18d Mon Sep 17 00:00:00 2001 From: Monikon Date: Fri, 28 Aug 2026 18:04:38 +0300 Subject: [PATCH 078/125] test(example): cover authme session-resume, enable AuthMe sessions sessions.enabled was off, so a reconnecting bot never got AuthMe's resume message and the new code path went untested. Turn it on and add a rejoin test asserting the resume message arrives with no login/register command sent. --- example_plugin/build.gradle.kts | 5 ++++- .../src/test/e2e/tests/auth-resume.spec.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 example_plugin/src/test/e2e/tests/auth-resume.spec.ts diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index 0ab41e4..79a1955 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -57,7 +57,10 @@ plugwright { file("plugins/AuthMe/config.yml", """ settings: sessions: - enabled: false + # On, so a bot that reconnects within the session timeout gets + # resumed silently instead of prompted — the case the authme + # plugin's sessionResumedPattern is built to detect. + enabled: true registration: dialog: preJoin: diff --git a/example_plugin/src/test/e2e/tests/auth-resume.spec.ts b/example_plugin/src/test/e2e/tests/auth-resume.spec.ts new file mode 100644 index 0000000..423353e --- /dev/null +++ b/example_plugin/src/test/e2e/tests/auth-resume.spec.ts @@ -0,0 +1,15 @@ +/** + * Reproduces a bot reconnect while AuthMe still considers it logged in — no login/register + * prompt arrives at all. Covers the fix: the authme plugin recognizes AuthMe's own + * session-resume message and stops there, instead of guessing and sending a command blind. + */ + +import { expect, test } from '@plugwright/runner'; + +test('rejoin resumes the session without a prompt', async ({ player }) => { + // onPlayerCreate has already run and resolved by the time rejoin() returns, so the + // resume message is in the buffer already — no extra wait needed. + await player.rejoin(); + + await expect(player).toHaveReceivedMessage(/session reconnection/i); +}); From 9b81ac65008250f597bfeefe8bd48ae22f596049 Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 28 Aug 2026 17:57:25 +0200 Subject: [PATCH 079/125] build: use npm ci instead of npm install for packages --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 56c469f..f7e998b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "scripts": { "bump": "node scripts/bump-version.js", - "install:packages": "npm install --prefix runner-package && npm install --prefix auth-authme-package && npm install --prefix console-rcon-package", + "install:packages": "npm ci --prefix runner-package && npm ci --prefix auth-authme-package && npm ci --prefix console-rcon-package", "build:packages": "npm run build --prefix runner-package && npm run build --prefix auth-authme-package && npm run build --prefix console-rcon-package", "publish:packages": "node scripts/publish.js" } From ebf256a5b67eacbec36f8720bce42f0e3f356d5f Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 28 Aug 2026 18:01:06 +0200 Subject: [PATCH 080/125] ci: use java 21 directly in plugwright-action --- .github/workflows/ci.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31fd7bc..3ff8fc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: npm run build:packages - uses: drownek/plugwright-action@v1 with: - java-version: "17" + java-version: "21" node-version: "24" working-directory: "./example_plugin" @@ -58,18 +58,10 @@ jobs: # to already have what npm(...) plugin refs and console { rcon {} } need. - uses: drownek/plugwright-action@v1 with: - java-version: "17" + java-version: "21" node-version: "24" working-directory: "./example_plugin" gradle-args: plugwrightProvisionLocal plugwrightCompileTests - # The provisioning above resolves a Java 21 toolchain for itself (build.gradle.kts pins - # languageVersion 21 for Paper 1.21.11), but that's Gradle's own on-demand download, not - # something start.sh can reach - it just runs whatever "java" is on PATH, which - # plugwright-action pinned to 17 above for the Gradle daemon. Put a real JDK 21 there. - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" # generated/ is gitignored, so start.sh - the launcher example_plugin/README.md tells # developers to hand-write - never made it into the provisioned run dir. Copy in the # tracked copy. From a8bfbe79a0b5f74541b6133e585eee3df8074ffd Mon Sep 17 00:00:00 2001 From: Drownek Date: Sat, 29 Aug 2026 10:31:16 +0200 Subject: [PATCH 081/125] ci: replace gradle ping loop with log tail and disable daemon --- .github/workflows/ci.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ff8fc3..4bed303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: test-example-plugin: if: "!contains(github.event.head_commit.message, 'skip-ci')" runs-on: ubuntu-latest + env: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" steps: - uses: actions/checkout@v4 @@ -37,6 +39,7 @@ jobs: if: "!contains(github.event.head_commit.message, 'skip-ci')" runs-on: ubuntu-latest env: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" # Test-only credentials for the Paper server this job starts and tears down itself. PLUGWRIGHT_RCON_PASSWORD: plugwright PLUGWRIGHT_BOT_PASSWORD: plugwright @@ -73,16 +76,12 @@ jobs: nohup ./start.sh > stand-server.log 2>&1 & echo $! > stand-server.pid - name: Wait for the stand server + working-directory: ./example_plugin/src/test/e2e/generated/local/run + timeout-minutes: 2 + run: tail -n +1 -f stand-server.log | grep -q -m 1 'Done' + - name: Verify authentication (Ping) working-directory: ./example_plugin - run: | - for i in $(seq 1 30); do - if ./gradlew plugwrightPingStand; then - exit 0 - fi - sleep 2 - done - echo "stand server did not come up in time" >&2 - exit 1 + run: ./gradlew plugwrightPingStand - name: Run the stand suite working-directory: ./example_plugin run: ./gradlew plugwrightTestStand From bf056c8872a78e0d4f6ffae11aec026191f2a7f2 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 15:51:34 +0300 Subject: [PATCH 082/125] feat(runner): let createPlayer take a password for a named bot A bot named by the test bypasses the account pool, so nothing knew a password for it and an auth plugin had no way to log it in. The only workaround was authme's plugin-wide `password` option, which is a plain value and documented as local-throwaway only. createPlayer({ username, password }) threads the password through to the synthetic account. No registration state needed: onPlayerCreate already ignores account.justCreated and answers whichever prompt arrives, so a name the server knows logs in and a fresh one registers. A password without a username, where the environment has a pool, throws rather than being silently dropped. --- docs/api-reference.mdx | 27 +++++++++++++++++++++++++++ docs/external-servers.mdx | 6 ++++-- runner-package/lib/account.ts | 8 ++++++-- runner-package/lib/test-runner.ts | 22 ++++++++++++++++------ runner-package/lib/types.ts | 8 ++++++-- 5 files changed, 59 insertions(+), 12 deletions(-) diff --git a/docs/api-reference.mdx b/docs/api-reference.mdx index b5ed7fd..5144c4f 100644 --- a/docs/api-reference.mdx +++ b/docs/api-reference.mdx @@ -120,6 +120,33 @@ const player2 = await createPlayer(); player2.chat('/tpa ' + player.username); ``` + + Connect as this exact name instead of taking whatever the environment's account pool has free. + + + + Names the bot inside a `describe.serial` block, so a later test in that block gets the same bot + back instead of connecting another one. + + + + The password an authentication plugin logs `username` in with. Only for a named bot: a pool + account already carries its own, and passing both is an error. + + +Ask for a name only when the test needs that specific identity — an account somebody provisioned +by hand, or a name that came out of an external API. A second bot that is only there to be a +second player should stay unnamed, so a stand can lease it from the pool. + +```javascript +const friend = await createPlayer({ + username: 'FriendBot', + password: process.env.FRIEND_BOT_PASSWORD, +}); +``` + +Read the password from the environment. Spec files go to git. + ### `sleep(ms)` Pauses execution for a specified number of milliseconds. diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index d259184..80a0445 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -85,10 +85,12 @@ One account is leased per bot and returned in a `finally`, whatever the test did An explicitly named bot bypasses the pool entirely: ```ts -const friend = await createPlayer({ username: 'FriendBot' }); +const friend = await createPlayer({ username: 'FriendBot', password: process.env.FRIEND_BOT_PASSWORD }); ``` -That is a request for a specific identity, not for whatever is free — so nothing knows its password. On a stand behind a login wall, either leave those tests to the local environment or give the account a password some other way. +That is a request for a specific identity, not for whatever is free, so the pool knows nothing about it and neither does your authentication plugin. Pass the password with the name. Read it from the environment; the spec file goes to git. + +Most second bots don't need this. A test that just wants another player should call `createPlayer()` with no arguments and let the pool answer — a name is worth asking for when the identity is, because somebody provisioned that account with a permission group or a balance, or because the name came from somewhere outside the test. A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, exclude what you can't, and treat `capabilities.freshState = false` as the honest description it is. diff --git a/runner-package/lib/account.ts b/runner-package/lib/account.ts index 0c4685e..b226255 100644 --- a/runner-package/lib/account.ts +++ b/runner-package/lib/account.ts @@ -19,9 +19,13 @@ export interface Account { /** * Stand-in used when an environment has no [AccountPool] of its own — a `local` bot is a * fresh offline-mode connection under a name the server has never seen. + * + * [password] is for the other case: a bot the test names itself. That bypasses the pool, so + * nothing else knows a password for it, and the test has to bring one for the authentication + * plugin to use. */ -export function syntheticAccount(username: string): Account { - return { username, auth: 'offline', justCreated: true }; +export function syntheticAccount(username: string, password?: string): Account { + return { username, password, auth: 'offline', justCreated: true }; } /** A short random identity suffix. Four hex digits: long enough that two names in a run diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index a492ca2..5b8627e 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -34,10 +34,12 @@ export interface RunSerialBlockParams { /** Bots created while one test, or one `describe.serial` block, is running: who leased what, * which player answers to which `as` name, and how to give it all back. */ interface BotScope { - connect(options?: { username?: string; account?: string }): Promise; + connect(options?: { username?: string; account?: string; password?: string }): Promise; /** `ctx.createPlayer`. `as` names the player so a later call — a later test, inside a block — - * gets the same bot back instead of connecting a second one. */ - createPlayer(options?: { username?: string; as?: string }): Promise; + * gets the same bot back instead of connecting a second one. `password` belongs with + * `username`: a named bot is not a pool account, so the test is the only thing that can + * say how it logs in. */ + createPlayer(options?: { username?: string; as?: string; password?: string }): Promise; /** Every player connected in this scope, in the order they joined. */ players(): PlayerWrapper[]; /** Disconnects every bot in the scope and returns the accounts they held. */ @@ -49,7 +51,7 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo const named = new Map(); const connected: PlayerWrapper[] = []; - const connect = async (options?: { username?: string; account?: string }): Promise => { + const connect = async (options?: { username?: string; account?: string; password?: string }): Promise => { const pool = options?.username ? null : session.env.accounts?.() ?? null; if (options?.account && !pool) { throw new Error( @@ -57,9 +59,17 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo 'to take it from — a named account needs one the build script declares.' ); } + // A pooled account brings its own password, so a password with no username would be + // read by nothing. Say so rather than connect as somebody else's account and ignore it. + if (options?.password && pool) { + throw new Error( + `a password was passed without a username, but environment "${session.env.id}" leases its ` + + 'accounts from a pool and those carry their own. Name the bot too, or drop the password.' + ); + } const account: Account = pool ? await pool.lease(options?.account) - : syntheticAccount(options?.username || `pw_${randomSuffix()}`); + : syntheticAccount(options?.username || `pw_${randomSuffix()}`, options?.password); try { const botUsername = account.username; @@ -97,7 +107,7 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo const existing = named.get(handle); if (existing) return existing; } - const player = await connect({ username: options?.username }); + const player = await connect({ username: options?.username, password: options?.password }); if (handle) named.set(handle, player); return player; }, diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 6c979f4..acf20ea 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -6,8 +6,12 @@ export interface TestContext { server: ServerWrapper; /** Connects an extra bot. Inside a `describe.serial` block, `as` names it: the same name in * a later test of that block returns the same bot instead of connecting another. Outside a - * block the name is scoped to the one test, which is as long as the bot lives anyway. */ - createPlayer: (options?: { username?: string; as?: string }) => Promise; + * block the name is scoped to the one test, which is as long as the bot lives anyway. + * + * `username` asks for one specific identity instead of whatever the pool has free, and + * `password` is what an authentication plugin logs that identity in with. Read it from the + * environment rather than writing it in the spec — spec files go to git. */ + createPlayer: (options?: { username?: string; as?: string; password?: string }) => Promise; /** Says the player is in a state the tests after this one were not written for. Inside a * `describe.serial` block that stops the block: the rest is reported skipped. Outside one * it does nothing — the bot is disconnected at the end of the test either way. */ From ffecdc3ab96577aa3fb5653cb9befd0d38deef9d Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 15:51:44 +0300 Subject: [PATCH 083/125] test(example): unname the second bot, run multi-bot and Cross-bot on the stand Neither test needs the name FriendBot; both just need a second player. createPlayer() with no username leases from the pool, so on the stand they get a real account instead of bypassing it, and they come off excludeTests. --- example_plugin/build.gradle.kts | 6 ++---- example_plugin/src/test/e2e/tests/message-buffer.spec.ts | 2 +- example_plugin/src/test/e2e/tests/multi-bot.spec.ts | 5 +++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index 79a1955..14205ba 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -132,11 +132,9 @@ plugwright { // Matched against test names. What is left out here is what no command puts back: // an arena slot that is filled once and stays filled, and a first join, which only // happens on an account the server has never seen. Op, inventory, balance and kit - // cooldowns are reset per test by the stand-reset plugin instead. multi-bot and - // Cross-bot are out for a different reason — they name their second bot, and a named - // bot is not a pool account, so nothing knows its password. + // cooldowns are reset per test by the stand-reset plugin instead. excludeTests.set(listOf( - "arena", "first join", "multi-bot", "Cross-bot" + "arena", "first join" )) } } diff --git a/example_plugin/src/test/e2e/tests/message-buffer.spec.ts b/example_plugin/src/test/e2e/tests/message-buffer.spec.ts index 345a559..2900fdb 100644 --- a/example_plugin/src/test/e2e/tests/message-buffer.spec.ts +++ b/example_plugin/src/test/e2e/tests/message-buffer.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@plugwright/runner'; test('Cross-bot message separation', async ({ player, createPlayer }) => { - const friend = await createPlayer({ username: 'FriendBot' }); + const friend = await createPlayer(); player.chat('/help'); diff --git a/example_plugin/src/test/e2e/tests/multi-bot.spec.ts b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts index d1a6b11..4cda208 100644 --- a/example_plugin/src/test/e2e/tests/multi-bot.spec.ts +++ b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts @@ -10,8 +10,9 @@ test('multi-bot teleportation', async ({ player, createPlayer }) => { // This can be also done with defining test as `opTest` instead of `test` or even within `beforeEach` block. await player.makeOp(); - // Spawn a second player - const friend = await createPlayer({ username: 'FriendBot' }); + // Spawn a second player. No username: the test needs a second bot, not a specific one, + // so on a stand this leases the next free pool account instead of bypassing the pool. + const friend = await createPlayer(); // Teleport the friend to a specific location // We wait for friend player to actually teleport. From 1823e821336dfe94defcaf598def28438ba50f62 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 17:37:49 +0300 Subject: [PATCH 084/125] fix(runner): read the console log from a cursor instead of clearing it session.consoleLog.clear() wiped the whole shared buffer at the start of every test. Fine when only one test runs at a time, but it means two tests running concurrently would race to wipe each other's messages out from under them. ServerWrapper now captures its own startIndex at construction (and can resetCursor() to "now"), and the toHaveReceivedMessage matcher defaults to reading from that instead of index 0 when since isn't given. Same observable behavior for a solo test, but the log itself is never destroyed, so nothing racing to read it can lose messages. --- runner-package/lib/matchers.ts | 6 ++++-- runner-package/lib/server.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/runner-package/lib/matchers.ts b/runner-package/lib/matchers.ts index 6bab347..dd5c522 100644 --- a/runner-package/lib/matchers.ts +++ b/runner-package/lib/matchers.ts @@ -87,11 +87,13 @@ export class RunnerMatchers extends Matchers { // 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. + // it's one console shared by the whole session — and never cleared, so a test that + // doesn't pass `since` defaults to its own `ServerWrapper.startIndex` instead of 0. const buffer = this.actual instanceof PlayerWrapper ? this.actual.messageBuffer : session.consoleLog; - const view = (): string[] => buffer.slice(since); + const effectiveSince = since ?? (this.actual instanceof PlayerWrapper ? undefined : this.actual.startIndex); + const view = (): string[] => buffer.slice(effectiveSince); await this.pollAssertion( () => view().some(isMatch), diff --git a/runner-package/lib/server.ts b/runner-package/lib/server.ts index 876423a..bfa32f6 100644 --- a/runner-package/lib/server.ts +++ b/runner-package/lib/server.ts @@ -2,9 +2,21 @@ import type { Session } from './session.js'; export class ServerWrapper { readonly session: Session; + /** Default read cursor for `toHaveReceivedMessage` when no `since` is given — the log index + * at construction time, so a fresh test only sees lines from its own start. Non-destructive + * replacement for the old `session.consoleLog.clear()`: the log itself is never wiped, so + * concurrent tests reading it don't race. */ + startIndex: number; constructor(session: Session) { this.session = session; + this.startIndex = session.consoleLog.length; + } + + /** Moves the default read cursor to "now". Used by a `describe.serial` block between its + * tests, which share one `ServerWrapper` — the block-level equivalent of a fresh one. */ + resetCursor(): void { + this.startIndex = this.session.consoleLog.length; } execute(cmd: string): void { From b71620d88113262a7e0a24317055df317d071b1d Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 17:38:01 +0300 Subject: [PATCH 085/125] feat(runner): add concurrency option to test() and describe.serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test(name, { concurrency: N }, fn) and describe.serial(name, { concurrency: N }, fn) fan out into N independent instances running at once, each with its own bot leased from the account pool. One failing instance fails the whole result — this is for races between real players, not a pass-rate to average. - Whole-session preflight: every spec file is loaded (imported once, registrations snapshotted) before any test runs, and every concurrency value is checked against the account pool's capacity() up front. A misconfigured concurrency aborts immediately instead of the Nth lease() hanging mid-run. An environment with no pool (LocalMode mints a throwaway account per bot) has nothing to check. - createBotScope.close() used to call session.disconnectAllBots() with no arguments, tearing down every bot in the session rather than just its own scope's. Harmless when nothing ran concurrently; with concurrency it would mean one finishing instance kicking every still-running sibling's bot. Fixed to keep every bot outside its own scope. - The report still has one row per test/test-in-block. Its durationMs is the slowest instance, and a new instances array carries every instance's own outcome (bot username, pass/fail, duration) so a failure names which bot lost the race. Wired into the console summary and the JSON report; JUnit keeps the single aggregated pass/fail, no per-instance breakdown. - A concurrent describe.serial block runs N full copies of the block; instances can diverge mid-block (one loses its race and stops early while another keeps going), so a test position only counts as skipped if every instance skipped it. - Console log lines (Test:, Serial block:, PASSED/FAILED, bot creation) are tagged with [i/N] — N instances logging the same test name at the same time is unreadable without it. --- runner-package/lib/reporter.ts | 32 +++++++ runner-package/lib/test-registry.ts | 19 ++++ runner-package/lib/test-runner.ts | 135 ++++++++++++++++++++++------ runner-package/lib/types.ts | 15 ++++ runner-package/runner.ts | 115 ++++++++++++++++++------ 5 files changed, 261 insertions(+), 55 deletions(-) diff --git a/runner-package/lib/reporter.ts b/runner-package/lib/reporter.ts index 8211386..116a8ed 100644 --- a/runner-package/lib/reporter.ts +++ b/runner-package/lib/reporter.ts @@ -15,6 +15,16 @@ function statusOf(result: TestResult): 'PASS' | 'FAIL' | 'SKIP' { return result.passed ? 'PASS' : 'FAIL'; } +/** min/avg/max duration across a `concurrency > 1` result's instances. */ +function instanceStats(instances: NonNullable): { min: number; avg: number; max: number } { + const durations = instances.map(i => i.durationMs); + return { + min: Math.min(...durations), + avg: Math.round(durations.reduce((sum, d) => sum + d, 0) / durations.length), + max: Math.max(...durations), + }; +} + export function printTestSummary(testResults: TestResult[]): number { console.log(`\n${pc.bold("=".repeat(40))}`); console.log(pc.bold(' Test Summary')); @@ -83,6 +93,17 @@ export function printTestSummary(testResults: TestResult[]): number { } } + if (result.instances) { + const { min, avg, max } = instanceStats(result.instances); + console.log(` ${pc.dim(`${result.instances.length} instances: min ${formatDuration(min)} / avg ${formatDuration(avg)} / max ${formatDuration(max)}`)}`); + for (const instance of result.instances) { + const label = instance.botUsername ?? '?'; + const status = instance.passed ? pc.green('OK') : pc.red('FAIL'); + const detail = instance.error ? pc.red(` ${instance.error.message}`) : ''; + console.log(` ${pc.dim(`- ${label}:`)} ${status} ${pc.dim(`(${formatDuration(instance.durationMs)})`)}${detail}`); + } + } + console.log(''); } @@ -126,6 +147,17 @@ export function writeJsonReport(path: string, environmentName: string, testResul error: r.error ? r.error.message : null, skipReason: r.skipReason ?? null, plugin: r.plugin ?? null, + botUsername: r.botUsername ?? null, + // Present when this row aggregates a `concurrency > 1` test/block: every instance's + // own outcome, so a failure names which bot lost the race instead of just that one did. + instances: r.instances + ? r.instances.map(i => ({ + botUsername: i.botUsername ?? null, + passed: i.passed, + durationMs: i.durationMs, + error: i.error ? i.error.message : null, + })) + : null, })), }; diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 6ec04ec..89a5e95 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -14,6 +14,11 @@ type TestFn = (context: TestContext) => Promise; export interface TestOptions { requires?: string[]; environments?: string[]; + /** Runs this many independent instances of the test concurrently, each with its own bot + * leased from the account pool, to exercise races between players hitting the same feature + * at once. One failing instance fails the whole test. Defaults to 1 (sequential, no pool + * requirement). Validated against the pool's capacity before any test runs. */ + concurrency?: number; } interface DescribeScope { @@ -32,6 +37,7 @@ export interface TestCase { afterHooks: Hook[]; requires: string[]; environments: string[] | null; + concurrency: number; } /** What a `describe.serial` block accepts beyond the usual filters. */ @@ -50,6 +56,7 @@ export interface SerialBlock { tests: TestCase[]; requires: string[]; environments: string[] | null; + concurrency: number; } export type RegistryItem = @@ -82,9 +89,20 @@ function scopedEntry(name: string, options: TestOptions) { afterHooks: [...scopeStack].reverse().flatMap(s => s.afterHooks), requires: options.requires ?? [], environments: options.environments ?? null, + concurrency: normalizeConcurrency(options.concurrency), }; } +/** `concurrency` must be a whole number of at least 1 — anything else can't be turned into a + * bot count. Checked at registration time so a typo fails on import, not mid-run. */ +function normalizeConcurrency(concurrency: number | undefined): number { + if (concurrency === undefined) return 1; + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error(`concurrency must be a whole number >= 1, got ${concurrency}`); + } + return concurrency; +} + function registerTest(name: string, options: TestOptions, fn: TestFn): void { const testCase = { ...scopedEntry(name, options), fn }; if (currentBlock) { @@ -156,6 +174,7 @@ function serialImpl(label: string, optionsOrFn: SerialOptions | (() => void), ma tests: [], requires: options.requires ?? [], environments: options.environments ?? null, + concurrency: normalizeConcurrency(options.concurrency), }; currentBlock = block; diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index a492ca2..c3ff851 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -10,6 +10,17 @@ import type { BotConnectionOptions } from './environment.js'; import type { SerialBlock, TestCase } from './test-registry.js'; import type { TestContext, TestResult } from './types.js'; +/** Which of a `concurrency > 1` run's N instances this is, for console log labeling — without + * it, several instances logging the same test name at the same time is unreadable. */ +export interface InstanceTag { + index: number; + total: number; +} + +function formatInstanceTag(instance?: InstanceTag): string { + return instance ? pc.dim(` [${instance.index}/${instance.total}]`) : ''; +} + export interface RunTestCaseParams { file: string; testCase: TestCase; @@ -19,6 +30,8 @@ export interface RunTestCaseParams { timeoutMs: number; /** Set when this test came from a plugin's inherited `tests`, for report labeling. */ pluginName?: string | null; + /** Set by `runConcurrentTestCase` on each fanned-out instance, for console log labeling. */ + instance?: InstanceTag; } export interface RunSerialBlockParams { @@ -29,6 +42,8 @@ export interface RunSerialBlockParams { connOpts: BotConnectionOptions; timeoutMs: number; pluginName?: string | null; + /** Set by `runConcurrentSerialBlock` on each fanned-out instance, for console log labeling. */ + instance?: InstanceTag; } /** Bots created while one test, or one `describe.serial` block, is running: who leased what, @@ -44,7 +59,7 @@ interface BotScope { close(): Promise; } -function createBotScope(session: Session, server: ServerWrapper, connOpts: BotConnectionOptions): BotScope { +function createBotScope(session: Session, server: ServerWrapper, connOpts: BotConnectionOptions, instance?: InstanceTag): BotScope { const leased: Array<{ account: Account; pool: AccountPool }> = []; const named = new Map(); const connected: PlayerWrapper[] = []; @@ -63,7 +78,7 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo try { const botUsername = account.username; - console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(botUsername)}`); + console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(botUsername)}${formatInstanceTag(instance)}`); await session.env.beforeJoin?.(); @@ -103,7 +118,10 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo }, players: () => [...connected], async close(): Promise { - await session.disconnectAllBots(); + // Only this scope's own bots — a concurrent sibling instance's bots are still + // running and must not be torn down by this one finishing first. + const ownBots = new Set(connected.map(p => p.bot)); + await session.disconnectAllBots(session.bots.filter(b => !ownBots.has(b))); for (const { account, pool } of leased) pool.release(account); leased.length = 0; named.clear(); @@ -175,12 +193,12 @@ async function executeTest(params: ExecuteParams): Promise { await Promise.race([body().finally(() => clearTimeout(timeoutHandle)), timeoutPromise]); } -function reportPassed(durationMs: number): void { - console.log(` ${pc.green(pc.bold('PASSED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}\n`); +function reportPassed(durationMs: number, instance?: InstanceTag): void { + console.log(` ${pc.green(pc.bold('PASSED'))}${formatInstanceTag(instance)} ${pc.dim(`(${formatDuration(durationMs)})`)}\n`); } -function reportFailed(durationMs: number, error: Error): void { - console.log(` ${pc.red(pc.bold('FAILED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}: ${pc.red(error.message)}\n`); +function reportFailed(durationMs: number, error: Error, instance?: InstanceTag): void { + console.log(` ${pc.red(pc.bold('FAILED'))}${formatInstanceTag(instance)} ${pc.dim(`(${formatDuration(durationMs)})`)}: ${pc.red(error.message)}\n`); } /** @@ -188,13 +206,12 @@ function reportFailed(durationMs: number, error: Error): void { * body, and disconnects everything it created on the way out. */ export async function runTestCase(params: RunTestCaseParams): Promise { - const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null } = params; + const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null, instance } = params; - console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); - session.consoleLog.clear(); + console.log(` ${pc.bold(`Test: ${testCase.name}`)}${formatInstanceTag(instance)}`); const server = new ServerWrapper(session); - const bots = createBotScope(session, server, connOpts); + const bots = createBotScope(session, server, connOpts, instance); const finalizers: Array<() => void | Promise> = []; const startedAt = Date.now(); @@ -203,7 +220,7 @@ export async function runTestCase(params: RunTestCaseParams): Promise { + const { concurrency, ...rest } = params; + if (concurrency <= 1) return runTestCase(rest); + + const instanceResults = await Promise.all( + Array.from({ length: concurrency }, (_, i) => runTestCase({ ...rest, instance: { index: i + 1, total: concurrency } })) + ); + return aggregateInstances(instanceResults); +} + /** * Runs a `describe.serial` block: one player, one connection, its tests in declaration order. * @@ -245,13 +277,12 @@ export async function runTestCase(params: RunTestCaseParams): Promise { - const { file, block, session, plugins, connOpts, timeoutMs, pluginName = null } = params; + const { file, block, session, plugins, connOpts, timeoutMs, pluginName = null, instance } = params; - console.log(` ${pc.bold(`Serial block: ${block.name}`)}${block.account ? pc.dim(` (account ${block.account})`) : ''}`); - session.consoleLog.clear(); + console.log(` ${pc.bold(`Serial block: ${block.name}`)}${block.account ? pc.dim(` (account ${block.account})`) : ''}${formatInstanceTag(instance)}`); const server = new ServerWrapper(session); - const bots = createBotScope(session, server, connOpts); + const bots = createBotScope(session, server, connOpts, instance); const results: TestResult[] = []; let player: PlayerWrapper; @@ -260,7 +291,7 @@ export async function runSerialBlock(params: RunSerialBlockParams): Promise index === 0 ? { file, testName: testCase.name, passed: false, durationMs: 0, error: error as Error, plugin: pluginName } @@ -279,7 +310,7 @@ export async function runSerialBlock(params: RunSerialBlockParams): Promise void | Promise> = []; @@ -320,12 +351,12 @@ export async function runSerialBlock(params: RunSerialBlockParams): Promise { + const { concurrency, ...rest } = params; + if (concurrency <= 1) return runSerialBlock(rest); + + const instanceRuns = await Promise.all( + Array.from({ length: concurrency }, (_, i) => runSerialBlock({ ...rest, instance: { index: i + 1, total: concurrency } })) + ); + return instanceRuns[0].map((_, index) => aggregateInstances(instanceRuns.map(run => run[index]))); +} + +/** + * Rolls up N concurrent runs of the same test/test-position into one `TestResult`. `durationMs` + * is the slowest instance (roughly the wall-clock cost of the `Promise.all`). + * + * Only meaningful for a serial block: its instances can diverge mid-block (one instance's race + * loses and it stops early, skipping the rest, while another keeps going) so a position isn't + * uniformly pass/fail/skip the way a plain concurrent test's instances are. An actual failure in + * any instance fails the whole result; short of that, a position only counts as skipped if every + * instance skipped it — one instance actually exercising it is enough to call it run. + */ +function aggregateInstances(results: TestResult[]): TestResult { + const first = results[0]; + const failed = results.find(r => !r.skipped && !r.passed); + const allSkipped = results.every(r => r.skipped); + return { + file: first.file, + testName: first.testName, + plugin: first.plugin, + passed: !failed, + durationMs: Math.max(...results.map(r => r.durationMs)), + error: failed?.error, + skipped: !failed && allSkipped, + skipReason: !failed && allSkipped ? first.skipReason : undefined, + instances: results.map(r => ({ + botUsername: r.botUsername, + passed: r.passed, + durationMs: r.durationMs, + error: r.error, + })), + }; +} diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 6c979f4..6474d2c 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -18,6 +18,15 @@ export interface TestContext { cleanup: (fn: () => void | Promise) => void; } +/** One concurrent instance's own outcome, rolled up into the `instances` array of the + * aggregate `TestResult` for a `concurrency > 1` test/block. */ +export interface TestInstanceResult { + botUsername?: string; + passed: boolean; + durationMs: number; + error?: Error; +} + export interface TestResult { file: string; testName: string; @@ -30,4 +39,10 @@ export interface TestResult { skipReason?: string; /** Name of the plugin this test was inherited from, or null for a user spec. */ plugin?: string | null; + /** The bot that ran this test, when one connected. Absent for a skip, or a test that failed + * before it got as far as leasing a bot. */ + botUsername?: string; + /** Set when this result aggregates `concurrency > 1` concurrent instances: `passed` is AND + * across all of them, `durationMs` is the slowest, `error` is the first failure. */ + instances?: TestInstanceResult[]; } diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 07f14c7..d8b570a 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -7,7 +7,7 @@ import { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator } from './lib/wr import { testRegistry, resetRegistry } from './lib/test-registry.js'; import { Session } from './lib/session.js'; import { PluginHost } from './lib/plugin-host.js'; -import { runSerialBlock, runTestCase } from './lib/test-runner.js'; +import { runSerialBlock, runTestCase, runConcurrentSerialBlock, runConcurrentTestCase } from './lib/test-runner.js'; import { skipReasonForOptions } from './lib/skip-reason.js'; import { LocalEnvironment } from './lib/environments/local.js'; import { externalEnvironment } from './lib/environments/external.js'; @@ -19,7 +19,7 @@ import type { Environment } from './lib/environment.js'; import type { EnvironmentConfig, LocalEnvironmentConfig, RunnerConfig } from './lib/config.js'; import type { ExternalEnvironmentConfig } from './lib/environments/external.js'; import type { TestResult } from './lib/types.js'; -import type { SerialBlock, TestCase } from './lib/test-registry.js'; +import type { SerialBlock, TestCase, RegistryItem } from './lib/test-registry.js'; import type { Account, AccountPool } from './lib/account.js'; // Enable source map support for accurate TypeScript stack traces @@ -147,14 +147,54 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): return null; } - /** 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 { + /** One imported spec file's registered tests, snapshotted right after import so its + * concurrency values can be validated before any file's tests run — re-importing later + * to re-check wouldn't work anyway: ESM caches the module, so a second `import()` of + * the same file wouldn't re-run its top-level `test()`/`describe()` calls. */ + interface LoadedFile { + file: string; + pluginName: string | null; + items: RegistryItem[]; + } + + async function loadFile(file: string, pluginName: string | null): Promise { resetRegistry(); await import(pathToFileURL(file).href); + return { file, pluginName, items: [...testRegistry] }; + } + + /** Fails fast, before any test in the session runs, on a `concurrency` the account pool + * here can't satisfy — rather than the test itself blocking on its Nth `pool.lease()`. An + * environment with no pool (e.g. `LocalMode`) mints a synthetic throwaway account per + * connection instead of leasing one, so there's no pool capacity to check against; its + * ceiling is the server's own `max-players`, which is on the operator, not this check. */ + function validateConcurrency(loaded: LoadedFile[]): void { + const pool = env.accounts?.() ?? null; + if (!pool) return; + const capacity = pool.capacity(); + + for (const { file, items } of loaded) { + for (const item of items) { + const [kind, name, concurrency] = item.kind === 'serial' + ? ['describe.serial', item.block.name, item.block.concurrency] as const + : ['test', item.testCase.name, item.testCase.concurrency] as const; + if (concurrency <= 1) continue; + if (concurrency > capacity) { + throw new Error( + `${kind} "${name}" (${file}) declares concurrency: ${concurrency}, exceeding the ` + + `account pool's capacity (${capacity}). Reduce concurrency or grow the pool.` + ); + } + } + } + } + + /** Runs everything one loaded file registered, appending results to `testResults`. + * Shared by user specs and every plugin-inherited test file. */ + async function runLoadedFile(loaded: LoadedFile): Promise { + const { file, pluginName, items } = loaded; - for (const item of testRegistry) { + for (const item of items) { if (item.kind === 'serial') { const { block } = item; const skipReason = blockSkipReason(block); @@ -166,7 +206,10 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): continue; } - testResults.push(...await runSerialBlock({ file, block, session, plugins, connOpts, timeoutMs, pluginName })); + const results = block.concurrency > 1 + ? await runConcurrentSerialBlock({ file, block, session, plugins, connOpts, timeoutMs, pluginName, concurrency: block.concurrency }) + : await runSerialBlock({ file, block, session, plugins, connOpts, timeoutMs, pluginName }); + testResults.push(...results); continue; } @@ -178,22 +221,16 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): continue; } - const result = await runTestCase({ file, testCase, session, plugins, connOpts, timeoutMs, pluginName }); + const result = testCase.concurrency > 1 + ? await runConcurrentTestCase({ file, testCase, session, plugins, connOpts, timeoutMs, pluginName, concurrency: testCase.concurrency }) + : 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'}`); - } - } + const preflightEntries = [...plugins.testFiles('preflight')]; + const loadedPreflight: LoadedFile[] = []; + for (const { file, pluginName } of preflightEntries) loadedPreflight.push(await loadFile(file, pluginName)); let testFiles = await findSpecFiles(config.tests.dir || process.cwd()); if (testFileFilters) { @@ -208,20 +245,44 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): }) ); } + const loadedMain: LoadedFile[] = []; + for (const file of testFiles) loadedMain.push(await loadFile(file, null)); - console.log(`${pc.bold(`Found ${testFiles.length} test file(s)${testFileFilters ? ` matching filter: ${testFileFilters.join(',')}` : ''}`)}\n`); + const suiteEntries = [...plugins.testFiles('suite')]; + const loadedSuite: LoadedFile[] = []; + for (const { file, pluginName } of suiteEntries) loadedSuite.push(await loadFile(file, pluginName)); - for (const file of testFiles) { - console.log(`\n${pc.blue(pc.bold(`Running tests from: ${file}`))}`); - await runFile(file, null); + // Whole-session preflight: every file is loaded (imported once, registrations + // snapshotted) before any of them runs, so a misconfigured `concurrency` aborts here + // instead of after burning time on earlier tests. + validateConcurrency([...loadedPreflight, ...loadedMain, ...loadedSuite]); + + // Preflight: plugin auth/setup tests, run before anything else. A failure aborts the + // whole session. + for (const loaded of loadedPreflight) { + console.log(`\n${pc.blue(pc.bold(`Running preflight tests from: ${loaded.file} ${pc.dim(`(plugin ${loaded.pluginName})`)}`))}`); + const before = testResults.length; + await runLoadedFile(loaded); + const failed = testResults.slice(before).find(r => !r.skipped && !r.passed); + if (failed) { + throw new Error(`Preflight test "${failed.testName}" failed (plugin ${loaded.pluginName}): ${failed.error?.message ?? 'unknown error'}`); + } + } + + console.log(`${pc.bold(`Found ${loadedMain.length} test file(s)${testFileFilters ? ` matching filter: ${testFileFilters.join(',')}` : ''}`)}\n`); + + for (const loaded of loadedMain) { + console.log(`\n${pc.blue(pc.bold(`Running tests from: ${loaded.file}`))}`); + await runLoadedFile(loaded); } // 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); + for (const loaded of loadedSuite) { + console.log(`\n${pc.blue(pc.bold(`Running tests from: ${loaded.file} ${pc.dim(`(plugin ${loaded.pluginName})`)}`))}`); + await runLoadedFile(loaded); } + } finally { await plugins.runCleanup(session, 'session'); await plugins.teardown(); From de4e9cf424bb01f0f524cdb1d91c96052d71079e Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 17:38:08 +0300 Subject: [PATCH 086/125] test(example): add e2e coverage for concurrent test execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers both shapes: a plain test with concurrency, and a concurrent describe.serial block. Each instance checks its own marker against the shared server log and confirms it's still connected afterward — the two bugs concurrency exposed (destructive log clear, scope-wide bot teardown) would show up here as flaky or crashed instances. --- .../src/test/e2e/tests/concurrency.spec.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 example_plugin/src/test/e2e/tests/concurrency.spec.ts diff --git a/example_plugin/src/test/e2e/tests/concurrency.spec.ts b/example_plugin/src/test/e2e/tests/concurrency.spec.ts new file mode 100644 index 0000000..3f0b84c --- /dev/null +++ b/example_plugin/src/test/e2e/tests/concurrency.spec.ts @@ -0,0 +1,38 @@ +/** + * `concurrency` fans a test (or a `describe.serial` block) out into N independent bots running + * at once — the shape a race between real players needs. These also stand in as regression + * coverage for the two bugs that concurrency exposed in the previously-sequential-only runner: + * - `session.consoleLog` used to be wiped by `clear()` at the start of every test; N bots + * writing into it at once would have raced. Each instance now reads from its own + * `ServerWrapper.startIndex` cursor instead, so its own marker is never missed no matter what + * the other instances are doing to the same shared log. + * - `createBotScope.close()` used to disconnect every bot in the session, not just its own — + * the first instance to finish would have kicked every other still-running instance's bot. + */ + +import { describe, expect, test } from '@plugwright/runner'; + +test('concurrent bots each see their own marker and stay connected', { concurrency: 3 }, async ({ player, server }) => { + const marker = `concurrency-marker-${player.username}`; + player.chat(`/say ${marker}`); + await expect(server).toHaveReceivedMessage(marker, { timeout: 10000 }); + + // Still connected: an earlier-finishing sibling instance's teardown must not have + // disconnected this one. + await player.teleport(50, 100, 50); + await expect(player).toBeNear(50, 100, 50, { tolerance: 2, timeout: 10000 }); +}); + +describe.serial('concurrent kit lifecycle', { concurrency: 2 }, () => { + test('claims the starter kit', async ({ player }) => { + player.chat('/kit starter'); + + await expect(player).toHaveReceivedMessage('Received starter kit'); + await expect(player).toContainItem('diamond_sword'); + }); + + test('is on cooldown right after', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('cooldown'); + }); +}); From 405caacb31fd65fd1e2c22612f8f4e60732bb101 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 17:38:15 +0300 Subject: [PATCH 087/125] docs: document the concurrency option and its report shape Writing Tests gets a new section covering test()/describe.serial with concurrency: what it's for, what you get back, how many instances you can ask for, and why the server log stays shared and unfiltered across instances. Reports gets the aggregated JSON shape (instances array, botUsername) and a note that JUnit only ever sees the one aggregate. --- docs/reports.mdx | 26 +++++++++++++++++++++++++- docs/writing-tests.mdx | 38 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/docs/reports.mdx b/docs/reports.mdx index 49b7659..9367166 100644 --- a/docs/reports.mdx +++ b/docs/reports.mdx @@ -40,12 +40,36 @@ build/reports/plugwright/.log per-environment output, matrix runs o } ``` -`status` is `pass`, `fail` or `skip`. `plugin` names the plugin a test came from when it was inherited rather than found in your test directory. +`status` is `pass`, `fail` or `skip`. `plugin` names the plugin a test came from when it was inherited rather than found in your test directory. `botUsername` is the bot that ran it, when one connected. Every skip carries its reason: excluded by name, wrong environment, a capability the environment doesn't have, or an earlier test in the same [`describe.serial`](/writing-tests) block that stopped the chain. A skipped test that doesn't say why is worse than a failing one, because it reads as coverage. Tests from a serial block appear as ordinary entries, in the order they ran, under their full `describe` path. +## Concurrent tests + +A test (or block) run with [`concurrency`](/writing-tests) still gets one entry, not N. `durationMs` is the slowest instance, and `instances` carries every instance's own outcome: + +```json +{ + "file": "…/dist/claim.spec.js", + "name": "only one player can claim the chest", + "status": "fail", + "durationMs": 812, + "error": "Expected message matching \"Claimed\" not received", + "skipReason": null, + "plugin": null, + "botUsername": null, + "instances": [ + { "botUsername": "pw_a1", "passed": true, "durationMs": 640, "error": null }, + { "botUsername": "pw_b2", "passed": false, "durationMs": 812, "error": "Expected message matching \"Claimed\" not received" }, + { "botUsername": "pw_c3", "passed": true, "durationMs": 701, "error": null } + ] +} +``` + +`instances` is `null` for an ordinary, non-concurrent test — `botUsername` on the row itself is where its bot lives instead. The JUnit report doesn't carry this breakdown; it only ever sees the one aggregated pass/fail/duration, so read the JSON report when a concurrent test fails. + ## JUnit XML ```xml diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index 9319cc2..53225d8 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -199,7 +199,7 @@ describe.serial('vip shop', { account: 'pw_0001' }, async () => { }); ``` -The account must exist in the environment's `accounts { }` pool and be free. If it is already leased, or the environment has no pool at all (`local` invents a name per bot and has none), the block fails with that message rather than quietly running as somebody else. +The account must exist in the environment's `accounts { }` pool and be free. If it is already leased, or the environment has no pool at all (`LocalMode` invents a name per bot and has none), the block fails with that message rather than quietly running as somebody else. ### A second bot for the block @@ -221,6 +221,40 @@ describe.serial('trading', () => { }); ``` +## Racing bots against each other: `concurrency` + +One bot can't produce a race. Two players opening the same chest at once, three players buying the last item in stock — bugs like that only exist when several bots hit the same feature at the same time, on the same server. `concurrency` runs a test as N independent instances at once, each with its own bot: + +```typescript +test('only one player can claim the chest', { concurrency: 3 }, async ({ player }) => { + player.chat('/claim'); + await expect(player).toHaveReceivedMessage(/Claimed|already claimed/); +}); +``` + +Each instance leases its own account and runs the full test body on its own bot. The test only passes if every instance does — one instance losing the race it wasn't supposed to lose is the bug you're trying to catch, not noise to average away. + +`describe.serial` blocks take the same option, running N independent copies of the whole ordered chain at once: + +```typescript +describe.serial('kit lifecycle', { concurrency: 5 }, () => { + test('claims the starter kit', async ({ player }) => { /* ... */ }); + test('is on cooldown right after', async ({ player }) => { /* ... */ }); +}); +``` + +### The report still has one row per test + +`concurrency` multiplies how many bots run a test, not how many rows it produces in the summary. That row's duration is the slowest instance, and it carries an `instances` array with every instance's own outcome — bot username, pass or fail, duration — so a failure tells you which bot lost, not just that somebody did. + +### How many you can ask for + +`concurrency: N` needs N free accounts. On an environment with an account pool, this is checked before any test in the run starts: ask for `concurrency: 10` against a 4-account pool and it fails immediately with a clear error, instead of the 5th bot hanging on a lease nobody's going to release. `LocalMode` has no pool — it mints a throwaway account per bot — so there's nothing to check there; its only real ceiling is the server's own `max-players`. + +### The server log is still one shared log + +`expect(server)` reads the console output the whole session shares, so one instance's commands sit in that log right next to every other instance's. Nothing filters that for you, and that's deliberate — asserting the server log never produced something no bot should have caused (`expect(server).not.toHaveReceivedMessage('NullPointerException')`) is exactly what a concurrency test is for. If you need one bot's output specifically, either put its username in the pattern or check `expect(player)` instead, since a player's own messages never mix with another bot's. + ## Best Practices 1. **Keep tests isolated** - Each test gets a fresh bot unless it is in a `describe.serial` block @@ -232,7 +266,7 @@ describe.serial('trading', () => { ## Tips -- Tests run sequentially, not in parallel +- Tests run sequentially by default — a test that needs bots racing each other can opt into `concurrency` - Server starts fresh for each test run - Bot automatically connects to the server - Server logs are visible in the console output From 647d3d8e48df29033ffbca23ea8cd8785fab20e4 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 17:57:02 +0300 Subject: [PATCH 088/125] fix(example): skip the server-log concurrency test where console output isn't full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expect(server).toHaveReceivedMessage() needs consoleOutput: 'full'. The stand environment's RCON console only offers 'responses', same limitation simple-ts.spec.ts's 'server logs command execution' already declares — this test needed the same requires and didn't have it, so it failed test-example-plugin-stand in CI instead of skipping. --- example_plugin/src/test/e2e/tests/concurrency.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example_plugin/src/test/e2e/tests/concurrency.spec.ts b/example_plugin/src/test/e2e/tests/concurrency.spec.ts index 3f0b84c..df45bca 100644 --- a/example_plugin/src/test/e2e/tests/concurrency.spec.ts +++ b/example_plugin/src/test/e2e/tests/concurrency.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, test } from '@plugwright/runner'; -test('concurrent bots each see their own marker and stay connected', { concurrency: 3 }, async ({ player, server }) => { +test('concurrent bots each see their own marker and stay connected', { concurrency: 3, requires: ['consoleOutput:full'] }, async ({ player, server }) => { const marker = `concurrency-marker-${player.username}`; player.chat(`/say ${marker}`); await expect(server).toHaveReceivedMessage(marker, { timeout: 10000 }); From 1ef323225436c004784ff12fec42926f7282710b Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 18:11:25 +0300 Subject: [PATCH 089/125] feat(runner): show instance pass counts in the report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions, both surfacing data the aggregate result already had: - TestInstanceResult gets index (1-based, matching the [i/N] console log tag for that same run) — was missing from both the console detail breakdown and the JSON report. - The summary table's own row for a concurrent test now tags itself with [passed/total] next to the duration, instead of that count only showing up in the Failed Tests detail section below. A passing concurrent test previously gave no indication in the table that it was even concurrent. --- docs/reports.mdx | 6 +++--- runner-package/lib/reporter.ts | 11 +++++++++-- runner-package/lib/test-runner.ts | 3 ++- runner-package/lib/types.ts | 3 +++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/reports.mdx b/docs/reports.mdx index 9367166..54d7b4c 100644 --- a/docs/reports.mdx +++ b/docs/reports.mdx @@ -61,9 +61,9 @@ A test (or block) run with [`concurrency`](/writing-tests) still gets one entry, "plugin": null, "botUsername": null, "instances": [ - { "botUsername": "pw_a1", "passed": true, "durationMs": 640, "error": null }, - { "botUsername": "pw_b2", "passed": false, "durationMs": 812, "error": "Expected message matching \"Claimed\" not received" }, - { "botUsername": "pw_c3", "passed": true, "durationMs": 701, "error": null } + { "index": 1, "botUsername": "pw_a1", "passed": true, "durationMs": 640, "error": null }, + { "index": 2, "botUsername": "pw_b2", "passed": false, "durationMs": 812, "error": "Expected message matching \"Claimed\" not received" }, + { "index": 3, "botUsername": "pw_c3", "passed": true, "durationMs": 701, "error": null } ] } ``` diff --git a/runner-package/lib/reporter.ts b/runner-package/lib/reporter.ts index 116a8ed..dfa8a8c 100644 --- a/runner-package/lib/reporter.ts +++ b/runner-package/lib/reporter.ts @@ -65,7 +65,12 @@ export function printTestSummary(testResults: TestResult[]): number { ? 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))}`); + // A concurrent test/block's row is one aggregate over N instances — say how many + // passed right in the table, not just in the failed-tests detail below. + const instanceTag = result.instances + ? pc.dim(` [${result.instances.filter(i => i.passed).length}/${result.instances.length}]`) + : ''; + console.log(` ${coloredStatus} ${result.testName.padEnd(testWidth)} ${pc.dim(duration.padStart(durationWidth))}${instanceTag}`); } console.log(separator); @@ -97,10 +102,11 @@ export function printTestSummary(testResults: TestResult[]): number { const { min, avg, max } = instanceStats(result.instances); console.log(` ${pc.dim(`${result.instances.length} instances: min ${formatDuration(min)} / avg ${formatDuration(avg)} / max ${formatDuration(max)}`)}`); for (const instance of result.instances) { + const tag = `[${instance.index}/${result.instances.length}]`; const label = instance.botUsername ?? '?'; const status = instance.passed ? pc.green('OK') : pc.red('FAIL'); const detail = instance.error ? pc.red(` ${instance.error.message}`) : ''; - console.log(` ${pc.dim(`- ${label}:`)} ${status} ${pc.dim(`(${formatDuration(instance.durationMs)})`)}${detail}`); + console.log(` ${pc.dim(`- ${tag} ${label}:`)} ${status} ${pc.dim(`(${formatDuration(instance.durationMs)})`)}${detail}`); } } @@ -152,6 +158,7 @@ export function writeJsonReport(path: string, environmentName: string, testResul // own outcome, so a failure names which bot lost the race instead of just that one did. instances: r.instances ? r.instances.map(i => ({ + index: i.index, botUsername: i.botUsername ?? null, passed: i.passed, durationMs: i.durationMs, diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index c3ff851..1487deb 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -415,7 +415,8 @@ function aggregateInstances(results: TestResult[]): TestResult { error: failed?.error, skipped: !failed && allSkipped, skipReason: !failed && allSkipped ? first.skipReason : undefined, - instances: results.map(r => ({ + instances: results.map((r, i) => ({ + index: i + 1, botUsername: r.botUsername, passed: r.passed, durationMs: r.durationMs, diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 6474d2c..896a17a 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -21,6 +21,9 @@ export interface TestContext { /** One concurrent instance's own outcome, rolled up into the `instances` array of the * aggregate `TestResult` for a `concurrency > 1` test/block. */ export interface TestInstanceResult { + /** 1-based position among the N concurrent instances — matches the `[i/N]` tag in the + * console log for this same run. */ + index: number; botUsername?: string; passed: boolean; durationMs: number; From 1909b710672a9bdae940892044a66afc4d6abc28 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 20:10:11 +0300 Subject: [PATCH 090/125] fix(authme): make Microsoft login-wall skip opt-in account.auth === 'microsoft' unconditionally skipped the login/register handshake, assuming AuthMe never prompts premium accounts. That's a server-config decision, not something derivable from the account type (issue #65: a microsoft account got prompted to /register like any other). Add skipOnMicrosoftAccount (default false) to opt back into the old shortcut once you've confirmed your server doesn't gate premium accounts. --- auth-authme-package/index.ts | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index c43618e..9005945 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -38,6 +38,14 @@ export interface AuthAuthmeOptions { * values, so only use this where the password is worth nothing: a local, disposable * server. Anywhere else, put the accounts in the pool and let the password be a secret. */ password?: string; + /** Skip the login/register handshake entirely for `microsoft` (online-mode) accounts. + * Off by default: whether AuthMe still puts up its login wall for a premium account is a + * server-side setting (e.g. AuthMe's premium auto-login), not something this plugin can + * assume — see issue #65, where a `microsoft` account was prompted to `/register` like + * any other. Only set this once you've confirmed your server really does let Microsoft + * accounts straight through. Plugin options travel as strings from the Kotlin DSL, so set + * it as `options["skipOnMicrosoftAccount"] = "true"`. */ + skipOnMicrosoftAccount?: boolean; } const DEFAULTS: Required> = { @@ -49,6 +57,7 @@ const DEFAULTS: Required> = { authenticatedPattern: 'success(ful)? login|logged in|authenticat', timeoutMs: 15000, sessionResumedPattern: 'Session Reconnection', + skipOnMicrosoftAccount: false, }; // `onPlayerCreate` doesn't receive the plugin's options — only `setup()` does — so the @@ -56,6 +65,12 @@ const DEFAULTS: Required> = { // process only ever runs one session at a time (see Session's own module-level caveats). let resolved: Required> & { password?: string } = DEFAULTS; +// Kotlin's `options[k] = v` map is string-only (see PluginRefSpec), so a boolean option set +// through the Gradle DSL arrives here as the literal string "true"/"false", not a real +// boolean — a plain truthy check would treat "false" as on. Anything already boolean (options +// set from a JS/TS environment config directly) passes through unchanged. +const isEnabled = (value: boolean | string): boolean => value === true || value === 'true'; + /** * Reference authentication plugin for a server running AuthMe (or anything with the same * login/register-by-chat flow). `onPlayerCreate` fires on every bot connection — the initial @@ -72,15 +87,21 @@ export default definePlugin({ }, async onPlayerCreate(player, { account }) { - // Online-mode (Microsoft) accounts never see AuthMe's offline-mode login wall. - if (account.auth === 'microsoft') return; + // Opt-in only: whether AuthMe skips its login wall for a premium account depends on + // server config, not on the account being `microsoft` (issue #65). + if (account.auth === 'microsoft' && isEnabled(resolved.skipOnMicrosoftAccount)) return; const password = account.password ?? resolved.password; if (!password) { throw new Error( - `authme: account "${account.username}" has no password to log in with. ` + - 'Give the environment an accounts pool, or set the plugin\'s "password" option ' + - 'for a throwaway local server.' + account.auth === 'microsoft' + ? `authme: microsoft account "${account.username}" has no password to log in with. ` + + 'Microsoft accounts never carry one (mineflayer authenticates them itself), so set ' + + 'the plugin\'s "password" option, or set "skipOnMicrosoftAccount" if your server ' + + 'really doesn\'t put up a login wall for premium accounts.' + : `authme: account "${account.username}" has no password to log in with. ` + + 'Give the environment an accounts pool, or set the plugin\'s "password" option ' + + 'for a throwaway local server.' ); } From 4415fc9933b6cebdb499704dcf7d791462f06ba1 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 20:10:18 +0300 Subject: [PATCH 091/125] docs(authme): drop 'AuthMe never prompts microsoft accounts' claim README and the plugins.mdx example both stated it as fact; document skipOnMicrosoftAccount instead and fix the example hook. --- auth-authme-package/README.md | 5 ++++- docs/plugins.mdx | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/auth-authme-package/README.md b/auth-authme-package/README.md index 95ade07..dbe68e0 100644 --- a/auth-authme-package/README.md +++ b/auth-authme-package/README.md @@ -4,7 +4,7 @@ Reference [plugwright](https://github.com/Drownek/plugwright) authentication plu On every bot connection — the first bot of a test, a second bot from `createPlayer()`, every `player.rejoin()`, and the `external` mode's admin-bot console — it waits for the server's prompt and answers it. Registration is followed through to the login it triggers, because a command sent between the two is still rejected as unauthenticated. -Microsoft (online-mode) accounts are left alone; AuthMe never prompts them. +Microsoft (online-mode) accounts go through the same handshake by default — whether AuthMe still puts up a login wall for a premium account is a server-side setting, not something this plugin assumes. Set `skipOnMicrosoftAccount` if you've confirmed yours doesn't. ## Usage @@ -48,11 +48,14 @@ A reconnect within AuthMe's own session timeout gets no prompt at all — AuthMe | `sessionResumedPattern` | `Session Reconnection` | Regex confirming AuthMe resumed the session on its own, no prompt needed | | `timeoutMs` | `15000` | How long to wait for each prompt or confirmation | | `password` | — | Fallback password for accounts that carry none | +| `skipOnMicrosoftAccount` | `false` | Skip the handshake entirely for `microsoft` accounts | All patterns are matched case-insensitively, and only against messages that arrived after the step they belong to. A greeting containing the word "welcome" would otherwise pass for a login confirmation, and the test would start before the player could run a single command. `password` covers accounts an environment invents rather than leases: `LocalMode` hands every test a throwaway `Test_` with no password of its own. Plugin options travel as plain strings, so use it only where the password protects nothing — a local server that is deleted after the run. Anywhere else, put the accounts in `accounts { }`, where the password stays a secret reference until the runner reads it. +`microsoft` accounts (`accounts { microsoft { ... } }`) carry no password of their own either — mineflayer authenticates them itself — so if your server still prompts them, they need `password` too. Set `skipOnMicrosoftAccount = true` only once you've confirmed your server lets premium accounts straight through without one. + ## Preflight test A `preflight` test ships with the plugin and runs before any user spec. The handshake above already throws on the first connection if it fails, so the test mostly exists to put a named failure at the top of the report instead of a stack trace buried in someone else's test. diff --git a/docs/plugins.mdx b/docs/plugins.mdx index 5441335..f8075a0 100644 --- a/docs/plugins.mdx +++ b/docs/plugins.mdx @@ -63,7 +63,8 @@ import { definePlugin, poll } from '@plugwright/runner'; export default definePlugin({ name: 'authme', async onPlayerCreate(player, { account }) { - if (account.auth === 'microsoft') return; + // Whether AuthMe puts up a login wall for a premium account is a server-side + // setting, not something derivable from `account.auth` — don't assume it away. // wait for the prompt, answer it, wait for the confirmation }, }); From dcc5ef858347cfc7e21271c65762d277e247ca76 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 3 Sep 2026 05:25:16 +0300 Subject: [PATCH 092/125] fix(session): stop disconnectAllBots from losing bots added mid-teardown disconnectAllBots recomputed "remaining" from this.bots after its await, using a keepSet fixed at call start. A bot connected by a concurrent test while the await was in flight was neither in keepSet nor among the ones just disconnected, so the post-await reset silently dropped it from tracking without ever disconnecting it. Now it snapshots the bots to disconnect before awaiting and removes exactly those afterward, leaving anything added mid-await untouched. --- runner-package/lib/session.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index 8e1525d..31d2087 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -168,19 +168,25 @@ export class Session { * * Each bot goes through `disconnectBot`, which is also what strips its listeners: a kept * bot is still connected and still listening, so tearing the others down must not be a - * second implementation that forgets to. */ + * second implementation that forgets to. + * + * Snapshots which bots to disconnect before the `await`, then removes exactly those from + * `this.bots` afterward — not "whatever isn't in `keep`" recomputed after the fact. A + * concurrent caller can push a new bot onto `this.bots` while this call is awaiting; that + * bot is in neither snapshot, so it survives here untouched instead of being silently + * dropped from tracking without ever being disconnected. */ async disconnectAllBots(keep: Bot[] = []): Promise { const keepSet = new Set(keep); + const toDisconnect = this.bots.filter(b => !keepSet.has(b)); await Promise.all( - this.bots - .filter(b => !keepSet.has(b)) - .map((b, i) => this.disconnectBot(b, b.username ?? `bot-${i}`, 2000)) + toDisconnect.map((b, i) => this.disconnectBot(b, b.username ?? `bot-${i}`, 2000)) ); - const remaining = this.bots.filter(b => keepSet.has(b)); - this.bots.length = 0; - this.bots.push(...remaining); + const disconnectedSet = new Set(toDisconnect); + for (let i = this.bots.length - 1; i >= 0; i--) { + if (disconnectedSet.has(this.bots[i])) this.bots.splice(i, 1); + } } /** Feeds raw environment output (e.g. Minecraft server stdout/stderr) into the console log buffer. */ From 7109b6b298f8b900910937a860d5b4deceb7605c Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 3 Sep 2026 05:25:41 +0300 Subject: [PATCH 093/125] fix(test-runner): disconnect bots whose join() failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createBotScope.close() tracked "own" bots as connected.map(p => p.bot) — populated only after a successful join(). A bot whose join() failed never landed in connected, so close() treated it as belonging to another scope and left it connected, leaking the connection. Track every bot the scope creates in ownBots instead, regardless of join outcome, and tear down from that. --- runner-package/lib/test-runner.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index 1487deb..8011249 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -1,4 +1,5 @@ import pc from 'picocolors'; +import type { Bot } from 'mineflayer'; import { PlayerWrapper } from './player.js'; import { ServerWrapper } from './server.js'; import { formatDuration } from './reporter.js'; @@ -63,6 +64,10 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo const leased: Array<{ account: Account; pool: AccountPool }> = []; const named = new Map(); const connected: PlayerWrapper[] = []; + // Every bot this scope created, whether or not `player.join()` went on to succeed — unlike + // `connected`, which only gains an entry after a successful join. `close()` needs this one: + // a bot whose join failed still opened a real connection and must still be torn down. + const ownBots: Bot[] = []; const connect = async (options?: { username?: string; account?: string }): Promise => { const pool = options?.username ? null : session.env.accounts?.() ?? null; @@ -88,6 +93,7 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo profilesFolder: account.microsoftCacheDir, }; const bot = session.createBot({ ...botOptions, username: botUsername }); + ownBots.push(bot); const player = new PlayerWrapper(bot, session); player._captureSpawnPromise(); player.setServerWrapper(server); @@ -119,13 +125,17 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo players: () => [...connected], async close(): Promise { // Only this scope's own bots — a concurrent sibling instance's bots are still - // running and must not be torn down by this one finishing first. - const ownBots = new Set(connected.map(p => p.bot)); - await session.disconnectAllBots(session.bots.filter(b => !ownBots.has(b))); + // running and must not be torn down by this one finishing first. Uses `ownBots` + // (everything this scope ever created), not `connected`, so a bot whose join() + // failed and never made it into `connected` still gets torn down here instead of + // leaking an open connection forever. + const ownBotsSet = new Set(ownBots); + await session.disconnectAllBots(session.bots.filter(b => !ownBotsSet.has(b))); for (const { account, pool } of leased) pool.release(account); leased.length = 0; named.clear(); connected.length = 0; + ownBots.length = 0; }, }; } From a529bb560f3e2265666386f9e8230df2431a3cb4 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 3 Sep 2026 05:25:45 +0300 Subject: [PATCH 094/125] fix(test-registry): reject concurrency on tests inside describe.serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A serial block always runs its tests sequentially on one player, so a per-test concurrency set inside one was silently ignored by the block runner — nothing warned, the test just ran once. Now it throws at registration time, same as an invalid concurrency value does. --- runner-package/lib/test-registry.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 89a5e95..40a4819 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -106,6 +106,16 @@ function normalizeConcurrency(concurrency: number | undefined): number { function registerTest(name: string, options: TestOptions, fn: TestFn): void { const testCase = { ...scopedEntry(name, options), fn }; if (currentBlock) { + // A serial block always runs its tests one after another on the same player — fanning + // one of them out into concurrent instances makes no sense and would otherwise be + // silently ignored (the block runner never reads a per-test concurrency), leaving + // whoever set it wondering why nothing ran concurrently. + if (testCase.concurrency > 1) { + throw new Error( + `test "${name}": concurrency is not supported on tests inside describe.serial ` + + `(block "${currentBlock.name}") — set concurrency on the describe.serial block itself instead.` + ); + } currentBlock.tests.push(testCase); } else { testRegistry.push({ kind: 'test', testCase }); From 37fc1046a420f4e03acac931a928eebd465948f7 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 3 Sep 2026 05:25:50 +0300 Subject: [PATCH 095/125] fix(runner): load main/suite spec files after preflight runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateConcurrency needed every file loaded (imported) up front to check concurrency before any test ran, which meant main/suite spec files were now imported before preflight ran — importing against whatever state preflight was about to set up, rather than what it left behind. Preflight files are now loaded, validated, and run on their own first; main and suite files are loaded and validated afterward, restoring the original import order while keeping the fail-fast concurrency check. --- runner-package/runner.ts | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/runner-package/runner.ts b/runner-package/runner.ts index d8b570a..255c381 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -232,6 +232,23 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): const loadedPreflight: LoadedFile[] = []; for (const { file, pluginName } of preflightEntries) loadedPreflight.push(await loadFile(file, pluginName)); + // Preflight files are loaded and validated on their own, before any main/suite spec + // file is imported — importing those here would run their top-level code ahead of + // preflight, against whatever state preflight was going to set up during execution. + validateConcurrency(loadedPreflight); + + // Preflight: plugin auth/setup tests, run before anything else. A failure aborts the + // whole session. + for (const loaded of loadedPreflight) { + console.log(`\n${pc.blue(pc.bold(`Running preflight tests from: ${loaded.file} ${pc.dim(`(plugin ${loaded.pluginName})`)}`))}`); + const before = testResults.length; + await runLoadedFile(loaded); + const failed = testResults.slice(before).find(r => !r.skipped && !r.passed); + if (failed) { + throw new Error(`Preflight test "${failed.testName}" failed (plugin ${loaded.pluginName}): ${failed.error?.message ?? 'unknown error'}`); + } + } + let testFiles = await findSpecFiles(config.tests.dir || process.cwd()); if (testFileFilters) { const patterns = testFileFilters; @@ -252,22 +269,11 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): const loadedSuite: LoadedFile[] = []; for (const { file, pluginName } of suiteEntries) loadedSuite.push(await loadFile(file, pluginName)); - // Whole-session preflight: every file is loaded (imported once, registrations - // snapshotted) before any of them runs, so a misconfigured `concurrency` aborts here - // instead of after burning time on earlier tests. - validateConcurrency([...loadedPreflight, ...loadedMain, ...loadedSuite]); - - // Preflight: plugin auth/setup tests, run before anything else. A failure aborts the - // whole session. - for (const loaded of loadedPreflight) { - console.log(`\n${pc.blue(pc.bold(`Running preflight tests from: ${loaded.file} ${pc.dim(`(plugin ${loaded.pluginName})`)}`))}`); - const before = testResults.length; - await runLoadedFile(loaded); - const failed = testResults.slice(before).find(r => !r.skipped && !r.passed); - if (failed) { - throw new Error(`Preflight test "${failed.testName}" failed (plugin ${loaded.pluginName}): ${failed.error?.message ?? 'unknown error'}`); - } - } + // Main and suite files are loaded (imported once, registrations snapshotted) before any + // of them runs, so a misconfigured `concurrency` aborts here instead of after burning + // time on earlier tests. Preflight has already run by this point, so this no longer + // imports them ahead of the state preflight sets up. + validateConcurrency([...loadedMain, ...loadedSuite]); console.log(`${pc.bold(`Found ${loadedMain.length} test file(s)${testFileFilters ? ` matching filter: ${testFileFilters.join(',')}` : ''}`)}\n`); @@ -282,7 +288,6 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): await runLoadedFile(loaded); } - } finally { await plugins.runCleanup(session, 'session'); await plugins.teardown(); From f7b7cd859b4ad812669c78ac00de0ff4b97ed199 Mon Sep 17 00:00:00 2001 From: Monikon Date: Thu, 3 Sep 2026 05:34:50 +0300 Subject: [PATCH 096/125] fix(test-runner): recognize a rejoined bot as the scope's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix tracked "own" bots in ownBots, snapshotted once per connect() call. player.rejoin() swaps a player's .bot for a freshly created connection under the same username but never touches ownBots, so close() no longer recognized the new bot as its own and left it connected — a permanent "already playing" kick for every later test that leased the same account. close() now also reads connected.map(p => p.bot) fresh, which reflects whatever rejoin() last swapped in. --- runner-package/lib/test-runner.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index 8011249..cb083fb 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -125,11 +125,12 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo players: () => [...connected], async close(): Promise { // Only this scope's own bots — a concurrent sibling instance's bots are still - // running and must not be torn down by this one finishing first. Uses `ownBots` - // (everything this scope ever created), not `connected`, so a bot whose join() - // failed and never made it into `connected` still gets torn down here instead of - // leaking an open connection forever. - const ownBotsSet = new Set(ownBots); + // running and must not be torn down by this one finishing first. Union of two + // sources: `ownBots` catches a bot whose join() failed and never made it into + // `connected`; reading `p.bot` fresh off `connected` catches the opposite case — + // `player.rejoin()` swaps a player's `.bot` for a new connection under the same + // scope, and that swapped-in bot isn't the one `ownBots` captured at connect time. + const ownBotsSet = new Set([...ownBots, ...connected.map(p => p.bot)]); await session.disconnectAllBots(session.bots.filter(b => !ownBotsSet.has(b))); for (const { account, pool } of leased) pool.release(account); leased.length = 0; From 9e423efad3f257e2de7c086e75206664ae0e98f0 Mon Sep 17 00:00:00 2001 From: Drownek Date: Thu, 3 Sep 2026 15:44:40 +0200 Subject: [PATCH 097/125] fix(authme): wait for authentication confirmation on session resume --- auth-authme-package/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index c43618e..5a78e4f 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -121,7 +121,14 @@ export default definePlugin({ message: `authme: never saw a login/register prompt or session-resume message for "${account.username}"`, }, ); - if (promptResult === 'resumed') return; + if (promptResult === 'resumed') { + const authenticated = new RegExp(resolved.authenticatedPattern, 'i'); + await poll(() => since(joinIndex, authenticated), { + timeout: resolved.timeoutMs, + message: `authme: "${account.username}" session resumed, but never confirmed as authenticated`, + }); + return; + } const isRegistration = promptResult === 'register'; // Everything below only looks at messages newer than the command. A server's greeting From ce619d53c0094658c56f510578e741aa9a28b3a3 Mon Sep 17 00:00:00 2001 From: Drownek Date: Thu, 3 Sep 2026 16:28:18 +0200 Subject: [PATCH 098/125] test(example): format concurrency test and remove /say --- .../src/test/e2e/tests/concurrency.spec.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/example_plugin/src/test/e2e/tests/concurrency.spec.ts b/example_plugin/src/test/e2e/tests/concurrency.spec.ts index df45bca..12776d0 100644 --- a/example_plugin/src/test/e2e/tests/concurrency.spec.ts +++ b/example_plugin/src/test/e2e/tests/concurrency.spec.ts @@ -12,16 +12,20 @@ import { describe, expect, test } from '@plugwright/runner'; -test('concurrent bots each see their own marker and stay connected', { concurrency: 3, requires: ['consoleOutput:full'] }, async ({ player, server }) => { - const marker = `concurrency-marker-${player.username}`; - player.chat(`/say ${marker}`); - await expect(server).toHaveReceivedMessage(marker, { timeout: 10000 }); +test( + 'concurrent bots each see their own marker and stay connected', + { concurrency: 3, requires: ['consoleOutput:full'] }, + async ({ player, server }) => { + const marker = `concurrency-marker-${player.username}`; + player.chat(marker); + await expect(server).toHaveReceivedMessage(marker, { timeout: 10000 }); - // Still connected: an earlier-finishing sibling instance's teardown must not have - // disconnected this one. - await player.teleport(50, 100, 50); - await expect(player).toBeNear(50, 100, 50, { tolerance: 2, timeout: 10000 }); -}); + // Still connected: an earlier-finishing sibling instance's teardown must not have + // disconnected this one. + await player.teleport(50, 100, 50); + await expect(player).toBeNear(50, 100, 50, { tolerance: 2, timeout: 10000 }); + } +); describe.serial('concurrent kit lifecycle', { concurrency: 2 }, () => { test('claims the starter kit', async ({ player }) => { From c93ed74c1391cf1e2903040fb94bd503f6e0bda5 Mon Sep 17 00:00:00 2001 From: Drownek <46686155+Drownek@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:42:23 +0200 Subject: [PATCH 099/125] docs: document HolyWorld integration in README --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 867ae28..2c532f0 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,18 @@ builds cannot reach the public ones. The URL and its credentials come from the e rather than from any file in the repository — see [`.env.example`](.env.example) and the [publishing guide](https://plugwright.dev/publishing). +## Used in Production + + + HolyWorld Logo + + +HolyWorld
+~10,000 peak online players. Plugwright powers their CI/CD pipeline for end-to-end plugin testing.
+Integrated by @monikon22 + +
+ ## Documentation & Examples For full examples on how to test **GUIs**, **multi-bot interactions**, **NMS**, and the complete **API Reference**, visit our official documentation site: From e774c5daf7e66926dffa5b35cc4d438c3b8f2921 Mon Sep 17 00:00:00 2001 From: Drownek <46686155+Drownek@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:48:29 +0200 Subject: [PATCH 100/125] docs: remove publishing instructions from README --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index 2c532f0..a2b189e 100644 --- a/README.md +++ b/README.md @@ -183,18 +183,6 @@ jobs: - uses: drownek/plugwright-action@v1 ``` -## Publishing - -Releasing Plugwright itself is two commands — `npm run publish:packages` for the npm -packages and `./gradlew publishToPublicRepository` for the gradle plugin. Both go to their -public homes, npmjs.com and the Gradle Plugin Portal, and tagging a commit `v*` runs them -for you. - -The same two commands publish to a registry of your own instead, for an organisation whose -builds cannot reach the public ones. The URL and its credentials come from the environment -rather than from any file in the repository — see [`.env.example`](.env.example) and the -[publishing guide](https://plugwright.dev/publishing). - ## Used in Production From 08dc6e90c0c2667b62e5593fcde3ca34d245c536 Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 4 Sep 2026 10:20:32 +0200 Subject: [PATCH 101/125] docs: clarify server log assertions and isolation under concurrency --- docs/matchers.mdx | 16 +++++++++++++--- docs/writing-tests.mdx | 14 ++++++++++++++ runner-package/lib/matchers.ts | 14 ++++++++++++++ runner-package/lib/server.ts | 3 +++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/matchers.mdx b/docs/matchers.mdx index 5c9034e..2f610f6 100644 --- a/docs/matchers.mdx +++ b/docs/matchers.mdx @@ -7,10 +7,10 @@ description: "Complete reference for all available assertion matchers." ### `toHaveReceivedMessage(message, options?)` -Waits for the bot to receive a message containing (or exactly matching) the text or RegExp. +Waits for the bot or server console to receive a message containing (or exactly matching) the text or RegExp. ```javascript -// Partial match (default) +// Partial match on player chat (default) await expect(player).toHaveReceivedMessage('Welcome'); // RegEx match @@ -28,10 +28,20 @@ await expect(player).toHaveReceivedMessage('Success', { since: marker }); await expect(player).not.toHaveReceivedMessage('Error'); ``` + +**Concurrency & Isolation Best Practices:** +- **`expect(player)` vs `expect(server)`**: Each bot maintains its own private `messageBuffer`. Messages sent to one player never bleed into another bot's assertions, making `expect(player)` 100% safe in concurrent suites (`concurrency: N`). +- **Asserting on Server Logs**: The server log (`expect(server)`) is a single shared stream for the entire server. In concurrent tests, always include `${player.username}` in your pattern to avoid matching lines produced by other bots: + ```javascript + await expect(server).toHaveReceivedMessage(new RegExp(`Granted VIP to ${player.username}`)); + ``` +- **Global Server State**: If a test asserts on global server output with no player identifier (e.g., `[Plugin] Reload complete`), simply run it as a standard test without the `concurrency` option. + + **Parameters:** - `message` (string | RegExp) - Text or pattern to search for - `options.strict` (boolean) - Require exact match (default: false) -- `options.since` (number) - Buffer index to search from +- `options.since` (number) - Buffer index to search from (e.g. `server.startIndex` or from `player.getMessageBufferIndex()`) - `options.timeout` (number) - Max wait time in ms diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index 53225d8..dc510dc 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -247,6 +247,20 @@ describe.serial('kit lifecycle', { concurrency: 5 }, () => { `concurrency` multiplies how many bots run a test, not how many rows it produces in the summary. That row's duration is the slowest instance, and it carries an `instances` array with every instance's own outcome — bot username, pass or fail, duration — so a failure tells you which bot lost, not just that somebody did. + +**Console and Server Log Assertions under Concurrency:** +- **Player chat is isolated**: `expect(player).toHaveReceivedMessage(...)` checks that specific bot's private message buffer. It never collides between concurrent instances. +- **Server console is shared**: `expect(server).toHaveReceivedMessage(...)` inspects the server's single shared log. When multiple bots run concurrently, distinguish log entries by incorporating the bot's unique username: + ```typescript + test('claims bounty', { concurrency: 3 }, async ({ player, server }) => { + player.chat('/bounty claim'); + // Qualify server log assertions with the player's unique name: + await expect(server).toHaveReceivedMessage(new RegExp(`Bounty awarded to ${player.username}`)); + }); + ``` +- **Global commands**: Tests asserting on unparameterized global server events (like `/reload` or global broadcasts without player names) should run as standard single tests without the `concurrency` option. + + ### How many you can ask for `concurrency: N` needs N free accounts. On an environment with an account pool, this is checked before any test in the run starts: ask for `concurrency: 10` against a 4-account pool and it fails immediately with a clear error, instead of the 5th bot hanging on a lease nobody's going to release. `LocalMode` has no pool — it mints a throwaway account per bot — so there's nothing to check there; its only real ceiling is the server's own `max-players`. diff --git a/runner-package/lib/matchers.ts b/runner-package/lib/matchers.ts index dd5c522..a18e058 100644 --- a/runner-package/lib/matchers.ts +++ b/runner-package/lib/matchers.ts @@ -61,6 +61,20 @@ export class RunnerMatchers extends Matchers { throw new Error(this.isNot ? passMessage() : failMessage()); } + /** + * Waits for the player or server console to receive a message matching the expected text or pattern. + * + * ### Concurrency & Isolation Guidance: + * - **`expect(player)`**: Checks `player.messageBuffer`, which is strictly isolated per bot. + * Always prefer `expect(player)` when asserting on chat, notifications, or feedback addressed to a player. + * It is completely safe from race conditions in concurrent tests (`concurrency: N`). + * - **`expect(server)`**: Reads `session.consoleLog`, which is a single shared stream for the entire server. + * In concurrent test execution (`concurrency: N`), lines from other bots appear in this log simultaneously. + * When asserting on server logs under concurrency, always qualify patterns with `${player.username}` + * (e.g. `new RegExp(`Gave 100 to ${player.username}`)`) or narrow the search window with `options.since`. + * For global assertions without a player identifier (e.g. `[Plugin] Reload complete`), run the test without + * the `concurrency` option as a standard, single-runner test. + */ async toHaveReceivedMessage( this: RunnerMatchers, expectedMessage: string | RegExp, diff --git a/runner-package/lib/server.ts b/runner-package/lib/server.ts index bfa32f6..65bfd53 100644 --- a/runner-package/lib/server.ts +++ b/runner-package/lib/server.ts @@ -19,6 +19,9 @@ export class ServerWrapper { this.startIndex = this.session.consoleLog.length; } + /** Executes a console command synchronously. Note: when running under `concurrency: N`, + * console output is shared across all concurrent tests. Prefer player actions or qualify + * commands with `player.username`. */ execute(cmd: string): void { if (!this.session.console) { throw new Error('No server console available for this environment'); From 60f31ca15ee647d787ae706b41284eede9fab909 Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 4 Sep 2026 17:45:16 +0200 Subject: [PATCH 102/125] fix(runner): return command output instead of sync marker in executeAndWait --- runner-package/lib/environments/local.ts | 11 ++++++++--- runner-package/lib/session.ts | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/runner-package/lib/environments/local.ts b/runner-package/lib/environments/local.ts index 42baf98..6b05dd5 100644 --- a/runner-package/lib/environments/local.ts +++ b/runner-package/lib/environments/local.ts @@ -38,7 +38,9 @@ class StdioConsole implements ServerConsole { } /** 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. */ + * and poll the console log for it, the same trick `PlayerWrapper.executeAndSync` uses. + * Returns all lines produced between command submission and the sync marker. + */ async executeAndWait(cmd: string, timeoutMs: number = 5000): Promise { const syncId = `sync_${randomUUID().split('-')[0]}`; const since = this.session.consoleLog.length; @@ -47,8 +49,11 @@ class StdioConsole implements ServerConsole { 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; + const recent = this.session.consoleLog.slice(since); + const syncIdx = recent.findIndex(l => l.includes(syncId)); + if (syncIdx !== -1) { + return recent.slice(0, syncIdx).join('\n'); + } await new Promise(resolve => setTimeout(resolve, 50)); } throw new Error(`Console command sync timed out for: ${cmd}`); diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index 31d2087..2f9f11a 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -25,8 +25,8 @@ export class MessageBuffer { this.lines.length = 0; } - slice(start?: number): string[] { - return start !== undefined ? this.lines.slice(start) : [...this.lines]; + slice(start?: number, end?: number): string[] { + return this.lines.slice(start, end); } find(predicate: (line: string) => boolean): string | undefined { From 5547b1fd6533d536cb48ce743420402b6748e584 Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 4 Sep 2026 17:45:31 +0200 Subject: [PATCH 103/125] feat(runner): add player.clearInventory method with state polling --- docs/api-reference.mdx | 12 +++++++++++- runner-package/lib/player.ts | 38 +++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/docs/api-reference.mdx b/docs/api-reference.mdx index 5144c4f..5fca846 100644 --- a/docs/api-reference.mdx +++ b/docs/api-reference.mdx @@ -79,6 +79,16 @@ Gives items to the player. await player.giveItem('diamond', 64); ``` +### `player.clearInventory(item?, options?)` + +Clears the player's inventory and waits until the client-side inventory state reflects it. If an item name is specified, only that item is cleared. + +```javascript +await player.clearInventory(); +// or clear specific item +await player.clearInventory('diamond'); +``` + ### Properties @@ -174,7 +184,7 @@ Repeatedly executes a function until it returns a value that is not `undefined`, ```javascript -await poll(() => player.inventory.hasItem('diamond')); +const diamond = await poll(() => player.bot.inventory.items().find(i => i.name === 'diamond')); ``` ### `waitForAssertion(fn, options?)` diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index fc2e1e8..cb9e8ef 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -5,7 +5,7 @@ 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 { poll, waitUntil } from './utils.js'; import { randomUUID } from 'node:crypto'; import pc from 'picocolors'; @@ -409,6 +409,42 @@ export class PlayerWrapper { ); } + /** + * Clears the player's inventory using `minecraft:clear` and waits until the + * bot's client-side inventory reflects the empty state. + * + * If `item` is provided, clears only items matching that name. + */ + async clearInventory( + itemOrOptions?: string | { timeout?: number }, + options: { timeout?: number } = {} + ): Promise { + this.requireServer(); + const item = typeof itemOrOptions === 'string' ? itemOrOptions : undefined; + const opts = typeof itemOrOptions === 'object' ? itemOrOptions : options; + const timeout = opts.timeout ?? 5000; + + if (item) { + this.serverWrapper!.execute(`minecraft:clear ${this.username} ${item}`); + await waitUntil( + () => !this.bot.inventory.items().some(i => i.name.includes(item)), + { + message: `Inventory item "${item}" for ${this.username} was not cleared`, + timeout, + } + ); + } else { + this.serverWrapper!.execute(`minecraft:clear ${this.username}`); + await waitUntil( + () => this.bot.inventory.items().length === 0, + { + message: `Inventory for ${this.username} was not cleared`, + timeout, + } + ); + } + } + private requireServer(): void { if (!this.serverWrapper) { throw new Error('ServerWrapper not set on PlayerWrapper'); From 9b5bc5b75a9993ac4a85caa38be8585099308503 Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 4 Sep 2026 17:45:40 +0200 Subject: [PATCH 104/125] refactor(plugins): adopt player.clearInventory and poll console confirmations --- docs/plugins.mdx | 11 +++++------ .../src/test/e2e/plugins/stand-reset.ts | 16 ++++++++++++---- .../resources/plugwright-init/example-plugin.ts | 4 ++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/plugins.mdx b/docs/plugins.mdx index f8075a0..68a3401 100644 --- a/docs/plugins.mdx +++ b/docs/plugins.mdx @@ -142,16 +142,15 @@ A plugin is an npm package (or a single compiled file) whose default export impl ```ts import { definePlugin } from '@plugwright/runner'; -export default definePlugin<{ resetCommand?: string }>({ +export default definePlugin({ name: 'staging-reset', - setup({ options }) { - resetCommand = options.resetCommand ?? '/pw reset'; - }, - async beforeEach({ player, server }) { if (!server.session.env.capabilities.console) return; - await server.executeAndWait(`minecraft:clear ${player.username}`); + + // Reset permissions and clear inventory before letting the test start + await player.deOp(); + await player.clearInventory(); }, }); ``` diff --git a/example_plugin/src/test/e2e/plugins/stand-reset.ts b/example_plugin/src/test/e2e/plugins/stand-reset.ts index ef87768..7194edd 100644 --- a/example_plugin/src/test/e2e/plugins/stand-reset.ts +++ b/example_plugin/src/test/e2e/plugins/stand-reset.ts @@ -1,4 +1,4 @@ -import { definePlugin } from '@plugwright/runner'; +import { definePlugin, waitUntil } from '@plugwright/runner'; /** What a fresh account starts with, per ExamplePlugin's own default. */ const STARTING_BALANCE = 1000; @@ -26,8 +26,16 @@ export default definePlugin({ if (!server.session.env.capabilities.console) return; await player.deOp(); - await server.executeAndWait(`minecraft:clear ${player.username}`); - await server.executeAndWait(`eco set ${player.username} ${STARTING_BALANCE}`); - await server.executeAndWait(`kit reset ${player.username}`); + await player.clearInventory(); + + await waitUntil(async () => { + const res = await server.executeAndWait(`eco set ${player.username} ${STARTING_BALANCE}`); + return res.includes(`Set balance of ${player.username} to $${STARTING_BALANCE}`); + }, { message: `Console did not confirm balance reset for ${player.username}` }); + + await waitUntil(async () => { + const res = await server.executeAndWait(`kit reset ${player.username}`); + return res.includes(`Kit cooldown reset for ${player.username}`); + }, { message: `Console did not confirm kit cooldown reset for ${player.username}` }); }, }); diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts index 6de2790..2aeeb2c 100644 --- a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts @@ -14,9 +14,9 @@ export default definePlugin({ // Runs before every test, with the bot already connected. async beforeEach({ player, server }) { - // An environment without a console has no way to run commands, and says so. if (!server.session.env.capabilities.console) return; - await server.executeAndWait(`minecraft:gamemode survival ${player.username}`); + + await player.clearInventory(); }, // What this returns becomes part of the object every test destructures: From d2213cefdab39b068c94fc761e47a9d049a20d46 Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 4 Sep 2026 19:00:57 +0200 Subject: [PATCH 105/125] fix(runner): fix TCP chunk splitting by keeping a remainder across chunks --- runner-package/lib/session.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index 2f9f11a..d64e5ad 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -189,18 +189,23 @@ export class Session { } } + private _remainder = ''; + /** 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 text = this._remainder + data.toString().replace(/\r\n/g, '\n'); const lines = text.split('\n'); + this._remainder = lines.pop() || ''; 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); + .map(line => line.length > 0 ? `${pc.gray('[MC]')} ${line}\n` : '\n') + .join(''); + if (prefixed.length > 0) { + process.stdout.write(prefixed); + } } } From 8dfbafe472f248c463eb310f9542543bacb1a35b Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 4 Sep 2026 19:00:58 +0200 Subject: [PATCH 106/125] fix(runner): fix test pattern matching bug against absolute paths --- runner-package/runner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 255c381..a542627 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -256,7 +256,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): testFiles = testFiles.filter(file => patterns.some(pattern => { const fileName = basename(file).replace(/\.spec\.js$/, ''); - const matches = fileName.includes(pattern) || file.includes(pattern); + const matches = fileName.includes(pattern); console.log(pc.dim(` Testing ${file} (basename: ${fileName}) against pattern "${pattern}": ${matches}`)); return matches; }) From 5647e3c2c57887c7bd32a3f4069e79bd433bc1bf Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 4 Sep 2026 19:00:58 +0200 Subject: [PATCH 107/125] fix(rcon): add buffer underflow guard when decoding packets --- console-rcon-package/lib/protocol.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/console-rcon-package/lib/protocol.ts b/console-rcon-package/lib/protocol.ts index 85de2de..61d497a 100644 --- a/console-rcon-package/lib/protocol.ts +++ b/console-rcon-package/lib/protocol.ts @@ -33,6 +33,7 @@ export function encodePacket(id: number, type: number, payload: string): Buffer /** Decodes one packet body — everything after the 4-byte length prefix a caller already * stripped off while reassembling the stream. */ export function decodePacketBody(body: Buffer): DecodedPacket { + if (body.length < 10) throw new Error('Packet body too short'); const id = body.readInt32LE(0); const type = body.readInt32LE(4); const payload = body.toString('utf8', 8, body.length - 2); From 2935edcf3992e70e1a7b1fa718d230db2697a79c Mon Sep 17 00:00:00 2001 From: Drownek Date: Fri, 4 Sep 2026 19:00:59 +0200 Subject: [PATCH 108/125] fix(rcon): implement standard RCON sentinel strategy for fragmented responses --- console-rcon-package/lib/rcon-connection.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/console-rcon-package/lib/rcon-connection.ts b/console-rcon-package/lib/rcon-connection.ts index 6e7b515..6fd8bc1 100644 --- a/console-rcon-package/lib/rcon-connection.ts +++ b/console-rcon-package/lib/rcon-connection.ts @@ -87,7 +87,6 @@ export class RconConnection { const waiter = this.pending.get(packet.id); if (waiter) { - this.pending.delete(packet.id); waiter.resolve(packet.payload); } } @@ -98,18 +97,28 @@ export class RconConnection { if (!socket) throw new Error('RCON connection is not open'); const id = this.nextId++; + const sentinelId = this.nextId++; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); + this.pending.delete(sentinelId); reject(new Error(`RCON command timed out after ${timeoutMs}ms: ${cmd}`)); }, timeoutMs); + let accumulated = ''; + this.pending.set(id, { - resolve: (payload) => { clearTimeout(timer); resolve(payload); }, - reject: (err) => { clearTimeout(timer); reject(err); }, + resolve: (payload) => { accumulated += payload; }, + reject: (err) => { clearTimeout(timer); this.pending.delete(id); this.pending.delete(sentinelId); reject(err); }, + }); + + this.pending.set(sentinelId, { + resolve: () => { clearTimeout(timer); this.pending.delete(id); this.pending.delete(sentinelId); resolve(accumulated); }, + reject: (err) => { clearTimeout(timer); this.pending.delete(id); this.pending.delete(sentinelId); reject(err); }, }); socket.write(encodePacket(id, PacketType.EXECCOMMAND, cmd)); + socket.write(encodePacket(sentinelId, PacketType.EXECCOMMAND, '')); }); } From 15ea8764f3e3095a36dc395d02220826c46a7518 Mon Sep 17 00:00:00 2001 From: Drownek Date: Sat, 5 Sep 2026 20:11:06 +0200 Subject: [PATCH 109/125] fix(runner): ensure proper TCP chunk boundary handling across packets --- runner-package/lib/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index d64e5ad..9872c12 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -193,7 +193,7 @@ export class Session { /** Feeds raw environment output (e.g. Minecraft server stdout/stderr) into the console log buffer. */ writeConsoleOutput(data: Buffer): void { - const text = this._remainder + data.toString().replace(/\r\n/g, '\n'); + const text = (this._remainder + data.toString()).replace(/\r\n/g, '\n'); const lines = text.split('\n'); this._remainder = lines.pop() || ''; for (const line of lines) { From a14a03252d2970226e67744a34654a6085d70014 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 6 Sep 2026 15:38:07 +0300 Subject: [PATCH 110/125] fix(gradle-plugin): make publish aggregate private and public repos 'publish' from maven-publish only reached the private repo; the portal side lived under plugin-publish's publishPlugins and never joined it. Wire both publishToPrivateRepository and publishToPublicRepository onto publish, so it is the one command a release runs, still respecting each side's enabled switch. Update release.yml to call ./gradlew publish instead of hardcoding publishToPublicRepository. --- .github/workflows/release.yml | 2 +- gradle-plugin/plugwright-bundle/build.gradle.kts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c293b9..49d937e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,5 +52,5 @@ jobs: GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} run: | chmod +x gradlew - ./gradlew publishToPublicRepository + ./gradlew publish \ No newline at end of file diff --git a/gradle-plugin/plugwright-bundle/build.gradle.kts b/gradle-plugin/plugwright-bundle/build.gradle.kts index b5f514e..1db1047 100644 --- a/gradle-plugin/plugwright-bundle/build.gradle.kts +++ b/gradle-plugin/plugwright-bundle/build.gradle.kts @@ -168,3 +168,12 @@ tasks.register("publishToPrivateRepository") { } } } + +// `maven-publish` already registers `publish` as the lifecycle task for this module, but on +// its own it only reaches the private repository above; the portal side lives under +// `plugin-publish`'s `publishPlugins` and never joins it on its own. Wiring both wrapper +// tasks onto `publish` makes it the one command a release runs, still skipping either side +// exactly as `publishToPrivateRepository`/`publishToPublicRepository` do on their own. +tasks.named("publish") { + dependsOn(tasks.named("publishToPrivateRepository"), tasks.named("publishToPublicRepository")) +} From bea8b6099a4fdc292f03fbe41d2617c234396448 Mon Sep 17 00:00:00 2001 From: Nikita Monikonov Date: Sun, 6 Sep 2026 20:36:08 +0300 Subject: [PATCH 111/125] [v3] fix: MS profile caching, RCON queue & runner exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(runner): cache Microsoft profile/certificates per account minecraft-protocol's built-in 'microsoft' auth refetches profile and certificates on every connect, no caching of their own. Every test gets its own bot connection, so a microsoft-auth account redoes that on every single test; enough tests in one run and one of those calls eventually hits a rate limit and fails a perfectly fine account. Add a custom auth function (microsoft-auth.ts) that mirrors minecraft-protocol's own microsoftAuth.authenticate but caches fetchProfile/fetchCertificates results per username for the life of the process. The access token itself is untouched — still fetched per connect via prismarine-auth's own disk cache, which is already cheap. Needs prismarine-auth as a direct dependency now (was only transitive through mineflayer) to call Authflow.getMinecraftJavaToken directly. * fix(runner): route microsoft accounts through cached auth Session.createBot passed auth straight through as a string. Swap it for the cached custom auth function (microsoft-auth.ts) whenever the account is microsoft-auth; offline/mojang accounts are unaffected. Fixes #69 * fix(runner): fix CJS named import of prismarine-auth under ESM 'import { Authflow, Titles } from prismarine-auth' compiled fine but crashed at runtime: 'SyntaxError: The requested module prismarine-auth does not provide an export named Titles'. prismarine-auth is CJS; Node's cjs-module-lexer interop for named ESM imports of a CJS module isn't reliable here — it missed Titles even though both are plain properties of module.exports. Import the default (the whole module.exports object) instead and destructure from that — the default import always works for CJS interop. Verified locally: typecheck, build, then imported the compiled dist/lib/microsoft-auth.js directly with node. Caught only in CI, not by tsc, since this is a runtime ESM/CJS interop quirk typecheck can't see; this package has no local e2e harness to catch it earlier. * chore: retrigger CI (check stand-suite RCON flake) * fix(rcon): queue RCON commands to prevent server disconnects from concurrent packets * fix(rcon): remove sentinel strategy to prevent server disconnects * fix(runner): set process.exitCode in runTestSession so test failures fail the build --------- Co-authored-by: Drownek --- console-rcon-package/lib/rcon-connection.ts | 55 ++++++------ runner-package/lib/microsoft-auth.ts | 94 +++++++++++++++++++++ runner-package/lib/session.ts | 5 +- runner-package/package-lock.json | 1 + runner-package/package.json | 1 + runner-package/runner.ts | 1 + 6 files changed, 130 insertions(+), 27 deletions(-) create mode 100644 runner-package/lib/microsoft-auth.ts diff --git a/console-rcon-package/lib/rcon-connection.ts b/console-rcon-package/lib/rcon-connection.ts index 6fd8bc1..86f6d6b 100644 --- a/console-rcon-package/lib/rcon-connection.ts +++ b/console-rcon-package/lib/rcon-connection.ts @@ -34,6 +34,7 @@ export class RconConnection { this.socket = socket; socket.once('connect', () => { + socket.setNoDelay(true); this.pendingAuth = { resolve: () => resolve(), reject: (err) => reject(err), @@ -91,34 +92,36 @@ export class RconConnection { } } - async executeAndWait(cmd: string, timeoutMs: number): Promise { - await this.ensureConnected(); - const socket = this.socket; - if (!socket) throw new Error('RCON connection is not open'); + private _commandQueue = Promise.resolve(); - const id = this.nextId++; - const sentinelId = this.nextId++; + async executeAndWait(cmd: string, timeoutMs: number): Promise { return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - this.pending.delete(sentinelId); - reject(new Error(`RCON command timed out after ${timeoutMs}ms: ${cmd}`)); - }, timeoutMs); - - let accumulated = ''; - - this.pending.set(id, { - resolve: (payload) => { accumulated += payload; }, - reject: (err) => { clearTimeout(timer); this.pending.delete(id); this.pending.delete(sentinelId); reject(err); }, - }); - - this.pending.set(sentinelId, { - resolve: () => { clearTimeout(timer); this.pending.delete(id); this.pending.delete(sentinelId); resolve(accumulated); }, - reject: (err) => { clearTimeout(timer); this.pending.delete(id); this.pending.delete(sentinelId); reject(err); }, - }); - - socket.write(encodePacket(id, PacketType.EXECCOMMAND, cmd)); - socket.write(encodePacket(sentinelId, PacketType.EXECCOMMAND, '')); + this._commandQueue = this._commandQueue.then(async () => { + try { + await this.ensureConnected(); + const socket = this.socket; + if (!socket) throw new Error('RCON connection is not open'); + + const id = this.nextId++; + + const result = await new Promise((innerResolve, innerReject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + innerReject(new Error(`RCON command timed out after ${timeoutMs}ms: ${cmd}`)); + }, timeoutMs); + + this.pending.set(id, { + resolve: (payload) => { clearTimeout(timer); this.pending.delete(id); innerResolve(payload); }, + reject: (err) => { clearTimeout(timer); this.pending.delete(id); innerReject(err); }, + }); + + socket.write(encodePacket(id, PacketType.EXECCOMMAND, cmd)); + }); + resolve(result); + } catch (err) { + reject(err); + } + }).catch(() => {}); }); } diff --git a/runner-package/lib/microsoft-auth.ts b/runner-package/lib/microsoft-auth.ts new file mode 100644 index 0000000..67e25ee --- /dev/null +++ b/runner-package/lib/microsoft-auth.ts @@ -0,0 +1,94 @@ +import path from 'node:path'; +import os from 'node:os'; +import type { Authflow as AuthflowInstance } from 'prismarine-auth'; +import prismarineAuth from 'prismarine-auth'; +// prismarine-auth is CJS; named imports aren't reliable under Node's ESM interop +// (cjs-module-lexer missed `Titles` here at runtime — "does not provide an export named +// 'Titles'" — even though both are plain properties of module.exports). Destructure the +// default import instead. +const { Authflow, Titles } = prismarineAuth; + +/** Same default `minecraft-protocol` itself falls back to (via the `minecraft-folder-path` + * package) when `profilesFolder` isn't set — kept in sync here since our custom `auth` function + * replaces its whole dispatch, defaults included. */ +function defaultMinecraftFolder(): string { + switch (os.type()) { + case 'Darwin': + return path.join(os.homedir(), 'Library', 'Application Support', 'minecraft'); + case 'Windows_NT': + return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), '.minecraft'); + default: + return path.join(os.homedir(), '.minecraft'); + } +} + +/** What we keep from a Microsoft account's login response across connections: everything + * `minecraft-protocol`'s own `microsoftAuth.authenticate` fetches but never caches itself. */ +interface CachedProfile { + profile: Record; + certificates: Record | undefined; +} + +/** + * In-memory cache of `fetchProfile`/`fetchCertificates` results, keyed by Microsoft account + * username, kept for the life of this process. + * + * Every test gets its own bot connection (`Session.createBot` → `mineflayer.createBot`), so a + * `microsoft`-auth account redoes the full Microsoft handshake on every single test. The MS/Xbox + * *token* is already disk-cached by `prismarine-auth` (`Authflow.getMinecraftJavaToken`'s own + * `verifyTokens()` check) and stays cheap, but `fetchProfile`/`fetchCertificates` run + * unconditionally on every connect with no caching of their own — enough tests in one run and one + * of those calls eventually hits a rate limit, failing a test whose account is perfectly fine. + * See issue #69. + */ +const cache = new Map(); + +/** + * Builds a `minecraft-protocol` custom `auth` function for one Microsoft account, backed by + * [cache]. Mirrors `minecraft-protocol`'s own `microsoftAuth.authenticate` (same defaults, same + * session/error shape) but only fetches profile/certificates once per `username` per process — + * every connection still gets a fresh access token, since that part is cheap already. + */ +export function microsoftAuthWithCache(username: string) { + return async (client: any, options: any): Promise => { + if (!options.profilesFolder) options.profilesFolder = path.join(defaultMinecraftFolder(), 'nmp-cache'); + if (options.authTitle === undefined) { + options.authTitle = Titles.MinecraftNintendoSwitch; + options.deviceType = 'Nintendo'; + options.flow = 'live'; + } + + const authflow: AuthflowInstance = client.authflow ?? new Authflow(options.username, options.profilesFolder, options, options.onMsaCode); + client.authflow = authflow; + + const cached = cache.get(username); + const { token, profile, certificates } = await authflow.getMinecraftJavaToken({ + fetchProfile: !cached, + fetchCertificates: !cached && !options.disableChatSigning, + }).catch((err: Error) => { + if (options.password) console.warn('Sign in failed, try removing the password field\n'); + if (err.toString().includes('Not Found')) console.warn(`Please verify that the account ${options.username} owns Minecraft\n`); + throw err; + }); + + let entry = cached; + if (!entry) { + if (!profile || (profile as any).error) throw new Error(`Failed to obtain profile data for ${options.username}, does the account own minecraft?`); + entry = { profile, certificates }; + cache.set(username, entry); + } + + options.haveCredentials = token !== null; + const session = { + accessToken: token, + selectedProfile: entry.profile, + availableProfile: [entry.profile], + }; + Object.assign(client, entry.certificates); + client.session = session; + client.username = entry.profile.name; + options.accessToken = token; + client.emit('session', session); + options.connect(client); + }; +} diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index 9872c12..a539435 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -5,6 +5,7 @@ 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'; +import { microsoftAuthWithCache } from './microsoft-auth.js'; /** * Append-only line buffer. Replaces the old module-level `string[]` singletons @@ -77,7 +78,9 @@ export class Session { port: options.port, username: options.username, version: options.version, - auth: options.auth, + // A custom function here (instead of the 'microsoft' string) so profile/certificate + // fetches are cached across bots for the same account — see microsoft-auth.ts. + auth: options.auth === 'microsoft' ? microsoftAuthWithCache(options.username) : 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 diff --git a/runner-package/package-lock.json b/runner-package/package-lock.json index 2c41c6b..6997c95 100644 --- a/runner-package/package-lock.json +++ b/runner-package/package-lock.json @@ -12,6 +12,7 @@ "js-yaml": "^4.1.0", "mineflayer": "^4.0.0", "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" }, "bin": { diff --git a/runner-package/package.json b/runner-package/package.json index 46a2728..451c756 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -40,6 +40,7 @@ "js-yaml": "^4.1.0", "mineflayer": "^4.0.0", "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" }, "devDependencies": { diff --git a/runner-package/runner.ts b/runner-package/runner.ts index a542627..5d911fd 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -305,6 +305,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): exitCode = printTestSummary(testResults); + process.exitCode = exitCode; setTimeout(() => { process.exit(exitCode); }, 1000).unref(); From 16116f28e1df782284caf0eb490ac58637bcd2c2 Mon Sep 17 00:00:00 2001 From: Drownek <46686155+Drownek@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:53:47 +0200 Subject: [PATCH 112/125] refactor: streamline v3 architecture, unify RCON execution, and remove legacy abstractions (#73) * refactor(core): streamline v3 architecture (RCON runner, remove journal, admin-bot & abilities) * fix(gradle-plugin): remove dead AdminBot config & pass RCON config dynamically * fix(rcon): handle authentication failure cleanly & export RconConnection * fix(runner): properly close RCON socket on environment teardown * test(e2e): await floating server.execute promises * docs: remove outdated references to abilities, journal, admin bot and cleanup task * chore: update lockfiles * docs: fix remaining outdated references (await server.execute, AdminBot) * refactor(core): merge console-rcon package into runner * fix: remove remaining references to console-rcon * refactor(gradle-plugin): scope useExternalPluginsOnly to LocalMode Removes deprecated global extension.useExternalPluginsOnly check from PlugwrightCorePlugin and resolves pluginJar dynamically in LocalMode based on the environment spec, preserving backward compatibility while enabling per-environment configuration in v3. * refactor: migrate requires from string array to typed object map * refactor: remove redundant freshState, lifecycle, and arbitraryUsernames capabilities --- auth-authme-package/README.md | 2 +- auth-authme-package/index.ts | 7 +- auth-authme-package/package-lock.json | 1 + console-rcon-package/README.md | 44 ---- console-rcon-package/package-lock.json | 206 ------------------ console-rcon-package/package.json | 49 ----- console-rcon-package/tsconfig.json | 25 --- docs/api-reference.mdx | 2 +- docs/custom-modes.mdx | 6 +- docs/environments.mdx | 3 +- docs/external-servers.mdx | 54 +---- docs/plugins.mdx | 2 +- docs/publishing.mdx | 11 +- docs/test-filtering.mdx | 14 +- docs/writing-tests.mdx | 4 +- example_plugin/src/test/e2e/package-lock.json | 23 +- example_plugin/src/test/e2e/package.json | 3 +- .../src/test/e2e/plugins/stand-reset.ts | 18 +- .../src/test/e2e/tests/concurrency.spec.ts | 2 +- .../src/test/e2e/tests/economy.spec.ts | 2 +- .../src/test/e2e/tests/minigame.spec.ts | 2 +- .../src/test/e2e/tests/simple-ts.spec.ts | 4 +- .../drownek/plugwright/api/PlugwrightMode.kt | 2 +- .../me/drownek/plugwright/AbstractNodeTask.kt | 3 +- .../plugwright/PlugwrightCorePlugin.kt | 16 +- .../plugwright/PlugwrightMatrixTask.kt | 2 - .../drownek/plugwright/PlugwrightTestTask.kt | 6 - .../me/drownek/plugwright/RunnerLauncher.kt | 7 +- .../plugwright/external/AccountsSpec.kt | 3 +- .../plugwright/external/ConsoleSpec.kt | 17 +- .../external/ExternalEnvironmentSpec.kt | 2 +- .../plugwright/external/ExternalMode.kt | 27 +-- .../external/PlugwrightCleanupTask.kt | 68 ------ .../plugwright/local/LocalEnvironmentSpec.kt | 6 + .../me/drownek/plugwright/local/LocalMode.kt | 14 +- .../plugwright/local/PaperProvisionTask.kt | 27 ++- package-lock.json | 6 + package.json | 4 +- runner-package/README.md | 2 +- runner-package/cli.ts | 6 +- runner-package/lib/admin-bot-console.ts | 82 ------- runner-package/lib/config.ts | 5 +- runner-package/lib/console.ts | 9 +- runner-package/lib/environment.ts | 4 - runner-package/lib/environments/external.ts | 52 +---- runner-package/lib/environments/local.ts | 107 ++++----- runner-package/lib/journal.ts | 65 ------ runner-package/lib/matchers.ts | 8 +- runner-package/lib/player.ts | 95 +------- runner-package/lib/plugin-host.ts | 4 +- runner-package/lib/plugin.ts | 3 - .../lib/rcon/connection.ts | 18 +- .../lib/rcon}/index.ts | 20 +- .../lib/rcon}/protocol.ts | 0 runner-package/lib/server.ts | 15 +- runner-package/lib/session.ts | 5 +- runner-package/lib/skip-reason.ts | 42 ++-- runner-package/lib/test-registry.ts | 20 +- runner-package/lib/test-runner.ts | 2 + runner-package/lib/types.ts | 5 + runner-package/runner.ts | 59 +---- scripts/bump-version.js | 1 - scripts/publish.js | 1 - 63 files changed, 274 insertions(+), 1050 deletions(-) delete mode 100644 console-rcon-package/README.md delete mode 100644 console-rcon-package/package-lock.json delete mode 100644 console-rcon-package/package.json delete mode 100644 console-rcon-package/tsconfig.json delete mode 100644 gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt create mode 100644 package-lock.json delete mode 100644 runner-package/lib/admin-bot-console.ts delete mode 100644 runner-package/lib/journal.ts rename console-rcon-package/lib/rcon-connection.ts => runner-package/lib/rcon/connection.ts (91%) rename {console-rcon-package => runner-package/lib/rcon}/index.ts (55%) rename {console-rcon-package/lib => runner-package/lib/rcon}/protocol.ts (100%) diff --git a/auth-authme-package/README.md b/auth-authme-package/README.md index dbe68e0..90bde85 100644 --- a/auth-authme-package/README.md +++ b/auth-authme-package/README.md @@ -2,7 +2,7 @@ Reference [plugwright](https://github.com/Drownek/plugwright) authentication plugin for a server running AuthMe, or anything else that asks for a password in chat. -On every bot connection — the first bot of a test, a second bot from `createPlayer()`, every `player.rejoin()`, and the `external` mode's admin-bot console — it waits for the server's prompt and answers it. Registration is followed through to the login it triggers, because a command sent between the two is still rejected as unauthenticated. +On every bot connection — the first bot of a test, a second bot from `createPlayer()`, and every `player.rejoin()` — it waits for the server's prompt and answers it. Registration is followed through to the login it triggers, because a command sent between the two is still rejected as unauthenticated. Microsoft (online-mode) accounts go through the same handshake by default — whether AuthMe still puts up a login wall for a premium account is a server-side setting, not something this plugin assumes. Set `skipOnMicrosoftAccount` if you've confirmed yours doesn't. diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index bf4b146..cdde38f 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -73,9 +73,10 @@ const isEnabled = (value: boolean | string): boolean => value === true || value /** * Reference authentication plugin for a server running AuthMe (or anything with the same - * login/register-by-chat flow). `onPlayerCreate` fires on every bot connection — the initial - * join and every `player.rejoin()` — and on the `external` mode's admin-bot console too, since - * that connects through the exact same `PlayerWrapper.join()` path a test bot does. + * login/register-by-chat flow). The credentials go straight into the runner's plugin configuration, and the runner manages + * the authentication flow. It happens exactly once per bot — on the very first + * join and every `player.rejoin()` — before test code ever gets a chance to see + * the player. */ export default definePlugin({ name: 'authme', diff --git a/auth-authme-package/package-lock.json b/auth-authme-package/package-lock.json index 157d1cf..6cb58aa 100644 --- a/auth-authme-package/package-lock.json +++ b/auth-authme-package/package-lock.json @@ -30,6 +30,7 @@ "js-yaml": "^4.1.0", "mineflayer": "^4.0.0", "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" }, "bin": { diff --git a/console-rcon-package/README.md b/console-rcon-package/README.md deleted file mode 100644 index f3e6d77..0000000 --- a/console-rcon-package/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# @plugwright/console-rcon - -RCON server console for [plugwright](https://github.com/Drownek/plugwright)'s `external` mode. - -A local server gives plugwright a console for free: it owns the process, so it reads stdout and writes stdin. A server someone else started gives it nothing. RCON is how tests reach that server's console instead. - -The Source RCON protocol is implemented directly over Node's `net` module, so this package has no dependencies of its own. Every command comes back with the server's answer, which means `executeAndWait` needs none of the client-side sync tricks a fire-and-forget channel does. - -## Usage - -Declared through the `external` environment's DSL rather than imported: - -```kotlin -environments { - create("staging", ExternalMode) { - console { - rcon { - port.set(25575) - password.set(secret.env("RCON_PASSWORD")) - } - } - } -} -``` - -The server has to be listening. In `server.properties`: - -```properties -enable-rcon=true -rcon.port=25575 -rcon.password=… -``` - -`plugwrightCompileTests` installs this package once a build script declares an `rcon` block. If it is missing from `node_modules` anyway, the runner says which package to install and where, rather than printing a stack trace. - -## What tests can do with it - -Commands and their answers, which covers `server.execute(...)`, `server.executeAndWait(...)`, `player.makeOp()` and everything built on them. - -What it cannot do is show a test the rest of the server log. RCON reports `output: 'responses'`, so `expect(server).toHaveReceivedMessage(...)` fails fast with an explanation instead of timing out. Mark those tests `requires: ['consoleOutput:full']` and they skip on an RCON-only environment. - -## License - -MIT diff --git a/console-rcon-package/package-lock.json b/console-rcon-package/package-lock.json deleted file mode 100644 index db49a3f..0000000 --- a/console-rcon-package/package-lock.json +++ /dev/null @@ -1,206 +0,0 @@ -{ - "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.1", - "license": "MIT", - "devDependencies": { - "@plugwright/runner": "file:../runner-package", - "@types/node": "^22.10.5", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@plugwright/runner": ">=3.0.0-dev.0" - } - }, - "../runner-package": { - "name": "@plugwright/runner", - "version": "3.0.0-dev.1", - "dev": true, - "license": "MIT", - "dependencies": { - "js-yaml": "^4.1.0", - "mineflayer": "^4.0.0", - "picocolors": "^1.1.1", - "source-map-support": "^0.5.21" - }, - "bin": { - "plugwright": "dist/cli.js" - }, - "devDependencies": { - "@types/js-yaml": "^4.0.9", - "@types/node": "^22.10.5", - "@types/source-map-support": "^0.5.10", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@plugwright/runner": { - "resolved": "../runner-package", - "link": true - }, - "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/console-rcon-package/package.json b/console-rcon-package/package.json deleted file mode 100644 index fa5c159..0000000 --- a/console-rcon-package/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.1", - "description": "RCON server console for plugwright's \"external\" mode", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "rimraf dist && tsc", - "prepublishOnly": "npm run build", - "watch": "tsc --watch", - "typecheck": "tsc --noEmit" - }, - "files": [ - "dist" - ], - "keywords": [ - "minecraft", - "rcon", - "plugwright", - "testing" - ], - "author": "drownek", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/Drownek/plugwright.git", - "directory": "console-rcon-package" - }, - "homepage": "https://github.com/Drownek/plugwright#readme", - "bugs": { - "url": "https://github.com/Drownek/plugwright/issues" - }, - "peerDependencies": { - "@plugwright/runner": ">=3.0.0-dev.0" - }, - "devDependencies": { - "@plugwright/runner": "file:../runner-package", - "@types/node": "^22.10.5", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - }, - "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/console-rcon-package/tsconfig.json b/console-rcon-package/tsconfig.json deleted file mode 100644 index f2df9c3..0000000 --- a/console-rcon-package/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "moduleResolution": "node", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "resolveJsonModule": true - }, - "include": [ - "**/*.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] -} diff --git a/docs/api-reference.mdx b/docs/api-reference.mdx index 5fca846..aef80dd 100644 --- a/docs/api-reference.mdx +++ b/docs/api-reference.mdx @@ -114,7 +114,7 @@ Executes a command from the server console. ```javascript -server.execute(`give ${player.username} diamond 64`); +await server.execute(`give ${player.username} diamond 64`); ``` ## Exported Utilities diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx index 783c9c4..471b9d0 100644 --- a/docs/custom-modes.mdx +++ b/docs/custom-modes.mdx @@ -148,10 +148,6 @@ class VelocityEnvironment implements Environment { console: true, consoleOutput: 'responses', op: true, - freshState: false, - arbitraryUsernames: true, - lifecycle: true, - cleanupStrategy: 'compensating', }; async setup(session: Session): Promise { /* connect, probe, warm up */ } @@ -163,7 +159,7 @@ class VelocityEnvironment implements Environment { } ``` -Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. +Capabilities are a promise the runner holds you to. Tests declaring `{ requires: { op: true } }` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. `accounts()` and `beforeJoin()` are optional. Returning no pool means every bot gets a throwaway `pw_` username, which is what `local` does. A pool is also what makes `describe.serial('...', { account: 'pw_0001' })` possible: without one, a block asking for a named account fails rather than running as somebody else. diff --git a/docs/environments.mdx b/docs/environments.mdx index 78f5ccf..d6cc8c0 100644 --- a/docs/environments.mdx +++ b/docs/environments.mdx @@ -51,13 +51,12 @@ plugwrightProvisionLocal download Paper, patch configs, copy the plugin jar plugwrightCleanLocal wipe the run directory plugwrightRunServerLocal start the server interactively, no tests plugwrightPingStaging check that an external stand answers, no tests -plugwrightCleanStaging compensating cleanup on an external stand plugwrightTestLocal run the suite against one environment plugwrightTestStaging plugwrightTest the matrix: every environment with includeInMatrix ``` -Which tasks exist depends on the mode. `LocalMode` contributes provisioning, cleaning and a server-run task; `ExternalMode` contributes ping and cleanup, and nothing that touches files. +Which tasks exist depends on the mode. `LocalMode` contributes provisioning, cleaning and a server-run task; `ExternalMode` contributes ping, and nothing that touches files. Tasks for the `primaryEnvironment` also get an unsuffixed alias, so `plugwrightRunServer` still means what it used to. `plugwrightTest` is the exception: it belongs to the matrix. diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index 80a0445..0ea82b9 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -21,7 +21,6 @@ environments { console { rcon { port.set(25575); password.set(secret.env("RCON_PASSWORD")) } - adminBot("StaffBot") { password.set(secret.env("STAFF_PASSWORD")) } } accounts { @@ -55,23 +54,20 @@ Channels are probed in declaration order, and the first one that answers becomes | Channel | Output level | Notes | |---|---|---| -| `rcon { }` | `responses` | Needs `enable-rcon=true` on the server. Installs `@plugwright/console-rcon` | -| `adminBot("Name") { }` | `responses` | A second bot with staff rights that sends commands through chat | +| `rcon { }` | `responses` | Needs `enable-rcon=true` on the server | | stdio | `full` | `LocalMode` only — Plugwright owns the process | The output level matters more than it looks. `full` means the whole server log is readable, so `expect(server).toHaveReceivedMessage(...)` works. `responses` means you get back what the command printed and nothing else. A test that reads the server log should say so: ```ts -test('command is logged', { requires: ['consoleOutput:full'] }, async ({ server }) => { - server.execute('say hello'); +test('command is logged', { requires: { consoleOutput: 'full' } }, async ({ server }) => { + await server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); ``` Declaring no channel at all is valid. The environment runs without a console, and every test that requires one is skipped and reported as skipped. -The admin bot connects through the same code path as a test bot, which means it goes through your authentication plugin too, and it connects before any test bot does. - ## Accounts A local server accepts any username; a stand usually does not. `accounts { }` builds a pool that tests lease from and return to, merged from three sources: @@ -93,7 +89,7 @@ That is a request for a specific identity, not for whatever is free, so the pool Most second bots don't need this. A test that just wants another player should call `createPlayer()` with no arguments and let the pool answer — a name is worth asking for when the identity is, because somebody provisioned that account with a permission group or a balance, or because the name came from somewhere outside the test. -A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, exclude what you can't, and treat `capabilities.freshState = false` as the honest description it is. +A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, and exclude what you can't. ## Numbered slots or fresh names @@ -124,46 +120,6 @@ That's for a scenario tied to state somebody provisioned on that account — a p Connects, probes the console channels, leases one account and authenticates with it, then disconnects. No tests run. When something is wrong with the stand — RCON password rotated, login plugin changed its messages, account pool exhausted — this fails in seconds with a specific message instead of failing test after test five minutes into a run. -## Cleaning up - -`plugwrightClean` means something different per mode. For `LocalMode` it wipes the run directory. For `ExternalMode` there is nothing to wipe: it starts the runner in cleanup mode, which connects, loads the plugins and calls their `cleanup({ scope: 'manual' })` handlers. No files are touched. - -```kotlin -// in a runner plugin -definePlugin({ - name: 'staging', - async cleanup({ session, scope }) { - // scope: 'session' after a run, 'manual' from plugwrightCleanStaging - await session.console?.executeAndWait('/pw purge-test-data'); - }, -}); -``` - -### The journal - -Finalizers registered with `TestContext.cleanup()` run in a `finally`. A `SIGKILL` skips `finally` blocks, and on a real server the leftovers accumulate — a hundred junk warps a month later. - -For obligations that must survive that, record a typed entry in `build/plugwright/-journal.jsonl`: - -```ts -test('creating a warp', async ({ player, server, cleanup }) => { - const warpName = `pw_${crypto.randomUUID().slice(0, 8)}`; - const id = warpName; - - player.chat(`/setwarp ${warpName}`); - server.session.journal.record(id, { kind: 'warp', name: warpName }); - - cleanup(() => { - server.execute(`/delwarp ${warpName}`); - server.session.journal.forget(id); - }); - - await expect(player).toHaveReceivedMessage('Warp created'); -}); -``` - -Entries are typed records interpreted by a plugin's `cleanup` handler, never raw command strings. A file that replays raw commands against a live server is a way to run arbitrary commands on it. Whatever is still in the journal when the next run starts is what a crash left behind; `plugwrightClean` prints anything a cleanup pass could not resolve. -## What the runner reports as skipped -After `setup()`, the environment reports what it actually supports. For `ExternalMode` that is: no fresh state, no server lifecycle, compensating cleanup, arbitrary usernames, and console plus op only if a console channel answered. Tests that declare `requires` are skipped against that list, with the reason in the report. See [Test Filtering](/test-filtering). +After `setup()`, the environment reports what it actually supports. For `ExternalMode` that is: console plus op only if a console channel answered. Tests that declare `requires` are skipped against that list, with the reason in the report. See [Test Filtering](/test-filtering). diff --git a/docs/plugins.mdx b/docs/plugins.mdx index 68a3401..7aa9a77 100644 --- a/docs/plugins.mdx +++ b/docs/plugins.mdx @@ -70,7 +70,7 @@ export default definePlugin({ }); ``` -`onPlayerCreate` fires on every connection: the bot a test starts with, a second bot from `createPlayer()`, every `player.rejoin()`, and the admin-bot console channel. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook. +`onPlayerCreate` fires on every connection: the bot a test starts with, a second bot from `createPlayer()`, and every `player.rejoin()`. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook. ## Hooks and `describe.serial` diff --git a/docs/publishing.mdx b/docs/publishing.mdx index fae3405..3845ae8 100644 --- a/docs/publishing.mdx +++ b/docs/publishing.mdx @@ -7,13 +7,12 @@ This page is for people releasing Plugwright itself, or running a fork of it ins organisation. If you are writing tests for your own plugin you want [Quickstart](/quickstart) instead. -Plugwright ships as four artifacts that move as one version: +Plugwright ships as three artifacts that move as one version: | Artifact | Kind | Public home | | --- | --- | --- | | `@plugwright/runner` | npm | npmjs.com | | `@plugwright/auth-authme` | npm | npmjs.com | -| `@plugwright/console-rcon` | npm | npmjs.com | | `io.github.drownek.plugwright` | gradle plugin | Gradle Plugin Portal | Both destinations — the public one and a private one — use the same two commands. What @@ -32,12 +31,12 @@ cd gradle-plugin ./gradlew publishToPublicRepository ``` -`publish:packages` with nothing configured publishes all three npm packages to npmjs.com. It +`publish:packages` with nothing configured publishes both npm packages to npmjs.com. It uses whatever credentials npm already has, so an `npm login` session or an `NPM_TOKEN` is enough. In CI it is an `NPM_TOKEN` secret rather than the job's OIDC token: a trusted publisher is configured per package, and the `@plugwright` names have never been published, so there is nothing to authenticate against until the first release has gone out. Provenance -is signed from the OIDC token either way, so `--provenance` works with both. Once all three +is signed from the OIDC token either way, so `--provenance` works with both. Once both packages exist, `npm trust github --file release.yml` replaces the secret. `publishToPublicRepository` is the Gradle Plugin Portal, and reads `GRADLE_PUBLISH_KEY` and @@ -140,8 +139,8 @@ repository for the plugin. That is covered in ## Moving the version -All four artifacts carry one version, kept in `version.txt`. `npm run bump` moves it -everywhere at once — the three `package.json` files and the lockfiles that record the +All three artifacts carry one version, kept in `version.txt`. `npm run bump` moves it +everywhere at once — the two `package.json` files and the lockfiles that record the runner's version, plus the README, the quickstart and the example plugin for a stable release — then tags the commit. diff --git a/docs/test-filtering.mdx b/docs/test-filtering.mdx index e897b05..9c6cce6 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -41,7 +41,7 @@ Two filters, meant for different problems. **By capability** — for a test that needs something the environment might not have. This travels with the test and doesn't care what the environments are called: ```ts -test('give command hands over the item', { requires: ['console', 'op'] }, async ({ player }) => { +test('give command hands over the item', { requires: { console: true, op: true } }, async ({ player }) => { await player.giveItem('diamond', 1); }); ``` @@ -53,21 +53,17 @@ Capability keys come from the environment's own report, after it has connected: | `console` | boolean | Commands can be run at all | | `consoleOutput` | `full` / `responses` / `none` | Whole server log, only command answers, or nothing | | `op` | boolean | The environment can grant operator status | -| `freshState` | boolean | Each test gets a clean world and a clean player | -| `arbitraryUsernames` | boolean | Bots may pick their own names | -| `lifecycle` | boolean | The server can be restarted or stopped | -| `cleanupStrategy` | `wipe` / `compensating` / `none` | How cleanup happens after the run | -A bare key is satisfied by anything other than `false`, `'none'` or an absent value. To demand one specific value, use `key:value`: +A boolean `true` capability is satisfied by anything other than `false`, `'none'` or an absent value. To demand one specific value, map it in the object: ```ts -test('command is logged', { requires: ['consoleOutput:full'] }, async ({ server }) => { - server.execute('say hello'); +test('console matters', { requires: { consoleOutput: 'full' } }, async ({ server }) => { + await server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); ``` -That form exists because `requires: ['console']` is satisfied by an RCON console that answers its own commands, while reading the server log needs a console that streams all of it. Without the distinction you get tests that neither skip nor work. +That form exists because `{ requires: { console: true } }` is satisfied by an RCON console that answers its own commands, while reading the server log needs a console that streams all of it. Without the distinction you get tests that neither skip nor work. **By environment name** — for when the difference isn't a capability but what's installed on that particular server: diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index dc510dc..8c6166a 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -52,7 +52,7 @@ test('player starts with default balance', async ({ player }) => { }); test('player can purchase items', async ({ player, server }) => { - server.execute(`eco give ${player.username} 500`); + await server.execute(`eco give ${player.username} 500`); player.chat('/buy diamond'); await expect(player).toHaveReceivedMessage('Purchased'); await expect(player).toContainItem('diamond'); @@ -73,7 +73,7 @@ test('player inventory has starter items', async ({ player }) => { ```typescript test('server executes commands', async ({ player, server }) => { - server.execute(`give ${player.username} diamond 64`); + await server.execute(`give ${player.username} diamond 64`); await expect(player).toContainItem('diamond'); }); ``` diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index 93e29f4..1e5dd95 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -6,7 +6,6 @@ "": { "dependencies": { "@plugwright/auth-authme": "file:../../../../auth-authme-package", - "@plugwright/console-rcon": "file:../../../../console-rcon-package", "@plugwright/runner": "file:../../../../runner-package" }, "devDependencies": { @@ -32,23 +31,6 @@ "@plugwright/runner": ">=3.0.0-dev.0" } }, - "../../../../console-rcon-package": { - "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.1", - "license": "MIT", - "devDependencies": { - "@plugwright/runner": "file:../runner-package", - "@types/node": "^22.10.5", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@plugwright/runner": ">=3.0.0-dev.0" - } - }, "../../../../runner-package": { "name": "@plugwright/runner", "version": "3.0.0-dev.1", @@ -57,6 +39,7 @@ "js-yaml": "^4.1.0", "mineflayer": "^4.0.0", "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" }, "bin": { @@ -77,10 +60,6 @@ "resolved": "../../../../auth-authme-package", "link": true }, - "node_modules/@plugwright/console-rcon": { - "resolved": "../../../../console-rcon-package", - "link": true - }, "node_modules/@plugwright/runner": { "resolved": "../../../../runner-package", "link": true diff --git a/example_plugin/src/test/e2e/package.json b/example_plugin/src/test/e2e/package.json index 6cfe34f..c921806 100644 --- a/example_plugin/src/test/e2e/package.json +++ b/example_plugin/src/test/e2e/package.json @@ -5,8 +5,7 @@ }, "dependencies": { "@plugwright/runner": "file:../../../../runner-package", - "@plugwright/auth-authme": "file:../../../../auth-authme-package", - "@plugwright/console-rcon": "file:../../../../console-rcon-package" + "@plugwright/auth-authme": "file:../../../../auth-authme-package" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/example_plugin/src/test/e2e/plugins/stand-reset.ts b/example_plugin/src/test/e2e/plugins/stand-reset.ts index 7194edd..7bb8103 100644 --- a/example_plugin/src/test/e2e/plugins/stand-reset.ts +++ b/example_plugin/src/test/e2e/plugins/stand-reset.ts @@ -1,4 +1,4 @@ -import { definePlugin, waitUntil } from '@plugwright/runner'; +import { definePlugin, expect } from '@plugwright/runner'; /** What a fresh account starts with, per ExamplePlugin's own default. */ const STARTING_BALANCE = 1000; @@ -21,21 +21,13 @@ export default definePlugin({ name: 'stand-reset', async beforeEach({ player, server }) { - // Nothing to reset with: an environment without a console cannot run commands at all, - // and the tests that depend on this reset are excluded there anyway. - if (!server.session.env.capabilities.console) return; - await player.deOp(); await player.clearInventory(); - await waitUntil(async () => { - const res = await server.executeAndWait(`eco set ${player.username} ${STARTING_BALANCE}`); - return res.includes(`Set balance of ${player.username} to $${STARTING_BALANCE}`); - }, { message: `Console did not confirm balance reset for ${player.username}` }); + const ecoOutput = await server.execute(`eco set ${player.username} ${STARTING_BALANCE}`); + expect(ecoOutput).toContain(`Set balance of ${player.username} to $${STARTING_BALANCE}`); - await waitUntil(async () => { - const res = await server.executeAndWait(`kit reset ${player.username}`); - return res.includes(`Kit cooldown reset for ${player.username}`); - }, { message: `Console did not confirm kit cooldown reset for ${player.username}` }); + const kitOutput = await server.execute(`kit reset ${player.username}`); + expect(kitOutput).toContain(`Kit cooldown reset for ${player.username}`); }, }); diff --git a/example_plugin/src/test/e2e/tests/concurrency.spec.ts b/example_plugin/src/test/e2e/tests/concurrency.spec.ts index 12776d0..3ec86a2 100644 --- a/example_plugin/src/test/e2e/tests/concurrency.spec.ts +++ b/example_plugin/src/test/e2e/tests/concurrency.spec.ts @@ -14,7 +14,7 @@ import { describe, expect, test } from '@plugwright/runner'; test( 'concurrent bots each see their own marker and stay connected', - { concurrency: 3, requires: ['consoleOutput:full'] }, + { concurrency: 3, requires: { consoleOutput: 'full' } }, async ({ player, server }) => { const marker = `concurrency-marker-${player.username}`; player.chat(marker); diff --git a/example_plugin/src/test/e2e/tests/economy.spec.ts b/example_plugin/src/test/e2e/tests/economy.spec.ts index c86a76a..67ac390 100644 --- a/example_plugin/src/test/e2e/tests/economy.spec.ts +++ b/example_plugin/src/test/e2e/tests/economy.spec.ts @@ -6,7 +6,7 @@ test('player starts with default balance', async ({ player }) => { }); test('player can send money', async ({ player, server }) => { - server.execute(`eco give ${player.username} 500`); + await server.execute(`eco give ${player.username} 500`); player.chat('/pay pw_dummy 100'); await expect(player).toHaveReceivedMessage('Sent $100'); diff --git a/example_plugin/src/test/e2e/tests/minigame.spec.ts b/example_plugin/src/test/e2e/tests/minigame.spec.ts index 35dd034..b9846ed 100644 --- a/example_plugin/src/test/e2e/tests/minigame.spec.ts +++ b/example_plugin/src/test/e2e/tests/minigame.spec.ts @@ -11,7 +11,7 @@ test('join arena game', async ({ player }) => { test('cannot join full arena', async ({ player, server }) => { // Fill arena with fake players for (let i = 0; i < 10; i++) { - server.execute(`arena addplayer Player${i}`); + await server.execute(`arena addplayer Player${i}`); } player.chat('/arena join'); diff --git a/example_plugin/src/test/e2e/tests/simple-ts.spec.ts b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts index dbcd92c..068a8a3 100644 --- a/example_plugin/src/test/e2e/tests/simple-ts.spec.ts +++ b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts @@ -27,7 +27,7 @@ test('help displays message', async ({ player }) => { // Reading the server log needs a console that streams all of it. An environment whose // console only answers its own commands skips this test instead of failing it. -test('server logs command execution', { requires: ['consoleOutput:full'] }, async ({ server }) => { - server.execute('say hello'); +test('server logs command execution', { requires: { consoleOutput: 'full' } }, async ({ server }) => { + await server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); \ No newline at end of file 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 1e21937..9bfc18c 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 @@ -50,6 +50,6 @@ interface PlugwrightMode { */ fun serialize(spec: S, node: ConfigNodeBuilder) - /** Registers the tasks for this environment: provisioning, cleanup, mode-specific extras. */ + /** Registers the tasks for this environment: provisioning, mode-specific extras. */ fun registerTasks(spec: S, ctx: TaskRegistrationContext) {} } 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 index c9f09e8..fc923c1 100644 --- 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 @@ -119,9 +119,8 @@ abstract class AbstractNodeTask : DefaultTask() { } stderrThread.isDaemon = true - var stdinThread: Thread? = null if (interactive) { - stdinThread = Thread { + val stdinThread = Thread { try { val reader = System.`in`.bufferedReader(Charsets.UTF_8) val out = process.outputStream 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 0232ee2..8de5861 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 @@ -117,7 +117,7 @@ class PlugwrightCorePlugin : Plugin { private fun wireEnvironments( project: Project, extension: PlugwrightExtension, - plugwrightCompileTests: org.gradle.api.tasks.TaskProvider, + plugwrightCompileTests: TaskProvider, defaultNodeInstallDir: File ) { // No environments { } block: fold the deprecated flat properties into one implicit @@ -136,7 +136,7 @@ class PlugwrightCorePlugin : Plugin { } val layout = PlugwrightLayout.of(extension.testsDir.get().asFile) - val projectPluginJarProvider = resolveProjectPluginJar(project, extension) + val projectPluginJarProvider = resolveProjectPluginJar(project) val validationProblems = mutableListOf() validationProblems += extension.npm.toConfig().problems().map { "[npm] $it" } val reportsDir = project.layout.buildDirectory.dir("reports/plugwright") @@ -178,7 +178,6 @@ class PlugwrightCorePlugin : Plugin { project, envName, envName == primaryName, projectPluginJarProvider, extension.testsDir.map { it.asFile }, layout, extension, defaultNodeInstallDir ) - val journalFilePath = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl").get().asFile val modePackages = mode.runnerPackages(entry.spec) // The package a mode names an export in is the one holding its environment factory. @@ -202,7 +201,6 @@ 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) @@ -227,7 +225,7 @@ class PlugwrightCorePlugin : Plugin { val environmentConfigProvider = ctx.environmentConfigProvider ?: project.provider { ConfigNodeBuilder().apply { mode.serialize(entry.spec, this) }.build() } - val pluginConfigsProvider = (ctx.pluginConfigsProvider ?: project.provider { emptyList() }) + val pluginConfigsProvider = (ctx.pluginConfigsProvider ?: project.provider { emptyList() }) .map { refs -> refs.map { resolveWorkspacePlugin(it, layout) } } // A plugin declared by npm name is installed alongside the environment's own @@ -257,7 +255,6 @@ class PlugwrightCorePlugin : Plugin { excludeTests = entry.spec.excludeTests.get(), environmentConfig = environmentConfigProvider, pluginConfigs = pluginConfigsProvider, - journalFile = journalFilePath, runtimePackage = runtimeRef?.name, runtimeExport = runtimeRef?.export, ) @@ -315,11 +312,8 @@ class PlugwrightCorePlugin : Plugin { } /** 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) - } + * no jar-producing task exists. */ + private fun resolveProjectPluginJar(project: Project): Provider { val jarTask = when { project.tasks.findByName("shadowJar") != null -> project.tasks.named("shadowJar") project.tasks.findByName("reobfJar") != null -> project.tasks.named("reobfJar") 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 ad3e9fa..690f7d8 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 @@ -27,7 +27,6 @@ internal data class MatrixEnvironmentInput( val excludeTests: List, val environmentConfig: Provider, val pluginConfigs: Provider>, - val journalFile: File?, val runtimePackage: String? = null, val runtimeExport: String? = null, ) @@ -125,7 +124,6 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { jsonReportFile = env.jsonReportFile, junitReportFile = env.junitReportFile, pluginConfigs = env.pluginConfigs.get(), - journalFile = env.journalFile, runtimePackage = env.runtimePackage, runtimeExport = env.runtimeExport, ) 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 0578217..bbea1f9 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 @@ -7,7 +7,6 @@ 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 /** * Runs the compiled test suite against one environment. @@ -61,10 +60,6 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { @get:Internal abstract val pluginConfigs: ListProperty - /** Crash-recovery journal for this environment's run. */ - @get:Internal - abstract val journalFile: RegularFileProperty - /** npm package exporting this environment's factory. Unset for a built-in mode, which the * runner already carries. */ @get:Input @@ -127,7 +122,6 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { jsonReportFile = jsonReportFile.get().asFile, junitReportFile = junitReportFile.get().asFile, pluginConfigs = pluginConfigs.get(), - journalFile = journalFile.orNull?.asFile, runtimePackage = runtimePackage.orNull, runtimeExport = runtimeExport.orNull, ) 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 f10d09c..344d4aa 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 @@ -10,13 +10,13 @@ import java.io.File /** * 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 + * for itself (ping). 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`. - * [jsonReportFile]/[junitReportFile] are omitted for service runs (`--ping`, `--cleanup`) + * [jsonReportFile]/[junitReportFile] are omitted for service runs (`--ping`) * that never produce a report. */ data class Entry( val environmentName: String, @@ -36,8 +36,6 @@ object RunnerLauncher { val runtimePackage: String? = null, /** Named export holding the factory; null means the package's default export. */ val runtimeExport: String? = null, - /** Crash-recovery journal path for `Session.journal`; null disables on-disk persistence. */ - val journalFile: File? = null, ) fun writeConfig(entry: Entry) { @@ -86,7 +84,6 @@ object RunnerLauncher { } } } - entry.journalFile?.let { put("journal", it.absolutePath) } ?: putNull("journal") }.build() RunnerConfigWriter.write(entry.configFile, root) 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 index edf5a39..72e9f20 100644 --- 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 @@ -23,8 +23,7 @@ class PoolSpec(private val objects: ObjectFactory) { * accounts on demand, up to [max] connected 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. + * Must start with `pw_` — generated accounts have to be recognizable as test accounts. * * The placeholder decides what happens to a name once the test holding it finishes. * `%d` (optionally zero-padded, `%04d`) numbers a fixed set of accounts the run keeps coming 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 index 6033300..dc05c48 100644 --- 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 @@ -8,24 +8,17 @@ import org.gradle.api.provider.Property * 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. */ + /** `console { rcon { port.set(25575); password.set(secret.env("RCON_PASS")) } }`. */ 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") { ... } }`. + * `console { rcon { ... } }`. * - * Declaring neither channel is valid — the environment just runs without a console, and any + * Declaring no 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) { @@ -34,8 +27,4 @@ class ConsoleSpec(private val objects: ObjectFactory) { 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 index 8c1b451..15a0e70 100644 --- 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 @@ -36,7 +36,7 @@ class ExternalEnvironmentSpec(private val environmentName: String, private val o internal val accountsSpec: AccountsSpec = AccountsSpec(objects) internal val pluginsSpec: PluginsSpec = PluginsSpec() - /** `console { rcon { ... }; adminBot("Name") { ... } }`. */ + /** `console { rcon { ... } }`. */ fun console(action: ConsoleSpec.() -> Unit) { consoleSpec = ConsoleSpec(objects).apply(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 index 97b1094..c5cf9a0 100644 --- 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 @@ -19,13 +19,9 @@ object ExternalMode : PlugwrightMode { override fun createSpec(name: String, objects: ObjectFactory): ExternalEnvironmentSpec = ExternalEnvironmentSpec(name, objects) - override fun runnerPackages(spec: ExternalEnvironmentSpec): List = buildList { - add(RunnerPackageRef("@plugwright/runner", export = "externalEnvironment")) - val needsRcon = spec.consoleSpec?.channels?.any { it is ConsoleChannelSpec.Rcon } == true - if (needsRcon) { - add(RunnerPackageRef("@plugwright/console-rcon", export = "rconConsole")) - } - } + override fun runnerPackages(spec: ExternalEnvironmentSpec): List = listOf( + RunnerPackageRef("@plugwright/runner", export = "externalEnvironment") + ) override fun validate(spec: ExternalEnvironmentSpec, ctx: ValidationContext) { if (!spec.host.isPresent || spec.host.get().isBlank()) { @@ -49,8 +45,6 @@ object ExternalMode : PlugwrightMode { 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") } } } @@ -70,11 +64,6 @@ object ExternalMode : PlugwrightMode { 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()) - } } } } @@ -119,7 +108,6 @@ object ExternalMode : PlugwrightMode { ctx.pluginConfigs(project.provider { spec.pluginsSpec.refs() }) 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) @@ -129,15 +117,6 @@ object ExternalMode : PlugwrightMode { 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/PlugwrightCleanupTask.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt deleted file mode 100644 index 849f9b3..0000000 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt +++ /dev/null @@ -1,68 +0,0 @@ -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(), - workspaceDir = 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-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt index e9fa421..69b9419 100644 --- 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 @@ -34,6 +34,12 @@ class LocalEnvironmentSpec(private val environmentName: String, objects: ObjectF /** Port bots connect on. Currently always bound on `localhost`. */ val port: Property = objects.property(Int::class.java).convention(25565) + /** RCON port for the local server. Defaults to 25575. */ + val rconPort: Property = objects.property(Int::class.java).convention(25575) + + /** RCON password for the local server. Static throwaway — the server only listens on localhost. */ + val rconPassword: Property = objects.property(String::class.java).convention("plugwright") + /** URLs of plugins to download before running tests. */ val pluginUrls: ListProperty = objects.listProperty(String::class.java).convention(emptyList()) 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 index c0ff091..ccf611d 100644 --- 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 @@ -27,8 +27,9 @@ object LocalMode : PlugwrightMode { override fun createSpec(name: String, objects: ObjectFactory): LocalEnvironmentSpec = LocalEnvironmentSpec(name, objects) - override fun runnerPackages(spec: LocalEnvironmentSpec): List = - listOf(RunnerPackageRef("@plugwright/runner", export = "localEnvironment")) + override fun runnerPackages(spec: LocalEnvironmentSpec): List = listOf( + RunnerPackageRef("@plugwright/runner", export = "localEnvironment") + ) override fun validate(spec: LocalEnvironmentSpec, ctx: ValidationContext) { if (spec.minecraftVersion.get().isBlank()) { @@ -79,9 +80,14 @@ object LocalMode : PlugwrightMode { runDir.set(spec.runDir) minecraftVersion.set(spec.minecraftVersion) port.set(spec.port) - pluginJar.set(ctx.projectPluginJar) + pluginJar.set(spec.useExternalPluginsOnly.flatMap { externalOnly -> + if (externalOnly) project.objects.property(File::class.java) + else ctx.projectPluginJar + }) pluginUrls.set(spec.pluginUrls) runDirFiles.set(spec.runDirFiles) + rconPort.set(spec.rconPort) + rconPassword.set(spec.rconPassword) } val javaLauncherProvider: Provider? = run { @@ -124,6 +130,8 @@ object LocalMode : PlugwrightMode { builder.put("minecraftVersion", spec.minecraftVersion.get()) builder.put("host", "localhost") builder.put("port", spec.port.get()) + builder.put("rconPort", spec.rconPort.get()) + builder.put("rconPassword", spec.rconPassword.get()) } private fun resolveJavaPath(javaLauncher: Provider?): String { diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt index ddff596..780a896 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt @@ -34,6 +34,12 @@ abstract class PaperProvisionTask : DefaultTask() { @get:Input abstract val port: Property + @get:Input + abstract val rconPort: Property + + @get:Input + abstract val rconPassword: Property + @get:Input @get:Optional abstract val pluginJar: Property @@ -125,12 +131,29 @@ abstract class PaperProvisionTask : DefaultTask() { lines.add("spawn-protection=0") } + // Enable RCON so the runner can send commands over a proper protocol + val rconProperties = mapOf( + "enable-rcon" to "true", + "rcon.port" to rconPort.get().toString(), + "rcon.password" to rconPassword.get() + ) + for ((key, value) in rconProperties) { + val hasKey = lines.any { it.trim().startsWith("$key=") } + if (hasKey) { + lines = lines.map { line -> + if (line.trim().startsWith("$key=")) "$key=$value" else line + }.toMutableList() + } else { + lines.add("$key=$value") + } + } + Files.write(serverProperties.toPath(), lines) } else { - logger.lifecycle("Creating server.properties with online-mode=false, connection-throttle=0, spawn-protection=0 and server-port=${port.get()}") + logger.lifecycle("Creating server.properties with online-mode=false, connection-throttle=0, spawn-protection=0, enable-rcon=true and server-port=${port.get()}") Files.write( serverProperties.toPath(), - listOf("online-mode=false", "connection-throttle=0", "spawn-protection=0", "server-port=${port.get()}") + listOf("online-mode=false", "connection-throttle=0", "spawn-protection=0", "server-port=${port.get()}", "enable-rcon=true", "rcon.port=${rconPort.get()}", "rcon.password=${rconPassword.get()}") ) } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1ce7483 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "plugwright", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json index f7e998b..22eb671 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "private": true, "scripts": { "bump": "node scripts/bump-version.js", - "install:packages": "npm ci --prefix runner-package && npm ci --prefix auth-authme-package && npm ci --prefix console-rcon-package", - "build:packages": "npm run build --prefix runner-package && npm run build --prefix auth-authme-package && npm run build --prefix console-rcon-package", + "install:packages": "npm ci --prefix runner-package && npm ci --prefix auth-authme-package", + "build:packages": "npm run build --prefix runner-package && npm run build --prefix auth-authme-package", "publish:packages": "node scripts/publish.js" } } diff --git a/runner-package/README.md b/runner-package/README.md index 0e8bb26..16feeef 100644 --- a/runner-package/README.md +++ b/runner-package/README.md @@ -41,7 +41,7 @@ The runner takes a config file describing one environment: npx plugwright --config build/tmp/plugwright/local.json ``` -The Gradle plugin writes that file, but nothing stops you from writing it yourself. `local` starts and stops its own Paper server; `external` connects to one that is already running, with an account pool, a console channel and authentication handled by a plugin. Two service modes exist for the second case: `--ping` checks that the server answers without running tests, and `--cleanup` replays outstanding cleanup work. +The Gradle plugin writes that file, but nothing stops you from writing it yourself. `local` starts and stops its own Paper server; `external` connects to one that is already running, with an account pool, a console channel and authentication handled by a plugin. Two service modes exist for the second case: `--ping` checks that the server answers without running tests. ## Documentation diff --git a/runner-package/cli.ts b/runner-package/cli.ts index 566361a..4618230 100644 --- a/runner-package/cli.ts +++ b/runner-package/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { runTestSession, runPingSession, runCleanupSession } from './runner.js'; +import { runTestSession, runPingSession } from './runner.js'; const argv = process.argv.slice(2); @@ -9,10 +9,6 @@ async function main(): Promise { await runPingSession(); return; } - if (argv.includes('--cleanup')) { - await runCleanupSession(); - return; - } await runTestSession(); } diff --git a/runner-package/lib/admin-bot-console.ts b/runner-package/lib/admin-bot-console.ts deleted file mode 100644 index 6a962e8..0000000 --- a/runner-package/lib/admin-bot-console.ts +++ /dev/null @@ -1,82 +0,0 @@ -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/config.ts b/runner-package/lib/config.ts index cd1f791..9665c66 100644 --- a/runner-package/lib/config.ts +++ b/runner-package/lib/config.ts @@ -66,9 +66,6 @@ export interface RunnerConfig { 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. */ @@ -80,6 +77,8 @@ export interface LocalEnvironmentConfig { minecraftVersion?: string | null; host?: string | null; port?: number | null; + rconPort?: number | null; + rconPassword?: string | null; } /** diff --git a/runner-package/lib/console.ts b/runner-package/lib/console.ts index ba0d585..c4efab7 100644 --- a/runner-package/lib/console.ts +++ b/runner-package/lib/console.ts @@ -1,14 +1,13 @@ /** * 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. + * e.g., RCON or stdio. */ export interface ServerConsole { - readonly kind: 'stdio' | 'rcon' | 'admin-bot'; + readonly kind: 'stdio' | 'rcon'; /** 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; + execute(cmd: string, timeoutMs?: number): Promise; + close?(): void | Promise; } diff --git a/runner-package/lib/environment.ts b/runner-package/lib/environment.ts index 61e43c6..e6df4f0 100644 --- a/runner-package/lib/environment.ts +++ b/runner-package/lib/environment.ts @@ -8,10 +8,6 @@ export interface EnvironmentCapabilities { console: boolean; consoleOutput: 'full' | 'responses' | 'none'; op: boolean; - freshState: boolean; - arbitraryUsernames: boolean; - lifecycle: boolean; - cleanupStrategy: 'wipe' | 'compensating' | 'none'; } export interface BotConnectionOptions { diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts index 8a24fbc..7d411e3 100644 --- a/runner-package/lib/environments/external.ts +++ b/runner-package/lib/environments/external.ts @@ -6,11 +6,11 @@ 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, importOptionalPackage } from '../utils.js'; +import { sleep } from '../utils.js'; +import { rconConsole } from '../rcon/index.js'; export interface ExternalConsoleChannelConfig { - kind: 'rcon' | 'adminBot'; + kind: 'rcon'; port?: number; username?: string; password?: SecretRef; @@ -31,10 +31,6 @@ const BASE_CAPABILITIES: EnvironmentCapabilities = { // 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', }; /** @@ -65,11 +61,9 @@ class ExternalEnvironment implements Environment { return this.accountPool; } - async setup(session: Session): Promise { - const connOpts = this.connection(); - + async setup(_session: Session): Promise { for (const channel of this.config.console ?? []) { - const candidate = await this.buildChannel(channel, session, connOpts); + const candidate = await this.buildChannel(channel); if (!candidate) continue; try { if (await candidate.probe()) { @@ -98,46 +92,15 @@ class ExternalEnvironment implements Environment { 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 importOptionalPackage(rconPackage); - } catch (error) { - 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.\n' + - `(${(error as Error).message})` - )); - 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({ + return rconConsole({ 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; } @@ -166,6 +129,9 @@ class ExternalEnvironment implements Environment { async teardown(): Promise { // No lifecycle: the tested server isn't ours to stop. + if (this._console?.close) { + await this._console.close(); + } } } diff --git a/runner-package/lib/environments/local.ts b/runner-package/lib/environments/local.ts index 6b05dd5..4ec8c0c 100644 --- a/runner-package/lib/environments/local.ts +++ b/runner-package/lib/environments/local.ts @@ -1,69 +1,25 @@ 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'; +import { rconConsole } from '../rcon/index.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. - * Returns all lines produced between command submission and the sync marker. - */ - 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 recent = this.session.consoleLog.slice(since); - const syncIdx = recent.findIndex(l => l.includes(syncId)); - if (syncIdx !== -1) { - return recent.slice(0, syncIdx).join('\n'); - } - 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. + * spawn it, tear it down. + * + * Commands are sent over RCON (the same protocol external mode uses) for reliable + * command-response correlation. The full server log is still captured via stdout/stderr + * so `expect(server).toHaveReceivedMessage(...)` keeps working — that's what + * `consoleOutput: 'full'` means. */ export class LocalEnvironment implements Environment { readonly id = 'local'; @@ -73,6 +29,7 @@ export class LocalEnvironment implements Environment { private serverProcess: ChildProcessWithoutNullStreams | null = null; private session: Session | null = null; private cleanupStarted = false; + private _rconConsole: ServerConsole | null = null; constructor(config: LocalEnvironmentConfig) { this.config = config; @@ -100,8 +57,48 @@ export class LocalEnvironment implements Environment { await this._waitForServerStart(serverProcess); console.log(`${pc.green(pc.bold('Server started successfully'))}\n`); + // stdout/stderr continue to feed the full console log — this is what makes + // `consoleOutput: 'full'` true and `expect(server).toHaveReceivedMessage` work. serverProcess.stdout.on('data', (data: Buffer) => session.writeConsoleOutput(data)); serverProcess.stderr.on('data', (data: Buffer) => session.writeConsoleOutput(data)); + + // Connect to the local server's RCON for sending commands. RCON gives a proper + // synchronous response per command, unlike the old stdin `/say ` trick. + await this._connectRcon(); + } + + /** + * Connects to the local server via RCON. + */ + private async _connectRcon(): Promise { + const consoleInstance: ServerConsole = rconConsole({ + host: this.config.host ?? 'localhost', + port: this.config.rconPort ?? 25575, + password: this.config.rconPassword ?? 'plugwright', + }); + + // RCON may need a moment after the server logs "Done" — retry a few times. + const maxAttempts = 5; + let lastError: Error | null = null; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + if (await consoleInstance.probe()) { + this._rconConsole = consoleInstance; + console.log(pc.green(`[local] RCON connected (port ${this.config.rconPort ?? 25575})`)); + return; + } + } catch (error) { + lastError = error as Error; + } + // Wait before retrying — RCON listener may start slightly after the game loop. + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + throw new Error( + `RCON failed to connect to the local server after ${maxAttempts} attempts. ` + + 'Make sure enable-rcon=true is set in server.properties (the Gradle plugin does ' + + `this automatically). ${lastError ? `(${lastError.message})` : ''}` + ); } connection(): BotConnectionOptions { @@ -114,14 +111,18 @@ export class LocalEnvironment implements Environment { } console(): ServerConsole | null { - if (!this.serverProcess || !this.session) return null; - return new StdioConsole(this.serverProcess, this.session); + return this._rconConsole; } async teardown(): Promise { + if (this._rconConsole?.close) { + await this._rconConsole.close(); + } + const serverProcess = this.serverProcess; if (!serverProcess) return; + // Send `stop` through stdin — reliable even if RCON has already disconnected. if (serverProcess.exitCode === null && !serverProcess.killed) { try { serverProcess.stdin.write('stop\n'); diff --git a/runner-package/lib/journal.ts b/runner-package/lib/journal.ts deleted file mode 100644 index 845c73b..0000000 --- a/runner-package/lib/journal.ts +++ /dev/null @@ -1,65 +0,0 @@ -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/matchers.ts b/runner-package/lib/matchers.ts index a18e058..0dac074 100644 --- a/runner-package/lib/matchers.ts +++ b/runner-package/lib/matchers.ts @@ -94,7 +94,7 @@ export class RunnerMatchers extends Matchers { if (!(this.actual instanceof PlayerWrapper) && session.env.capabilities.consoleOutput !== 'full') { throw new Error( `Cannot read the server log on environment "${session.env.id}": its console output level is ` + - `"${session.env.capabilities.consoleOutput}". Mark the test with requires: ['consoleOutput:full'] ` + + `"${session.env.capabilities.consoleOutput}". Mark the test with { requires: { consoleOutput: 'full' } } ` + 'to have it skipped there instead.' ); } @@ -212,9 +212,9 @@ interface PollOptions { } export class PollMatchers { - private fn: () => T | Promise; - private options: Required> & { message?: string }; - private isNot: boolean; + private readonly fn: () => T | Promise; + private readonly options: Required> & { message?: string }; + private readonly isNot: boolean; constructor(fn: () => T | Promise, options: PollOptions = {}, isNot: boolean = false) { this.fn = fn; diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index cb9e8ef..2376ea4 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -46,12 +46,6 @@ export class PlayerWrapper { private _spawnPromise: Promise | null = null; private _listenersBot: Bot | null = null; private _account?: Account; - /** Labels describing server state this player is known to carry — set automatically by - * `makeOp`/`deOp`/`setGameMode`, and by hand via `mark`/`unmark` for anything else. Survives - * `rejoin()`: it describes server state, which a reconnect doesn't touch. Nothing in the - * core reads a label's meaning; they exist for a test (or a plugin) to leave a note on a - * player one step of a `describe.serial` block can read in the next. */ - private readonly _abilities = new Set(); constructor(bot: Bot, session: Session) { this.bot = bot; @@ -204,29 +198,6 @@ export class PlayerWrapper { this.serverWrapper = server; } - /** Read-only snapshot of this player's ability labels. */ - get abilities(): ReadonlySet { - return this._abilities; - } - - /** Records that this player carries `ability`. A statement, not a check — nothing here - * verifies it against real server state. */ - mark(ability: string): void { - this._abilities.add(ability); - } - - /** Removes `ability`. No-op if the player never carried it. */ - unmark(ability: string): void { - this._abilities.delete(ability); - } - - private markGameMode(mode: string): void { - for (const ability of this._abilities) { - if (ability.startsWith('gamemode:')) this._abilities.delete(ability); - } - this._abilities.add(`gamemode:${mode}`); - } - getCurrentGui(): GuiWrapper | null { let currentWindow = this.bot.currentWindow; return currentWindow ? new GuiWrapper(this.bot, currentWindow as Window) : null; @@ -286,60 +257,33 @@ export class PlayerWrapper { async makeOp(): Promise { this.requireServer(); - const command = `minecraft:op ${this.username}`; - - // A console that answers (RCON) says whether the command worked; the confirmation is - // never broadcast to the player, so there is nothing to wait for in the chat buffer. - if (this.session.console?.output === 'responses') { - const response = await this.serverWrapper!.executeAndWait(command); - // "Made X a server operator" on success, "Nothing changed. The player already is - // an operator" when it was already granted — both mean the player is op now. - if (/operator/i.test(response)) { - this.mark('op'); - return; - } + const response = await this.serverWrapper!.execute(`minecraft:op ${this.username}`); + if (!/operator/i.test(response) && !/nothing changed/i.test(response)) { throw new Error(`Player ${this.username} was not opped: ${response.trim() || 'no response from the console'}`); } - - const messagesSince = this.messageBuffer.length; - const consoleSince = this.session.consoleLog.length; - this.serverWrapper!.execute(command); - - // "Made X a server operator" reaches the player's own chat. "Nothing changed. The - // player already is an operator" — the case a reused, already-op player hits on a - // second `makeOp()` — never does; it only ever shows up in the server's own log. - await poll( - () => - this.messageBuffer.slice(messagesSince).find(m => m.includes(`Made ${this.username} a server operator`)) ?? - this.session.consoleLog.slice(consoleSince).find(m => /operator/i.test(m)), - { message: `Player ${this.username} was not opped` } - ); - this.mark('op'); } async deOp(): Promise { - await this.executeAndSync(`minecraft:deop ${this.username}`); - this.unmark('op'); + this.requireServer(); + await this.serverWrapper!.execute(`minecraft:deop ${this.username}`); } async setGameMode(mode: 'survival' | 'creative' | 'adventure' | 'spectator'): Promise { if (this.bot.game.gameMode === mode) { - this.markGameMode(mode); return; } this.requireServer(); - this.serverWrapper!.execute(`minecraft:gamemode ${mode} ${this.username}`); + await this.serverWrapper!.execute(`minecraft:gamemode ${mode} ${this.username}`); await poll( () => this.bot.game.gameMode === mode ? true : undefined, { message: `Game mode did not change to "${mode}"` } ); - this.markGameMode(mode); } async teleport(x: number, y: number, z: number): Promise { this.requireServer(); - this.serverWrapper!.execute(`minecraft:tp ${this.username} ${x} ${y} ${z}`); + await this.serverWrapper!.execute(`minecraft:tp ${this.username} ${x} ${y} ${z}`); await poll( () => { @@ -396,7 +340,7 @@ export class PlayerWrapper { async giveItem(item: string, count: number = 1): Promise { this.requireServer(); - this.serverWrapper!.execute(`minecraft:give ${this.username} ${item} ${count}`); + await this.serverWrapper!.execute(`minecraft:give ${this.username} ${item} ${count}`); await poll( () => { @@ -425,7 +369,7 @@ export class PlayerWrapper { const timeout = opts.timeout ?? 5000; if (item) { - this.serverWrapper!.execute(`minecraft:clear ${this.username} ${item}`); + await this.serverWrapper!.execute(`minecraft:clear ${this.username} ${item}`); await waitUntil( () => !this.bot.inventory.items().some(i => i.name.includes(item)), { @@ -434,7 +378,7 @@ export class PlayerWrapper { } ); } else { - this.serverWrapper!.execute(`minecraft:clear ${this.username}`); + await this.serverWrapper!.execute(`minecraft:clear ${this.username}`); await waitUntil( () => this.bot.inventory.items().length === 0, { @@ -450,25 +394,4 @@ export class PlayerWrapper { throw new Error('ServerWrapper not set on PlayerWrapper'); } } - - private async executeAndSync(cmd: string): Promise { - this.requireServer(); - - // A console that answers has already finished the command by the time it replies. The - // marker below exists for the stdio console, where output and command completion are - // two unrelated streams. - if (this.session.console?.output === 'responses') { - await this.serverWrapper!.executeAndWait(cmd); - return; - } - - const syncId = `sync_${randomUUID().split('-')[0]}`; - this.serverWrapper!.execute(cmd); - this.serverWrapper!.execute(`minecraft:say ${syncId}`); - - await poll( - () => this.messageBuffer.find(m => m.includes(syncId)), - { message: `Server command sync timed out for: ${cmd}` } - ); - } } \ No newline at end of file diff --git a/runner-package/lib/plugin-host.ts b/runner-package/lib/plugin-host.ts index d1c6da6..b8780c4 100644 --- a/runner-package/lib/plugin-host.ts +++ b/runner-package/lib/plugin-host.ts @@ -115,10 +115,10 @@ export class PluginHost { ); } - async runCleanup(session: Session, scope: 'session' | 'manual'): Promise { + async runCleanup(session: Session): Promise { for (const { plugin } of [...this.plugins].reverse()) { try { - await plugin.cleanup?.({ session, scope }); + await plugin.cleanup?.({ session }); } catch (error) { console.error(pc.red(`[plugin ${plugin.name}] cleanup error: ${(error as Error).message}`)); } diff --git a/runner-package/lib/plugin.ts b/runner-package/lib/plugin.ts index 352666c..fe58c4e 100644 --- a/runner-package/lib/plugin.ts +++ b/runner-package/lib/plugin.ts @@ -17,9 +17,6 @@ export interface SessionContext { 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 { diff --git a/console-rcon-package/lib/rcon-connection.ts b/runner-package/lib/rcon/connection.ts similarity index 91% rename from console-rcon-package/lib/rcon-connection.ts rename to runner-package/lib/rcon/connection.ts index 86f6d6b..6ae7600 100644 --- a/console-rcon-package/lib/rcon-connection.ts +++ b/runner-package/lib/rcon/connection.ts @@ -81,8 +81,14 @@ export class RconConnection { if (packet.type === PacketType.AUTH_RESPONSE && this.pendingAuth) { const waiter = this.pendingAuth; this.pendingAuth = null; - if (packet.id === -1) waiter.reject(new Error('RCON authentication failed: wrong password')); - else waiter.resolve(''); + if (packet.id === -1) { + this.socket?.destroy(); + this.socket = null; + this.connectPromise = null; + waiter.reject(new Error('RCON authentication failed: wrong password')); + } else { + waiter.resolve(''); + } return; } @@ -130,4 +136,12 @@ export class RconConnection { console.error(`[rcon] command failed: ${cmd}: ${error.message}`); }); } + + disconnect(): void { + if (this.socket) { + this.socket.end(); + this.socket = null; + } + this.connectPromise = null; + } } diff --git a/console-rcon-package/index.ts b/runner-package/lib/rcon/index.ts similarity index 55% rename from console-rcon-package/index.ts rename to runner-package/lib/rcon/index.ts index fa0939b..b2b2da9 100644 --- a/console-rcon-package/index.ts +++ b/runner-package/lib/rcon/index.ts @@ -1,5 +1,5 @@ -import type { ServerConsole } from '@plugwright/runner'; -import { RconConnection } from './lib/rcon-connection.js'; +import type { ServerConsole } from '../console.js'; +import { RconConnection } from './connection.js'; export interface RconConsoleConfig { host: string; @@ -8,9 +8,7 @@ export interface RconConsoleConfig { } /** - * `ServerConsole` over RCON: unlike `stdio` and `admin-bot`, the protocol gives a synchronous - * response to every command, so `executeAndWait` doesn't need the `minecraft:say ` - * round-trip trick those two rely on. + * `ServerConsole` over RCON. */ export function rconConsole(config: RconConsoleConfig): ServerConsole { const connection = new RconConnection(config.host, config.port, config.password); @@ -28,12 +26,14 @@ export function rconConsole(config: RconConsoleConfig): ServerConsole { } }, - execute(cmd: string): void { - connection.execute(cmd); - }, - - async executeAndWait(cmd: string, timeoutMs: number = 5000): Promise { + async execute(cmd: string, timeoutMs: number = 5000): Promise { return connection.executeAndWait(cmd, timeoutMs); }, + + close(): void { + connection.disconnect(); + } }; } + +export { RconConnection }; diff --git a/console-rcon-package/lib/protocol.ts b/runner-package/lib/rcon/protocol.ts similarity index 100% rename from console-rcon-package/lib/protocol.ts rename to runner-package/lib/rcon/protocol.ts diff --git a/runner-package/lib/server.ts b/runner-package/lib/server.ts index 65bfd53..4a75fb5 100644 --- a/runner-package/lib/server.ts +++ b/runner-package/lib/server.ts @@ -19,22 +19,13 @@ export class ServerWrapper { this.startIndex = this.session.consoleLog.length; } - /** Executes a console command synchronously. Note: when running under `concurrency: N`, + /** Executes a console command and resolves with the server's response. Note: when running under `concurrency: N`, * console output is shared across all concurrent tests. Prefer player actions or qualify * commands with `player.username`. */ - execute(cmd: string): void { + execute(cmd: string, timeoutMs?: number): Promise { if (!this.session.console) { throw new Error('No server console available for this environment'); } - this.session.console.execute(cmd); - } - - /** Runs a command and resolves with whatever the console gives back. A console with - * `output: 'none'` has nothing to give back and resolves empty. */ - executeAndWait(cmd: string, timeoutMs?: number): Promise { - if (!this.session.console) { - throw new Error('No server console available for this environment'); - } - return this.session.console.executeAndWait(cmd, timeoutMs); + return this.session.console.execute(cmd, timeoutMs); } } diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index a539435..7d3b891 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -1,6 +1,5 @@ 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'; @@ -55,16 +54,14 @@ export class Session { console: ServerConsole | null = null; readonly bots: Bot[] = []; readonly consoleLog = new MessageBuffer(); - readonly journal: CleanupJournal; /** 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) { + constructor(env: Environment) { 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/skip-reason.ts b/runner-package/lib/skip-reason.ts index 554dbf6..35f90bf 100644 --- a/runner-package/lib/skip-reason.ts +++ b/runner-package/lib/skip-reason.ts @@ -1,21 +1,37 @@ import type { Environment } from './environment.js'; +import type { RequiresMap } from './test-registry.js'; -/** Capability keys from a `requires` list that `env` does not actually satisfy. A value of +/** Capability keys from a `requires` map that `env` does not actually satisfy. A value of * `false`, `'none'`, or an absent key all count as unmet. * - * `'key:value'` demands one specific value instead — `'consoleOutput:full'` for a test that + * `{ consoleOutput: 'full' }` demands one specific value instead — for a test that * reads the server log, which a console answering only its own commands cannot provide even - * though it satisfies plain `'console'`. */ -export function missingCapabilities(env: Environment, required: string[]): string[] { - const capabilities = env.capabilities as unknown as Record; - return required.filter(key => { - const separator = key.indexOf(':'); - if (separator !== -1) { - return String(capabilities[key.slice(0, separator)]) !== key.slice(separator + 1); + * though it satisfies plain `console: true`. */ +export function missingCapabilities(env: Environment, required: RequiresMap): string[] { + if (Array.isArray(required)) { + throw new Error('Test "requires" must be an object map (e.g. { requires: { console: true } }), not an array.'); + } + const capabilities = (env?.capabilities ?? {}) as unknown as Record; + const missing: string[] = []; + for (const [key, expectedValue] of Object.entries(required ?? {})) { + if (expectedValue === undefined) continue; + const actualValue = capabilities[key]; + + if (expectedValue === true) { + if (actualValue === false || actualValue === 'none' || actualValue == null) { + missing.push(key); + } + } else if (expectedValue === false) { + if (actualValue !== false && actualValue !== 'none' && actualValue != null) { + missing.push(`!${key}`); + } + } else { + if (String(actualValue) !== String(expectedValue)) { + missing.push(`${key}:${expectedValue}`); + } } - const value = capabilities[key]; - return value === false || value === 'none' || value === undefined; - }); + } + return missing; } /** The two `TestOptions` fields a test itself declares — `environments` and `requires` — @@ -24,7 +40,7 @@ export function missingCapabilities(env: Environment, required: string[]): strin export function skipReasonForOptions( env: Environment, environmentName: string, - requires: string[], + requires: RequiresMap, environments: string[] | null, ): string | null { if (environments && !environments.includes(environmentName)) { diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 40a4819..7559266 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -3,16 +3,20 @@ import type { TestContext } from './types.js'; export type Hook = (context: TestContext) => Promise | void; type TestFn = (context: TestContext) => Promise; +import type { EnvironmentCapabilities } from './environment.js'; + /** * Filters usable from a spec file, independent of environment names. * - * `requires` checks capability flags on `env.capabilities` (e.g. `'console'`, `'op'`) — + * `requires` checks capability flags on `env.capabilities` (e.g. `console: true`, `op: true`) — * 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. */ +export type RequiresMap = Partial; + export interface TestOptions { - requires?: string[]; + requires?: RequiresMap; environments?: string[]; /** Runs this many independent instances of the test concurrently, each with its own bot * leased from the account pool, to exercise races between players hitting the same feature @@ -35,7 +39,7 @@ export interface TestCase { /** Spec-level `afterEach` hooks in run order (innermost `describe` first) — already * reversed at registration time, see `registerTest`. */ afterHooks: Hook[]; - requires: string[]; + requires: RequiresMap; environments: string[] | null; concurrency: number; } @@ -54,7 +58,7 @@ export interface SerialBlock { name: string; account: string | null; tests: TestCase[]; - requires: string[]; + requires: RequiresMap; environments: string[] | null; concurrency: number; } @@ -87,7 +91,7 @@ function scopedEntry(name: string, options: TestOptions) { name: [...labels, name].join(' > '), beforeHooks: scopeStack.flatMap(s => s.beforeHooks), afterHooks: [...scopeStack].reverse().flatMap(s => s.afterHooks), - requires: options.requires ?? [], + requires: options.requires ?? {}, environments: options.environments ?? null, concurrency: normalizeConcurrency(options.concurrency), }; @@ -138,9 +142,7 @@ export function opTest(name: string, fnOrOptions: TestFn | TestOptions, maybeFn? const options = typeof fnOrOptions === 'function' ? {} : fnOrOptions; const fn = typeof fnOrOptions === 'function' ? fnOrOptions : maybeFn!; registerTest(name, options, async (context: TestContext) => { - // The label is what a player already opped earlier in the same block carries, so a - // second `opTest` in one `describe.serial` doesn't re-run the command. - if (!context.player.abilities.has('op')) await context.player.makeOp(); + await context.player.makeOp(); await fn(context); }); } @@ -182,7 +184,7 @@ function serialImpl(label: string, optionsOrFn: SerialOptions | (() => void), ma name: [...labels, label].join(' > '), account: options.account ?? null, tests: [], - requires: options.requires ?? [], + requires: options.requires ?? {}, environments: options.environments ?? null, concurrency: normalizeConcurrency(options.concurrency), }; diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index bad1ea5..fcacaff 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -250,6 +250,7 @@ export async function runTestCase(params: RunTestCaseParams): Promise bots.createPlayer(options), invalidatePlayer: () => { /* nothing follows this test — see the serial-block runner */ }, signal: abort.signal, @@ -352,6 +353,7 @@ export async function runSerialBlock(params: RunSerialBlockParams): Promise bots.createPlayer(options), invalidatePlayer: (p, reason) => { if (p === player) invalidatedBy = reason ?? `invalidated by "${testCase.name}"`; diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 90e8989..1eec0c1 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -1,9 +1,14 @@ import type { PlayerWrapper } from './player.js'; import type { ServerWrapper } from './server.js'; +import type { Environment } from './environment.js'; export interface TestContext { player: PlayerWrapper; server: ServerWrapper; + /** The environment this test is running against. Use `env.id` to check whether + * you're on `'local'` or `'external'`, and `env.capabilities` to inspect what + * the environment supports. */ + env: Environment; /** Connects an extra bot. Inside a `describe.serial` block, `as` names it: the same name in * a later test of that block returns the same bot instead of connecting another. Outside a * block the name is scoped to the one test, which is as long as the bot lives anyway. diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 5d911fd..1a51ac7 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -30,7 +30,7 @@ export { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator }; export { PlayerWrapper }; export { ServerWrapper } from './lib/server.js'; export { test, opTest, describe, beforeEach, afterEach } from './lib/test-registry.js'; -export type { TestOptions, TestCase, SerialOptions, SerialBlock } from './lib/test-registry.js'; +export type { TestOptions, TestCase, SerialOptions, SerialBlock, RequiresMap } 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, PluginConfig } from './lib/config.js'; @@ -43,9 +43,6 @@ export { definePlugin, PLUGIN_API_VERSION } from './lib/plugin.js'; export type { PlugwrightPlugin, SessionContext, CleanupContext, PluginTestRef, MatcherFn } from './lib/plugin.js'; export { AccountPool } from './lib/account.js'; export type { Account, AccountsConfig } from './lib/account.js'; -export { AdminBotConsole } from './lib/admin-bot-console.js'; -export { CleanupJournal } from './lib/journal.js'; -export type { JournalEntry } from './lib/journal.js'; export { externalEnvironment }; export type { ExternalEnvironmentConfig, ExternalConsoleChannelConfig } from './lib/environments/external.js'; @@ -101,13 +98,13 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): const testResults: TestResult[] = []; const env = await resolveEnvironment(config.environment); - const session = new Session(env, config.journal ?? null); + const session = new Session(env); 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. + // to authenticate during setup(), which goes through this same hook. session.onPlayerCreate = (player, ctx) => plugins.onPlayerCreate(player, ctx); let exitCode = 0; @@ -289,7 +286,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): } } finally { - await plugins.runCleanup(session, 'session'); + await plugins.runCleanup(session); await plugins.teardown(); await session.disconnectAllBots(); await env.teardown(); @@ -324,7 +321,7 @@ export async function runPingSession(config: RunnerConfig = loadRunnerConfig()): 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 session = new Session(env); const plugins = new PluginHost(); await plugins.load(config.plugins ?? []); plugins.registerMatchers(); @@ -391,49 +388,3 @@ export async function runPingSession(config: RunnerConfig = loadRunnerConfig()): 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(); - } - - // 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(); -} diff --git a/scripts/bump-version.js b/scripts/bump-version.js index d3aaf65..383a721 100644 --- a/scripts/bump-version.js +++ b/scripts/bump-version.js @@ -10,7 +10,6 @@ const readline = require("readline"); const NPM_PACKAGES = [ "runner-package", "auth-authme-package", - "console-rcon-package", ]; function prompt(question) { diff --git a/scripts/publish.js b/scripts/publish.js index 33c435a..893d482 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -49,7 +49,6 @@ const path = require("path"); const PACKAGES = [ "runner-package", "auth-authme-package", - "console-rcon-package", ]; const PUBLIC_REGISTRY = "https://registry.npmjs.org/"; From 433f57c04ecf6f7f7944772f8d88706dd3bfbcfc Mon Sep 17 00:00:00 2001 From: Drownek Date: Tue, 8 Sep 2026 13:16:14 +0200 Subject: [PATCH 113/125] feat(runner): support Minecraft 26.1.1 and 26.1.2 versions --- runner-package/lib/session.ts | 7 +++- runner-package/package-lock.json | 56 ++++++++++++++++++-------------- runner-package/package.json | 2 +- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index 7d3b891..51c00d0 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -70,11 +70,16 @@ export class Session { } createBot(options: BotConnectionOptions & { username: string }): Bot { + let version = options.version; + if (version && version.startsWith('26.1.')) { + version = '26.1'; + } + const bot = mineflayer.createBot({ host: options.host, port: options.port, username: options.username, - version: options.version, + version, // A custom function here (instead of the 'microsoft' string) so profile/certificate // fetches are cached across bots for the same account — see microsoft-auth.ts. auth: options.auth === 'microsoft' ? microsoftAuthWithCache(options.username) : options.auth, diff --git a/runner-package/package-lock.json b/runner-package/package-lock.json index 6997c95..e7358e6 100644 --- a/runner-package/package-lock.json +++ b/runner-package/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", - "mineflayer": "^4.0.0", + "mineflayer": "^4.39.0", "picocolors": "^1.1.1", "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" @@ -78,9 +78,9 @@ } }, "node_modules/@types/readable-stream": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", - "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", "license": "MIT", "dependencies": { "@types/node": "*" @@ -486,9 +486,9 @@ "license": "MIT" }, "node_modules/minecraft-data": { - "version": "3.111.0", - "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.111.0.tgz", - "integrity": "sha512-0kKHqNZL/D1IbH7tJXBJ3z7YSn1/ylnWWR7X2zFwtEV+yr23nimItpGjP3o8UaLYrC+OV1gKLFQoew03XvJyoA==", + "version": "3.116.0", + "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.116.0.tgz", + "integrity": "sha512-2XwQjnAqCdCdhEts6cm1nnmzeeCCbsiAhhuOa6rQOTKceGkwZTE8ZJwO3UeJLwvjEAh8S0/irXN1Xqv/lhceNg==", "license": "MIT" }, "node_modules/minecraft-folder-path": { @@ -498,9 +498,9 @@ "license": "MIT" }, "node_modules/minecraft-protocol": { - "version": "1.66.2", - "resolved": "https://registry.npmjs.org/minecraft-protocol/-/minecraft-protocol-1.66.2.tgz", - "integrity": "sha512-keY1IY1E2AeurcekCfcXrg0TDbykGVFiMe1E4wR8QkNtQRieNwfr2xaF3g3vT9ChkwzvENqp3jxgmtFCKSUKPg==", + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/minecraft-protocol/-/minecraft-protocol-1.68.0.tgz", + "integrity": "sha512-atAGg/fhIVqyK6voWvED5QA+4ah65PNwpoDnkHawWhVzQbwFii77KcF6OpsU/Xlbi0o9GpihHiPdORSxXEA+Ug==", "license": "BSD-3-Clause", "dependencies": { "@types/node-rsa": "^1.1.4", @@ -510,7 +510,7 @@ "debug": "^4.3.2", "endian-toggle": "^0.0.0", "lodash.merge": "^4.3.0", - "minecraft-data": "^3.78.0", + "minecraft-data": "^3.109.0", "minecraft-folder-path": "^1.2.0", "node-fetch": "^2.6.1", "node-rsa": "^0.4.2", @@ -528,22 +528,22 @@ } }, "node_modules/mineflayer": { - "version": "4.37.1", - "resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-4.37.1.tgz", - "integrity": "sha512-kchZCJb1znzz8ZhE0+gLQ3e2t/9xUsqUy/IM/sGfceINxi3h6KXKY9luaUEa59vnD/x0OKwYdERY4sscm0ErNQ==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-4.39.0.tgz", + "integrity": "sha512-HptE++dIG55aRmT7GIj3QComS6MhEXstE3RJSuYMFhLtobsQhJJsqWmY9ObfiLp84MrS6Q4Em+wUH0rVQMXLJw==", "license": "MIT", "dependencies": { - "minecraft-data": "^3.108.0", - "minecraft-protocol": "^1.66.0", + "minecraft-data": "^3.114.0", + "minecraft-protocol": "^1.67.0", "mojangson": "^2.0.4", "prismarine-biome": "^1.1.1", "prismarine-block": "^1.22.0", "prismarine-chat": "^1.7.1", - "prismarine-chunk": "^1.39.0", + "prismarine-chunk": "^1.41.0", "prismarine-entity": "^2.5.0", "prismarine-item": "^1.17.0", "prismarine-nbt": "^2.0.0", - "prismarine-physics": "^1.9.0", + "prismarine-physics": "^1.11.1", "prismarine-recipe": "^1.5.0", "prismarine-registry": "^1.10.0", "prismarine-windows": "^2.9.0", @@ -734,9 +734,9 @@ } }, "node_modules/prismarine-chunk": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/prismarine-chunk/-/prismarine-chunk-1.39.0.tgz", - "integrity": "sha512-RJHACPV2T3ABGlj0+q/ZUDBLPcslWTIa12lWyE2jJb/svqpTBx8S/K6VjHxhyJ488L9ZWdEmOAr+CHMZJtwWHw==", + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/prismarine-chunk/-/prismarine-chunk-1.41.0.tgz", + "integrity": "sha512-KldSl3pDzPU7jcttxSYWNJ40rJMdiUxYvTpqXzqiQ3lIdNL1HAuJHPdscHcqlKJRB9Hw/cbFcROR1h5ncavY+Q==", "license": "MIT", "dependencies": { "prismarine-biome": "^1.2.0", @@ -784,16 +784,22 @@ } }, "node_modules/prismarine-physics": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/prismarine-physics/-/prismarine-physics-1.10.0.tgz", - "integrity": "sha512-FE2xUSDhrdgjlJFtBPMTQt1FX3uG2YvKceRvoMmhcCni0MrS8365ZlbIcW06SB1sKIpoNQWanS5LuefynzwdXQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/prismarine-physics/-/prismarine-physics-1.11.1.tgz", + "integrity": "sha512-GO9Pqqg5Khs0YxvPqRXYi3Ep3/OoxnL3RtfMknrgddY0ogpT91bTna6HDTQXVeBinfCB3WU9vXaFoTND/3xqyA==", "license": "MIT", "dependencies": { "minecraft-data": "^3.0.0", "prismarine-nbt": "^2.0.0", - "vec3": "^0.1.7" + "vec3": "^0.2.0" } }, + "node_modules/prismarine-physics/node_modules/vec3": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.2.0.tgz", + "integrity": "sha512-jOjU4zbCNWOKOGLHop1I0J2Ar4h5Zz76455LJhJKhiOJwI21QP6d5TnKklNZDKG+c5h71zWtPAbd9wh+Va57OQ==", + "license": "BSD" + }, "node_modules/prismarine-realms": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz", diff --git a/runner-package/package.json b/runner-package/package.json index 451c756..d69623b 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "js-yaml": "^4.1.0", - "mineflayer": "^4.0.0", + "mineflayer": "^4.39.0", "picocolors": "^1.1.1", "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" From c0578ca66eaec066dd0aefb7f84e4b63f830ed8a Mon Sep 17 00:00:00 2001 From: Drownek Date: Tue, 8 Sep 2026 13:16:19 +0200 Subject: [PATCH 114/125] docs: sync supported versions, CI example, and links from master --- README.md | 16 +++++++++++++--- docs/examples.mdx | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a2b189e..666f41e 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ The runner is published as @plugwright/runner from 3.0 onwards; + Explore the Java source code and TypeScript test specs to see how to implement robust E2E tests for your own plugins. From 03d8f035fb56867295b9c2289f8943ada9192167 Mon Sep 17 00:00:00 2001 From: Drownek Date: Tue, 8 Sep 2026 13:55:45 +0200 Subject: [PATCH 115/125] fix(gradle-plugin): redownload server.jar on Minecraft version change --- .../me/drownek/plugwright/PlugwrightExtension.kt | 2 +- .../drownek/plugwright/local/LocalEnvironmentSpec.kt | 2 +- .../drownek/plugwright/local/PaperProvisionTask.kt | 12 +++++++++--- .../drownek/plugwright/local/PlugwrightCleanTask.kt | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) 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 b6adc04..fa23912 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 @@ -99,7 +99,7 @@ abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperti @Deprecated("Use environments { create(\"local\", LocalMode) { cleanExcludePatterns.set(...) } }") override val cleanExcludePatterns: ListProperty = project.objects.listProperty(String::class.java).convention( - listOf("server.jar", "cache", "libraries") + listOf("server.jar", ".minecraft-version", "cache", "libraries") ) @Deprecated("Use environments { create(\"local\", LocalMode) { downloadPlugins { ... } } }") 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 index 69b9419..287ffdf 100644 --- 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 @@ -48,7 +48,7 @@ class LocalEnvironmentSpec(private val environmentName: String, objects: ObjectF /** 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") + listOf("server.jar", ".minecraft-version", "cache", "libraries") ) /** When true, the plugin under test is not built or installed automatically. */ diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt index 780a896..c566557 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt @@ -192,9 +192,15 @@ abstract class PaperProvisionTask : DefaultTask() { // Download Paper server if needed val serverJarFile = File(runDirectory, "server.jar") - if (!serverJarFile.exists()) { - logger.lifecycle("Server JAR not found. Downloading Paper server for Minecraft ${minecraftVersion.get()}...") - downloadPaperServer(minecraftVersion.get(), serverJarFile) + val versionMarkerFile = File(runDirectory, ".minecraft-version") + val requestedVersion = minecraftVersion.get() + val currentVersion = if (versionMarkerFile.exists()) versionMarkerFile.readText().trim() else null + + if (!serverJarFile.exists() || currentVersion != requestedVersion) { + val reason = if (!serverJarFile.exists()) "not found" else "version mismatch (found $currentVersion, requested $requestedVersion)" + logger.lifecycle("Server JAR $reason. Downloading Paper server for Minecraft $requestedVersion...") + downloadPaperServer(requestedVersion, serverJarFile) + versionMarkerFile.writeText(requestedVersion) } } 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 index 876f476..376129d 100644 --- 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 @@ -38,7 +38,7 @@ abstract class PlugwrightCleanTask : DefaultTask() { val keptFiles = mutableListOf() allEntries.forEach { entry -> - val shouldExclude = excludePatterns.any { pattern -> entry.name == pattern } + val shouldExclude = entry.name == ".minecraft-version" || excludePatterns.any { pattern -> entry.name == pattern } if (!shouldExclude) { deletedFiles.add(entry.name) project.delete(entry) From a48636b8ee5ec3bbac6a8acee73917b21da7adb6 Mon Sep 17 00:00:00 2001 From: Drownek Date: Tue, 8 Sep 2026 14:06:35 +0200 Subject: [PATCH 116/125] ci: add workflow_dispatch trigger with selectable Minecraft and Java versions --- .github/workflows/ci.yml | 33 ++++++++++++++++++++++++++++++--- example_plugin/build.gradle.kts | 8 +++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bed303..6908792 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,29 @@ on: - 'docs/**' - 'docs.json' - '**/*.md' + workflow_dispatch: + inputs: + mc_version: + description: 'Minecraft version to test' + required: true + default: '1.21.11' + type: choice + options: + - '1.21.11' + - '26.1.2' + java_version: + description: 'Java version' + required: true + default: '21' + type: choice + options: + - '21' + - '26' + run_stand: + description: 'Run external stand server tests' + required: true + default: true + type: boolean jobs: test-example-plugin: @@ -20,6 +43,8 @@ jobs: runs-on: ubuntu-latest env: GRADLE_OPTS: "-Dorg.gradle.daemon=false" + MC_VERSION: ${{ inputs.mc_version || '1.21.11' }} + JAVA_VERSION: ${{ inputs.java_version || '21' }} steps: - uses: actions/checkout@v4 @@ -31,15 +56,17 @@ jobs: npm run build:packages - uses: drownek/plugwright-action@v1 with: - java-version: "21" + java-version: ${{ inputs.java_version || '21' }} node-version: "24" working-directory: "./example_plugin" test-example-plugin-stand: - if: "!contains(github.event.head_commit.message, 'skip-ci')" + if: "!contains(github.event.head_commit.message, 'skip-ci') && (inputs.run_stand == null || inputs.run_stand == true)" runs-on: ubuntu-latest env: GRADLE_OPTS: "-Dorg.gradle.daemon=false" + MC_VERSION: ${{ inputs.mc_version || '1.21.11' }} + JAVA_VERSION: ${{ inputs.java_version || '21' }} # Test-only credentials for the Paper server this job starts and tears down itself. PLUGWRIGHT_RCON_PASSWORD: plugwright PLUGWRIGHT_BOT_PASSWORD: plugwright @@ -61,7 +88,7 @@ jobs: # to already have what npm(...) plugin refs and console { rcon {} } need. - uses: drownek/plugwright-action@v1 with: - java-version: "21" + java-version: ${{ inputs.java_version || '21' }} node-version: "24" working-directory: "./example_plugin" gradle-args: plugwrightProvisionLocal plugwrightCompileTests diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index 14205ba..ed41333 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -18,6 +18,8 @@ val localBotPassword = "plugwright" // channel that connects back to it. The literal is the fallback for a server started without // the variable set; the console channel reads the variable itself, at run time. val standRconPassword: String = providers.environmentVariable("PLUGWRIGHT_RCON_PASSWORD").getOrElse("plugwright") +val mcVersion: String = providers.environmentVariable("MC_VERSION").getOrElse("1.21.11") +val javaVersion: Int = providers.environmentVariable("JAVA_VERSION").map { it.toInt() }.getOrElse(21) plugwright { testsDir.set(file("src/test/e2e")) @@ -27,7 +29,7 @@ plugwright { environments { // Paper downloaded, patched, started and killed by plugwright itself. create("local", LocalMode) { - minecraftVersion.set("1.21.11") + minecraftVersion.set(mcVersion) acceptEula.set(true) // No runDir: the server goes to src/test/e2e/generated/local/run, which is where // the layout puts what an environment generates. @@ -100,7 +102,7 @@ plugwright { create("stand", ExternalMode) { host.set("localhost") port.set(25565) - minecraftVersion.set("1.21.11") + minecraftVersion.set(mcVersion) includeInMatrix.set(false) joinThrottleMs.set(500) @@ -207,6 +209,6 @@ tasks.withType { java { toolchain { - languageVersion.set(JavaLanguageVersion.of(21)) + languageVersion.set(JavaLanguageVersion.of(javaVersion)) } } From b9d2758dc82d48a6d64529f9eab26891df55d3e1 Mon Sep 17 00:00:00 2001 From: Drownek Date: Tue, 8 Sep 2026 15:49:30 +0200 Subject: [PATCH 117/125] feat(runner): log bot name on chat and harden RCON socket lifecycle --- runner-package/lib/player.ts | 4 +++- runner-package/lib/rcon/connection.ts | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 2376ea4..7cb3300 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -222,7 +222,9 @@ export class PlayerWrapper { (text, secret) => (secret ? text.split(secret).join('[REDACTED]') : text), message, ); - console.log(`${pc.cyan('[Bot]')} ${pc.dim(`Chatting: ${logged}`)}`); + const name = this.bot?.username ?? this.username; + const tag = name ? `[Bot ${name}]` : '[Bot]'; + console.log(`${pc.cyan(tag)} ${pc.dim(`Chatting: ${logged}`)}`); this.bot.chat(message); } diff --git a/runner-package/lib/rcon/connection.ts b/runner-package/lib/rcon/connection.ts index 6ae7600..f4ea1cc 100644 --- a/runner-package/lib/rcon/connection.ts +++ b/runner-package/lib/rcon/connection.ts @@ -33,7 +33,9 @@ export class RconConnection { const socket = createConnection({ host: this.host, port: this.port }); this.socket = socket; + let hasConnected = false; socket.once('connect', () => { + hasConnected = true; socket.setNoDelay(true); this.pendingAuth = { resolve: () => resolve(), @@ -45,14 +47,23 @@ export class RconConnection { socket.on('data', (chunk) => this.onData(chunk)); - socket.once('error', (err) => { + socket.on('error', (err) => { this.connectPromise = null; - reject(err); + if (!hasConnected) { + reject(err); + } + if (this.pendingAuth) { + this.pendingAuth.reject(err); + this.pendingAuth = null; + } + for (const waiter of this.pending.values()) waiter.reject(err); + this.pending.clear(); }); socket.once('close', () => { this.connectPromise = null; this.socket = null; + this.inbound = Buffer.alloc(0); const closedError = new Error('RCON connection closed'); this.pendingAuth?.reject(closedError); this.pendingAuth = null; @@ -85,6 +96,7 @@ export class RconConnection { this.socket?.destroy(); this.socket = null; this.connectPromise = null; + this.inbound = Buffer.alloc(0); waiter.reject(new Error('RCON authentication failed: wrong password')); } else { waiter.resolve(''); @@ -139,9 +151,10 @@ export class RconConnection { disconnect(): void { if (this.socket) { - this.socket.end(); + this.socket.destroy(); this.socket = null; } this.connectPromise = null; + this.inbound = Buffer.alloc(0); } } From e4bbe181e23e06c217fdd5d57390ae6dd1ad5419 Mon Sep 17 00:00:00 2001 From: Drownek Date: Tue, 8 Sep 2026 16:54:43 +0200 Subject: [PATCH 118/125] style(runner): apply unified cyan prefix to bot log outputs --- runner-package/lib/player.ts | 4 ++-- runner-package/lib/test-runner.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 7cb3300..87ffbb3 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -76,7 +76,7 @@ export class PlayerWrapper { const onSpawn = () => { cleanup(); - console.log(`${pc.cyan('[Bot]')} ${pc.dim(`${name()} spawned successfully`)}`); + console.log(`${pc.cyan(`[Bot ${name()}]`)} Spawned successfully`); resolve(); }; @@ -177,7 +177,7 @@ export class PlayerWrapper { bot.on('message', (jsonMsg: unknown) => { const message = String(jsonMsg); - console.log(pc.dim(`[Bot ${botUsername()}] Received message: "${message}"`)); + console.log(`${pc.cyan(`[Bot ${botUsername()}]`)} ${pc.dim(`Received message: "${message}"`)}`); this.messageBuffer.push(message); }); diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index fcacaff..050d750 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -93,7 +93,7 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo try { const botUsername = account.username; - console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(botUsername)}${formatInstanceTag(instance)}`); + console.log(`${pc.cyan(`[Bot ${botUsername}]`)} Creating bot...${formatInstanceTag(instance)}`); await session.env.beforeJoin?.(); From 4ad5d199a7221c10bc93ed3f062b8a51ea250f93 Mon Sep 17 00:00:00 2001 From: Drownek Date: Tue, 8 Sep 2026 17:03:43 +0200 Subject: [PATCH 119/125] style(runner): unify remaining bot log prefixes with cyan [Bot username] --- runner-package/lib/player.ts | 8 ++++---- runner-package/lib/session.ts | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 87ffbb3..7a907ce 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -82,13 +82,13 @@ export class PlayerWrapper { const onError = (err: Error) => { cleanup(); - console.log(pc.red(`[Bot] ${name()} connection error: ${err.message}`)); + console.log(`${pc.cyan(`[Bot ${name()}]`)} ${pc.red(`Connection error: ${err.message}`)}`); reject(err); }; const onKicked = (reason: string) => { cleanup(); - console.log(pc.red(`[Bot] ${name()} kicked: ${reason}`)); + console.log(`${pc.cyan(`[Bot ${name()}]`)} ${pc.red(`Kicked: ${reason}`)}`); reject(new Error(`Bot ${name()} was kicked: ${reason}`)); }; @@ -184,13 +184,13 @@ export class PlayerWrapper { bot.on('windowOpen', (window: unknown) => { if (process.env.PLUGWRIGHT_DEBUG !== '1') return; const win = window as { title?: string; type?: string | number; slots?: unknown[] }; - console.log(pc.gray(`[DEBUG] [Bot ${botUsername()}] Global windowOpen event - Title: "${win.title}", Type: ${win.type}, SlotCount: ${win.slots?.length}`)); + console.log(`${pc.gray('[DEBUG]')} ${pc.cyan(`[Bot ${botUsername()}]`)} ${pc.gray(`Global windowOpen event - Title: "${win.title}", Type: ${win.type}, SlotCount: ${win.slots?.length}`)}`); }); bot.on('windowClose', (window: unknown) => { if (process.env.PLUGWRIGHT_DEBUG !== '1') return; const win = window as { title?: string }; - console.log(pc.gray(`[DEBUG] [Bot ${botUsername()}] windowClose event - Window: ${win?.title || 'unknown'}`)); + console.log(`${pc.gray('[DEBUG]')} ${pc.cyan(`[Bot ${botUsername()}]`)} ${pc.gray(`windowClose event - Window: ${win?.title || 'unknown'}`)}`); }); } diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index 51c00d0..91679f6 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -104,13 +104,13 @@ export class Session { errorCount++; const now = Date.now(); if (now - lastLoggedAt > 1000) { - console.log(pc.dim(`[Bot] ${options.username} error (${errorCount} so far): ${err.message}`)); + console.log(`${pc.cyan(`[Bot ${options.username}]`)} ${pc.dim(`Error (${errorCount} so far): ${err.message}`)}`); lastLoggedAt = now; } }); bot.once('end', (reason: string) => { - console.log(pc.dim(`[Bot] ${options.username} connection ended: ${reason}`)); + console.log(`${pc.cyan(`[Bot ${options.username}]`)} ${pc.dim(`Connection ended: ${reason}`)}`); }); return bot; @@ -135,7 +135,7 @@ export class Session { try { bot.removeAllListeners(); } catch (err) { - console.log(pc.dim(`[Bot] ${label} warning: failed to remove listeners: ${(err as Error).message}`)); + console.log(`${pc.cyan(`[Bot ${label}]`)} ${pc.dim(`Warning: failed to remove listeners: ${(err as Error).message}`)}`); } }; @@ -147,7 +147,7 @@ export class Session { return new Promise((resolve) => { const timeout = setTimeout(() => { - console.log(pc.dim(`[Bot] ${label} disconnect timeout, continuing`)); + console.log(`${pc.cyan(`[Bot ${label}]`)} ${pc.dim('Disconnect timeout, continuing')}`); cleanupListeners(); resolve(); }, timeoutMs); @@ -160,7 +160,7 @@ export class Session { }); bot.quit(); } catch (err) { - console.log(pc.dim(`[Bot] ${label} error during disconnect: ${(err as Error).message}`)); + console.log(`${pc.cyan(`[Bot ${label}]`)} ${pc.dim(`Error during disconnect: ${(err as Error).message}`)}`); clearTimeout(timeout); cleanupListeners(); resolve(); From 2392986f4f0997208ea0ff4ca6277244f3b90e68 Mon Sep 17 00:00:00 2001 From: Drownek Date: Tue, 8 Sep 2026 19:32:59 +0200 Subject: [PATCH 120/125] test(e2e): set creative mode in multi-bot teleportation test to prevent fall damage --- example_plugin/src/test/e2e/tests/multi-bot.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example_plugin/src/test/e2e/tests/multi-bot.spec.ts b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts index 4cda208..0c37a02 100644 --- a/example_plugin/src/test/e2e/tests/multi-bot.spec.ts +++ b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts @@ -9,10 +9,12 @@ test('multi-bot teleportation', async ({ player, createPlayer }) => { // so when await completes, we are sure player is op. // This can be also done with defining test as `opTest` instead of `test` or even within `beforeEach` block. await player.makeOp(); + await player.setGameMode('creative'); // Spawn a second player. No username: the test needs a second bot, not a specific one, // so on a stand this leases the next free pool account instead of bypassing the pool. const friend = await createPlayer(); + await friend.setGameMode('creative'); // Teleport the friend to a specific location // We wait for friend player to actually teleport. From bfe6eb6cfc2aef38e6c34a4669732ecf277b4534 Mon Sep 17 00:00:00 2001 From: Drownek Date: Wed, 9 Sep 2026 09:59:49 +0200 Subject: [PATCH 121/125] refactor(runner): clean up console types, return promise in rcon execute, and standardize item accessors --- docs/api-reference.mdx | 2 +- docs/core-concepts.mdx | 2 +- docs/external-servers.mdx | 2 +- docs/gui-testing.mdx | 8 +-- example_plugin/src/test/e2e/package-lock.json | 2 +- .../src/test/e2e/tests/multi-bot.spec.ts | 2 +- .../src/test/e2e/tests/pagination.spec.ts | 4 +- .../src/test/e2e/tests/simple-ts.spec.ts | 2 +- .../src/test/e2e/tests/teleport.spec.ts | 2 +- runner-package/lib/console.ts | 2 - runner-package/lib/environments/external.ts | 3 +- runner-package/lib/environments/local.ts | 48 +++++++-------- runner-package/lib/player.ts | 3 +- runner-package/lib/rcon/connection.ts | 38 +++++++++--- runner-package/lib/rcon/index.ts | 1 - runner-package/lib/test-registry.ts | 10 ---- runner-package/lib/wrappers.ts | 58 ++++++++++++++----- runner-package/runner.ts | 23 +++++--- 18 files changed, 130 insertions(+), 82 deletions(-) diff --git a/docs/api-reference.mdx b/docs/api-reference.mdx index aef80dd..23ccd63 100644 --- a/docs/api-reference.mdx +++ b/docs/api-reference.mdx @@ -31,7 +31,7 @@ Waits for a GUI to open matching the title and returns a live handle. ```javascript const gui = await player.gui({ title: 'Shop' }); -const button = gui.locator(i => i.getDisplayName().includes('Confirm')); +const button = gui.locator(i => i.displayName.includes('Confirm')); ``` ### `player.makeOp()` diff --git a/docs/core-concepts.mdx b/docs/core-concepts.mdx index 26aef18..42ce2e1 100644 --- a/docs/core-concepts.mdx +++ b/docs/core-concepts.mdx @@ -27,7 +27,7 @@ test('Click an item in GUI', async ({ player }) => { const gui = await player.gui({ title: 'Menu' }); // Find an item by its internal name or display name - const shopItem = gui.locator(i => i.getDisplayName().includes('Shop')); + const shopItem = gui.locator(i => i.displayName.includes('Shop')); await shopItem.click(); await expect(player).toHaveReceivedMessage('You opened the shop!'); diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index 0ea82b9..e5d7a54 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -55,7 +55,7 @@ Channels are probed in declaration order, and the first one that answers becomes | Channel | Output level | Notes | |---|---|---| | `rcon { }` | `responses` | Needs `enable-rcon=true` on the server | -| stdio | `full` | `LocalMode` only — Plugwright owns the process | +| `LocalMode` | `full` | Built into LocalMode — commands run via RCON while full server logs are captured directly from stdout | The output level matters more than it looks. `full` means the whole server log is readable, so `expect(server).toHaveReceivedMessage(...)` works. `responses` means you get back what the command printed and nothing else. A test that reads the server log should say so: diff --git a/docs/gui-testing.mdx b/docs/gui-testing.mdx index 8b8998c..1b938d7 100644 --- a/docs/gui-testing.mdx +++ b/docs/gui-testing.mdx @@ -88,7 +88,7 @@ Creates a locator for items matching the predicate. const compass = gui.locator(i => i.name === 'compass'); // By display name -const itemByName = gui.locator(i => i.getDisplayName().includes('Session')); +const itemByName = gui.locator(i => i.displayName.includes('Session')); // By lore const itemByLore = gui.locator(i => i.hasLore('Click to view')); @@ -198,7 +198,7 @@ test('clicking GUI item triggers callback', async ({ player }) => { player.chat('/example gui-settings'); const gui = await player.gui({ title: 'guiSettings' }); - const item = gui.locator(item => item.getDisplayName().includes('guiItemInfo')); + const item = gui.locator(item => item.displayName.includes('guiItemInfo')); await item.click(); await expect(player).toHaveReceivedMessage('You clicked on item'); @@ -232,7 +232,7 @@ test('navigate through pages', async ({ player }) => { const gui = await player.gui({ title: 'Warps' }); // Check first page - const firstItem = gui.locator(i => i.getDisplayName().includes('Spawn')); + const firstItem = gui.locator(i => i.displayName.includes('Spawn')); await firstItem.click(); // Reopen and check next page @@ -240,7 +240,7 @@ test('navigate through pages', async ({ player }) => { const nextButton = gui.locator(i => i.name === 'arrow'); await nextButton.click(); - const secondItem = gui.locator(i => i.getDisplayName().includes('Arena')); + const secondItem = gui.locator(i => i.displayName.includes('Arena')); await expect.poll(() => secondItem.displayName()).toContain('Arena'); }); ``` diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index 1e5dd95..784eb99 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -37,7 +37,7 @@ "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", - "mineflayer": "^4.0.0", + "mineflayer": "^4.39.0", "picocolors": "^1.1.1", "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" diff --git a/example_plugin/src/test/e2e/tests/multi-bot.spec.ts b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts index 0c37a02..70b3838 100644 --- a/example_plugin/src/test/e2e/tests/multi-bot.spec.ts +++ b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts @@ -7,7 +7,7 @@ import { expect, test } from '@plugwright/runner'; test('multi-bot teleportation', async ({ player, createPlayer }) => { // This executes op server command, and we wait for response from server // so when await completes, we are sure player is op. - // This can be also done with defining test as `opTest` instead of `test` or even within `beforeEach` block. + // This can be also done within a `beforeEach` block. await player.makeOp(); await player.setGameMode('creative'); diff --git a/example_plugin/src/test/e2e/tests/pagination.spec.ts b/example_plugin/src/test/e2e/tests/pagination.spec.ts index 69f9c46..0114a2f 100644 --- a/example_plugin/src/test/e2e/tests/pagination.spec.ts +++ b/example_plugin/src/test/e2e/tests/pagination.spec.ts @@ -6,7 +6,7 @@ test('navigate through paginated GUI', async ({ player }) => { const gui = await player.gui({ title: 'Warps' }); // verify page 1 - const spawnItem = gui.locator(i => i.getDisplayName().includes('Spawn')); + const spawnItem = gui.locator(i => i.displayName.includes('Spawn')); await expect.poll(() => spawnItem.displayName()).toContain('Spawn'); // click arrow @@ -14,7 +14,7 @@ test('navigate through paginated GUI', async ({ player }) => { await nextButton.click(); // verify page 2 without reopening the GUI - const arenaItem = gui.locator(i => i.getDisplayName().includes('Arena')); + const arenaItem = gui.locator(i => i.displayName.includes('Arena')); // This expects the item to eventually appear on the same GUI instance await expect.poll(() => arenaItem.displayName()).toContain('Arena'); diff --git a/example_plugin/src/test/e2e/tests/simple-ts.spec.ts b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts index 068a8a3..4b1fcfd 100644 --- a/example_plugin/src/test/e2e/tests/simple-ts.spec.ts +++ b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts @@ -14,7 +14,7 @@ test('admin can interact with gui', async ({ player }) => { const gui = await player.gui({ title: 'guiSettings' }); // 3. Interact: Click the item named "guiItemInfo" - await gui.locator(item => item.getDisplayName().includes('guiItemInfo')).click(); + await gui.locator(item => item.displayName.includes('guiItemInfo')).click(); // 4. Assertion: Check for the callback message await expect(player).toHaveReceivedMessage('You clicked on item'); diff --git a/example_plugin/src/test/e2e/tests/teleport.spec.ts b/example_plugin/src/test/e2e/tests/teleport.spec.ts index 8198dfa..c54ce71 100644 --- a/example_plugin/src/test/e2e/tests/teleport.spec.ts +++ b/example_plugin/src/test/e2e/tests/teleport.spec.ts @@ -19,7 +19,7 @@ test('warp GUI lists available warps', async ({ player }) => { const gui = await player.gui({ title: 'Warps' }); const spawn = gui.locator(item => - item.getDisplayName().includes('Spawn') + item.displayName.includes('Spawn') ); await expect.poll(() => spawn.displayName()).toContain('Spawn'); diff --git a/runner-package/lib/console.ts b/runner-package/lib/console.ts index c4efab7..3b5db50 100644 --- a/runner-package/lib/console.ts +++ b/runner-package/lib/console.ts @@ -1,9 +1,7 @@ /** * A channel for sending admin commands to the server and reading its output. - * e.g., RCON or stdio. */ export interface ServerConsole { - readonly kind: 'stdio' | 'rcon'; /** 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'; diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts index 7d411e3..4c617d4 100644 --- a/runner-package/lib/environments/external.ts +++ b/runner-package/lib/environments/external.ts @@ -12,7 +12,6 @@ import { rconConsole } from '../rcon/index.js'; export interface ExternalConsoleChannelConfig { kind: 'rcon'; port?: number; - username?: string; password?: SecretRef; } @@ -86,7 +85,7 @@ class ExternalEnvironment implements Environment { }; console.log(this._console - ? pc.green(`[external] console channel: ${this._console.kind} (output=${this._console.output})`) + ? pc.green(`[external] console channel reachable (output=${this._console.output})`) : pc.dim('[external] no console channel reachable, running without one')); } diff --git a/runner-package/lib/environments/local.ts b/runner-package/lib/environments/local.ts index 4ec8c0c..0bcf489 100644 --- a/runner-package/lib/environments/local.ts +++ b/runner-package/lib/environments/local.ts @@ -54,13 +54,15 @@ export class LocalEnvironment implements Environment { this.serverProcess = serverProcess; this._installProcessGuards(serverProcess); - await this._waitForServerStart(serverProcess); - console.log(`${pc.green(pc.bold('Server started successfully'))}\n`); - - // stdout/stderr continue to feed the full console log — this is what makes + // stdout/stderr continuously feed the full console log — this is what makes // `consoleOutput: 'full'` true and `expect(server).toHaveReceivedMessage` work. serverProcess.stdout.on('data', (data: Buffer) => session.writeConsoleOutput(data)); serverProcess.stderr.on('data', (data: Buffer) => session.writeConsoleOutput(data)); + // Ignore EPIPE if server process terminates before/during teardown stdin writes. + serverProcess.stdin.on('error', () => { /* ignore */ }); + + await this._waitForServerStart(serverProcess); + console.log(`${pc.green(pc.bold('Server started successfully'))}\n`); // Connect to the local server's RCON for sending commands. RCON gives a proper // synchronous response per command, unlike the old stdin `/say ` trick. @@ -154,42 +156,42 @@ export class LocalEnvironment implements Environment { } private _waitForServerStart(serverProcess: ChildProcessWithoutNullStreams): Promise { - const session = this.session!; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { + cleanup(); 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); + cleanup(); 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); + const errorHandler = (err: Error): void => { + cleanup(); reject(new Error(`Failed to start server: ${err.message}`)); - }); + }; - serverProcess.on('exit', (code: number | null) => { + const exitHandler = (code: number | null): void => { if (code !== null && code !== 0) { - clearTimeout(timeout); + cleanup(); reject(new Error(`Server exited with code ${code} before becoming ready`)); } - }); + }; + + const cleanup = (): void => { + clearTimeout(timeout); + serverProcess.stdout.removeListener('data', dataHandler); + serverProcess.removeListener('error', errorHandler); + serverProcess.removeListener('exit', exitHandler); + }; + + serverProcess.stdout.on('data', dataHandler); + serverProcess.on('error', errorHandler); + serverProcess.on('exit', exitHandler); }); } diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 7a907ce..7dfa548 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -289,7 +289,8 @@ export class PlayerWrapper { await poll( () => { - const pos = this.bot.entity.position; + const pos = this.bot.entity?.position; + if (!pos) return undefined; const close = Math.abs(pos.x - x) < 1 && Math.abs(pos.y - y) < 1 && diff --git a/runner-package/lib/rcon/connection.ts b/runner-package/lib/rcon/connection.ts index f4ea1cc..d6456f8 100644 --- a/runner-package/lib/rcon/connection.ts +++ b/runner-package/lib/rcon/connection.ts @@ -34,7 +34,14 @@ export class RconConnection { this.socket = socket; let hasConnected = false; + const connectTimer = setTimeout(() => { + if (!hasConnected) { + socket.destroy(new Error(`RCON connection to ${this.host}:${this.port} timed out after 10000ms`)); + } + }, 10000); + socket.once('connect', () => { + clearTimeout(connectTimer); hasConnected = true; socket.setNoDelay(true); this.pendingAuth = { @@ -48,7 +55,10 @@ export class RconConnection { socket.on('data', (chunk) => this.onData(chunk)); socket.on('error', (err) => { - this.connectPromise = null; + clearTimeout(connectTimer); + if (this.socket === socket) { + this.connectPromise = null; + } if (!hasConnected) { reject(err); } @@ -61,9 +71,12 @@ export class RconConnection { }); socket.once('close', () => { - this.connectPromise = null; - this.socket = null; - this.inbound = Buffer.alloc(0); + clearTimeout(connectTimer); + if (this.socket === socket) { + this.connectPromise = null; + this.socket = null; + this.inbound = Buffer.alloc(0); + } const closedError = new Error('RCON connection closed'); this.pendingAuth?.reject(closedError); this.pendingAuth = null; @@ -80,11 +93,20 @@ export class RconConnection { while (this.inbound.length >= 4) { const size = this.inbound.readInt32LE(0); + if (size < 10 || size > 1024 * 1024) { + // Invalid packet size: minimum RCON packet size is 10 (4 id + 4 type + 1 body null + 1 pad null). + this.inbound = Buffer.alloc(0); + break; + } if (this.inbound.length < 4 + size) break; const body = this.inbound.subarray(4, 4 + size); this.inbound = this.inbound.subarray(4 + size); - this.handlePacket(decodePacketBody(body)); + try { + this.handlePacket(decodePacketBody(body)); + } catch (err) { + console.error(`[rcon] Failed to decode packet: ${(err as Error).message}`); + } } } @@ -143,10 +165,8 @@ export class RconConnection { }); } - execute(cmd: string): void { - this.executeAndWait(cmd, 5000).catch((error: Error) => { - console.error(`[rcon] command failed: ${cmd}: ${error.message}`); - }); + execute(cmd: string, timeoutMs: number = 5000): Promise { + return this.executeAndWait(cmd, timeoutMs); } disconnect(): void { diff --git a/runner-package/lib/rcon/index.ts b/runner-package/lib/rcon/index.ts index b2b2da9..365cb28 100644 --- a/runner-package/lib/rcon/index.ts +++ b/runner-package/lib/rcon/index.ts @@ -14,7 +14,6 @@ export function rconConsole(config: RconConsoleConfig): ServerConsole { const connection = new RconConnection(config.host, config.port, config.password); return { - kind: 'rcon', output: 'responses', async probe(): Promise { diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 7559266..e124c14 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -136,16 +136,6 @@ export function test(name: string, fnOrOptions: TestFn | TestOptions, 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); - }); -} function describeImpl(label: string, fn: () => void): void { scopeStack.push({ label, beforeHooks: [], afterHooks: [] }); diff --git a/runner-package/lib/wrappers.ts b/runner-package/lib/wrappers.ts index c33091a..3a066b3 100644 --- a/runner-package/lib/wrappers.ts +++ b/runner-package/lib/wrappers.ts @@ -37,23 +37,47 @@ export class GuiItemLocator { } /** - * Gets the lore text of the located item. + * Gets the display name of the located item. * Re-queries the GUI each time it's called. */ - loreText(): string { + displayName(): string { const item = this._tryFind(); if (!item) return ''; - return item.getLore().join(' '); + return item.displayName; } /** - * Gets the display name of the located item. + * Alias for `displayName()`. + */ + getDisplayName(): string { + return this.displayName(); + } + + /** + * Gets the lore lines of the located item. * Re-queries the GUI each time it's called. */ - displayName(): string { + lore(): string[] { + const item = this._tryFind(); + if (!item) return []; + return item.lore; + } + + /** + * Alias for `lore()`. + */ + getLore(): string[] { + return this.lore(); + } + + /** + * Gets the lore text of the located item (joined by space). + * Re-queries the GUI each time it's called. + */ + loreText(): string { const item = this._tryFind(); if (!item) return ''; - return item.getDisplayName(); + return item.lore.join(' '); } /** @@ -89,8 +113,8 @@ export class GuiItemLocator { const rows = items.map(item => ({ slot: item.slot, name: item.name, - displayName: item.getDisplayName(), - lore: item.getLore().join(' | ') + displayName: item.displayName, + lore: item.lore.join(' | ') })); if (rows.length === 0) { @@ -277,6 +301,14 @@ export class ItemWrapper { return String(raw); } + get displayName(): string { + return this.getDisplayName(); + } + + get lore(): string[] { + return this.getLore(); + } + getDisplayName(): string { const components = (this.raw as any).components; if (Array.isArray(components)) { @@ -378,8 +410,8 @@ export class GuiWrapper { throw new Error(`[GUI] Failed to click: Item not found matching criteria in "${this.title}"`); } - const lore = item.getLore(); - console.log(`[GUI] Clicking item: ${item.getDisplayName()}`); + const lore = item.lore; + console.log(`[GUI] Clicking item: ${item.displayName}`); console.log(` Material: ${item.name}`); console.log(` Slot: ${item.slot}`); if (lore.length > 0) { @@ -451,7 +483,7 @@ export function createPlayerExtensions(bot: Bot) { const matchedItem = items.find(itemMatcher); if (matchedItem) { - console.log(`[Player] Found GUI item: ${matchedItem.getDisplayName()} at slot ${matchedItem.slot}`); + console.log(`[Player] Found GUI item: ${matchedItem.displayName} at slot ${matchedItem.slot}`); return matchedItem; } } @@ -483,8 +515,8 @@ export function createPlayerExtensions(bot: Bot) { const matchedItem = items.find(itemMatcher); if (matchedItem) { - const lore = matchedItem.getLore(); - console.log(`[Player] Clicking GUI item: ${matchedItem.getDisplayName()}`); + const lore = matchedItem.lore; + console.log(`[Player] Clicking GUI item: ${matchedItem.displayName}`); console.log(` Material: ${matchedItem.name}`); console.log(` Slot: ${matchedItem.slot}`); if (lore.length > 0) { diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 1a51ac7..7a09452 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -29,7 +29,7 @@ installSourceMapSupport(); export { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator }; export { PlayerWrapper }; export { ServerWrapper } from './lib/server.js'; -export { test, opTest, describe, beforeEach, afterEach } from './lib/test-registry.js'; +export { test, describe, beforeEach, afterEach } from './lib/test-registry.js'; export type { TestOptions, TestCase, SerialOptions, SerialBlock, RequiresMap } from './lib/test-registry.js'; export { expect } from './lib/matchers.js'; export { loadRunnerConfig, resolveSecret, isSecretRef } from './lib/config.js'; @@ -45,6 +45,8 @@ export { AccountPool } from './lib/account.js'; export type { Account, AccountsConfig } from './lib/account.js'; export { externalEnvironment }; export type { ExternalEnvironmentConfig, ExternalConsoleChannelConfig } from './lib/environments/external.js'; +export { rconConsole, RconConnection } from './lib/rcon/index.js'; +export type { RconConsoleConfig } from './lib/rcon/index.js'; /** * `local` and `external` are built into this package; anything else is a third-party mode, @@ -109,11 +111,11 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): let exitCode = 0; - await env.setup(session); - session.refreshConsole(); - await plugins.setup(session); - try { + await env.setup(session); + session.refreshConsole(); + await plugins.setup(session); + const connOpts = env.connection(); /** Why a test should not run, or null to run it. Checked in order: name exclude, @@ -337,7 +339,7 @@ export async function runPingSession(config: RunnerConfig = loadRunnerConfig()): await plugins.setup(session); if (env.capabilities.console) { - console.log(pc.green(`console: reachable (${session.console?.kind}, output=${session.console?.output})`)); + console.log(pc.green(`console: reachable (output=${session.console?.output})`)); } else { console.log(pc.yellow('console: unavailable')); problems.push('no console channel could be reached'); @@ -349,10 +351,15 @@ export async function runPingSession(config: RunnerConfig = loadRunnerConfig()): account = await pool.lease(); await env.beforeJoin?.(); const connOpts = env.connection(); - const bot = session.createBot({ ...connOpts, auth: account.auth, username: account.username }); + const botOptions = { + ...connOpts, + auth: account.auth, + profilesFolder: account.microsoftCacheDir, + }; + const bot = session.createBot({ ...botOptions, username: account.username }); const player = new PlayerWrapper(bot, session); player._captureSpawnPromise(); - player._setBotOptions({ ...connOpts, auth: account.auth }); + player._setBotOptions(botOptions); player._setAccount(account); await player.join(); console.log(pc.green(`auth: "${account.username}" connected and authenticated`)); From 5f3c04d60b91496b979f123224d40e00af94f66f Mon Sep 17 00:00:00 2001 From: Drownek Date: Thu, 10 Sep 2026 15:03:54 +0200 Subject: [PATCH 122/125] docs: add individual migration guide, expand api-reference --- README.md | 13 +- docs/api-reference.mdx | 62 ++++++++++ docs/docs.json | 1 + docs/migration-v3.mdx | 274 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 338 insertions(+), 12 deletions(-) create mode 100644 docs/migration-v3.mdx diff --git a/README.md b/README.md index 666f41e..097e97e 100644 --- a/README.md +++ b/README.md @@ -9,22 +9,11 @@ End-to-end testing framework for Paper/Spigot Minecraft plugins. Supports JavaSc ![Video showcase demonstrating Plugwright bots joining a server, moving, and interacting with GUIs](https://github.com/user-attachments/assets/0272a6d9-f9ab-4486-8bf3-ee5909a10ee9) -
-⚠️ Upgrading from Paperwright (v1.x)? Click here for migration steps. -
-This framework has been renamed from Paperwright to Plugwright. If you are upgrading from an older version, update the following: - -1. Change `id("io.github.drownek.paperwright")` to `id("io.github.drownek.plugwright")`. -2. Rename your `paperwright { ... }` configuration block to `plugwright { ... }` and Gradle tasks (e.g. `./gradlew paperwrightTest` to `./gradlew plugwrightTest`). -3. In your `package.json`, change `@drownek/paperwright` to `@plugwright/runner` and run `npm install`. -4. Update your test files: `import { test } from '@drownek/paperwright'` to `import { test } from '@plugwright/runner'`. -5. Change your CI to use `drownek/plugwright-action@v1`. -
-
## Features diff --git a/docs/api-reference.mdx b/docs/api-reference.mdx index 23ccd63..59f6961 100644 --- a/docs/api-reference.mdx +++ b/docs/api-reference.mdx @@ -3,6 +3,68 @@ title: "API Reference" description: "Detailed API documentation for advanced users." --- +## Test Runner API + +### `test(name, options?, fn)` + +Defines an individual test case. + + + The title/description of the test. + + + + Runs N independent instances of the test simultaneously with distinct leased accounts to detect race conditions. + + + + Environment capabilities required for this test to execute. + + + + List of environment names where this test is allowed to run. + + +```typescript +import { test, expect } from '@plugwright/runner'; + +test('race condition check', { concurrency: 3 }, async ({ player }) => { + player.chat('/claim'); + await expect(player).toHaveReceivedMessage(/Claimed|already claimed/); +}); +``` + +### `describe.serial(name, options?, fn)` + +Groups tests that execute sequentially while retaining the same player/bot session and leased account across all tests in the block. + + + The title/description of the serial block. + + + + Specific account name to lease from the environment's account pool. + + + + Runs N independent copies of the entire ordered serial chain at the same time. + + +```typescript +import { describe, test, expect } from '@plugwright/runner'; + +describe.serial('kit lifecycle', () => { + test('claim kit', async ({ player }) => { + player.chat('/kit starter'); + }); + + test('is on cooldown', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('cooldown'); + }); +}); +``` + ## `player` / `bot` Object The `player` or `bot` object represents a real Minecraft client connected to the test server. diff --git a/docs/docs.json b/docs/docs.json index ba45477..aa118e6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -20,6 +20,7 @@ "pages": [ "introduction", "quickstart", + "migration-v3", "project-layout", "configuration" ] diff --git a/docs/migration-v3.mdx b/docs/migration-v3.mdx new file mode 100644 index 0000000..8dae508 --- /dev/null +++ b/docs/migration-v3.mdx @@ -0,0 +1,274 @@ +--- +title: "Migration Guide (v2 to v3)" +description: "Migrate your Plugwright test suites and build configuration from v2 to v3." +--- + +Plugwright v3 introduces multi-environment execution, external server and staging stands support, runner plugins, concurrent bot testing, and an updated workspace layout. + +Migrating from v2 to v3 is straightforward, and the Gradle plugin handles most workspace structure changes automatically on the first run. + +--- + +## Step-by-Step Migration Walkthrough + +Here is the exact step-by-step path to upgrade a v2 project to v3: + +### 1. Update the Gradle Plugin Version +In your `build.gradle.kts`, bump the plugin version to `3.0.0` (or check for the latest `3.x` release): + +```kotlin +plugins { + // Before (v2) + // id("io.github.drownek.plugwright") version "2.0.4" + + // After (v3) + id("io.github.drownek.plugwright") version "3.0.0" // or the latest 3.x version +} +``` + +### 2. Update `package.json` and Install +In `src/test/e2e/package.json`, replace `@drownek/plugwright` with `@plugwright/runner` using version `^3.0.0` (or matching your Gradle plugin's 3.x version), then run `npm install`: + +```json +{ + "devDependencies": { + "@plugwright/runner": "^3.0.0" + } +} +``` + +### 3. Update Spec Imports +In your TypeScript test files, rename the package import: + +```typescript +// Before (v2) +import { test, expect } from '@drownek/plugwright'; + +// After (v3) +import { test, expect } from '@plugwright/runner'; +``` + +### 4. Run `gradlew plugwrightTest` +Run your test task: + +```bash +./gradlew plugwrightTest +``` + +On first run, Plugwright detects the legacy v2 layout and performs an automatic migration: +- Moves your `*.spec.ts` files from `src/test/e2e/` into `src/test/e2e/tests/` (preserving subdirectories). +- Updates `src/test/e2e/tsconfig.json` to include `"tests/**/*.ts"` and `"plugins/**/*.ts"`. +- Compiles the tests and runs the suite. + +```text +Moved 13 spec file(s) into .../src/test/e2e/tests — plugwright looks for specs under 'tests' now. +Updated .../src/test/e2e/tsconfig.json for the new layout +``` + +--- + +## Recommended Configuration Update + +While v3 retains compatibility with the old flat `plugwright { ... }` block, it is recommended to adopt the new `environments` syntax. Notice that server-specific settings (`minecraftVersion`, `acceptEula`, `downloadPlugins`) now belong inside `environments.create("local", LocalMode)`: + +```kotlin +// Before (v2 flat configuration) +plugwright { + minecraftVersion.set("26.1.2") + acceptEula.set(true) + testsDir.set(file("src/test/e2e")) + downloadPlugins { + url("https://hangarcdn.papermc.io/plugins/HelpChat/PlaceholderAPI/versions/2.11.6/PAPER/PlaceholderAPI-2.11.6.jar") + } + downloadNode.set(System.getenv("CI") != "true") +} +``` + +```kotlin +// After (v3 environments DSL) +import me.drownek.plugwright.local.LocalMode + +plugwright { + environments.create("local", LocalMode) { + minecraftVersion.set("26.1.2") + acceptEula.set(true) + downloadPlugins { + url("https://hangarcdn.papermc.io/plugins/HelpChat/PlaceholderAPI/versions/2.11.6/PAPER/PlaceholderAPI-2.11.6.jar") + } + } + testsDir.set(file("src/test/e2e")) + downloadNode.set(System.getenv("CI") != "true") +} +``` + + +Without `environments`, the flat properties define an implicit `local` environment. They are deprecated and slated for removal. + + +--- + +## API Adjustments & Modernizations + +### Awaiting `server.execute(...)` +In v3, `server.execute(...)` communicates with the console channel asynchronously and returns a `Promise`. +While unawaited calls will often still fire in the background (similar to v2 behavior), awaiting it is strongly recommended so you can catch errors or read command output reliably: + +```typescript +// Recommended in v3: +await server.execute(`give ${player.username} diamond 64`); +``` + +### GUI Item Display Name Property +Instead of invoking `item.getDisplayName()`, you can now use the clean property accessor `item.displayName`: + +```typescript +// Before (v2) +const spawn = gui.locator(item => + item.getDisplayName().includes('Spawn') +); + +// After (v3) +const spawn = gui.locator(item => + item.displayName.includes('Spawn') +); +``` + +### Built-in `player.clearInventory(...)` +Avoid manual command workarounds to reset a player's inventory. `player.clearInventory` clears the inventory and waits until client-side inventory state reflects it: + +```typescript +// Clear entire inventory +await player.clearInventory(); + +// Or clear specific item +await player.clearInventory('diamond'); +``` + +--- + +## Directory & Git Ignore Updates + +The runtime directories are now isolated per environment: +- **Server files**: Now live in `/generated//run/` (e.g. `src/test/e2e/generated/local/run/`). +- **Compiled specs**: Output to `src/test/e2e/dist/`. + +Make sure `src/test/e2e/.gitignore` contains: +```gitignore +node_modules +dist +generated +.npmrc +``` +You can safely remove root `run/` from your repository's top-level `.gitignore` if it's no longer used. + +--- + +## New Features Available in v3 + +Plugwright v3 brings major capabilities designed for real-world server environments, race condition detection, and complex gameplay flows: + +### 1. Stateful Multi-Step Tests: `describe.serial` +By default, every test gets a fresh player and an isolated connection. With `describe.serial`, a single player connection is maintained across all tests in the block. This makes it effortless to test lifecycles such as kit cooldowns, auction cycles, multi-step quests, and economy balances without cumbersome workarounds. + +```typescript +import { describe, test, expect, sleep } from '@plugwright/runner'; + +describe.serial('kit cooldown lifecycle', () => { + test('claims the starter kit', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Received starter kit'); + }); + + test('kit is immediately on cooldown', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Kit is on cooldown'); + }); + + test('can claim again after waiting', async ({ player }) => { + await sleep(5000); + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Received starter kit'); + }); +}); +``` + +You can also name retained secondary bots across steps using `createPlayer({ as: 'buyer' })`. [Read more in Writing Tests › describe.serial](/writing-tests#tests-that-share-a-player-describeserial). + +--- + +### 2. Race Condition Testing: `concurrency: N` +Catching bugs like item duping, chest snipe, or auction desync requires multiple players hitting the same logic simultaneously. Plugwright v3 introduces first-class concurrency at the test and serial block level: + +```typescript +test('only one player can loot the treasure chest', { concurrency: 5 }, async ({ player }) => { + player.chat('/lootchest claim'); + // Passes only if all 5 concurrent instances observe expected outcomes without server errors + await expect(player).toHaveReceivedMessage(/Claimed reward|Chest already looted/); +}); +``` + +Plugwright spins up N isolated runner instances and leases distinct accounts from the pool simultaneously. [Read more in Writing Tests › concurrency](/writing-tests#racing-bots-against-each-other-concurrency). + +--- + +### 3. Remote Stands & External Servers (`ExternalMode`) +In addition to spinning up ephemeral local Paper servers via `LocalMode`, v3 natively supports testing against remote staging servers, production mirrors, or persistent local stands using `ExternalMode`. + +- **Account Pools**: Safely leases and releases pre-configured test bot accounts. +- **RCON Console Channel**: Execute server commands and parse console responses via secure RCON. +- **Stand Reset & Ping Tasks**: Auto-generated `./gradlew Ping` and `./gradlew Clean` tasks. + +```kotlin +import me.drownek.plugwright.external.ExternalMode + +plugwright { + environments.create("stand", ExternalMode) { + server { + host.set("staging.myserver.net") + port.set(25565) + } + rcon { + port.set(25575) + password.set(System.getenv("STAND_RCON_PASSWORD")) + } + accounts { + account("bot_1", System.getenv("BOT1_PASSWORD")) + account("bot_2", System.getenv("BOT2_PASSWORD")) + } + } +} +``` +[Read more in External Servers](/external-servers). + +--- + +### 4. Runner Plugins & Authentication (e.g. AuthMe) +Runner plugins extend test execution with custom hooks, fixtures, matchers, and auth adapters. Plugwright v3 provides first-party packages like `@plugwright/auth-authme` (handling login/register dialogs, session resumption, and password secrecy). + +Plugins can be declared directly in your Gradle environment configuration: +```kotlin +environments.create("stand", ExternalMode) { + plugins { + plugin("@plugwright/auth-authme") { + config.set(mapOf("registerCommand" to "/register", "loginCommand" to "/login")) + } + } +} +``` +[Read more in Runner Plugins](/plugins). + +--- + +### 5. Private npm Registries +If your organization distributes internal matchers, runner plugins, or fixtures via private npm registries, declare them right in your `build.gradle.kts`: + +```kotlin +plugwright { + npm { + registry("@myorg", "https://npm.pkg.github.com") { + authToken.set(System.getenv("GITHUB_TOKEN")) + } + } +} +``` +Plugwright generates the appropriate `.npmrc` scoped configuration automatically before installing test dependencies. [Read more in Configuration](/configuration#npm-registries). From 586482375d204171bbcd0d2cb3b4991de38490ff Mon Sep 17 00:00:00 2001 From: Drownek Date: Thu, 10 Sep 2026 15:25:23 +0200 Subject: [PATCH 123/125] docs: update quickstart to use environments --- README.md | 26 +++++++++++++++++--------- docs/quickstart.mdx | 23 ++++++++++++++--------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 097e97e..06c9d50 100644 --- a/README.md +++ b/README.md @@ -45,22 +45,30 @@ Before you begin, you need: **1. Add the plugin to your `build.gradle.kts`:** ```kotlin +import me.drownek.plugwright.local.LocalMode + plugins { id("io.github.drownek.plugwright") version "2.0.3" } plugwright { - minecraftVersion.set("1.19.4") - testsDir.set(file("src/test/e2e")) - acceptEula.set(true) - - // Download some dependencies your plugin might need - downloadPlugins { - url("https://url.to/plugin1.jar") - url("https://url.to/plugin2.jar") - // ... etc + environments { + // Paper downloaded, patched, started and killed by plugwright itself. + create("local", LocalMode) { + minecraftVersion.set("1.19.4") + acceptEula.set(true) + + // Download some dependencies your plugin might need + downloadPlugins { + url("https://url.to/plugin1.jar") + url("https://url.to/plugin2.jar") + // ... etc + } + } } + testsDir.set(file("src/test/e2e")) + // If true, always downloads and uses an isolated Node.js version, ignoring the system Node. downloadNode.set(true) } diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 94814f6..f1db920 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -21,17 +21,22 @@ description: "Start running your first test in less than 5 minutes." } plugwright { - minecraftVersion.set("1.19.4") - testsDir.set(file("src/test/e2e")) - acceptEula.set(true) - - // Download some dependencies your plugin might need - downloadPlugins { - url("https://url.to/plugin1.jar") - url("https://url.to/plugin2.jar") - // ... etc + environments { + // Paper downloaded, patched, started and killed by plugwright itself. + create("local", LocalMode) { + minecraftVersion.set("1.19.4") + acceptEula.set(true) + // Download some dependencies your plugin might need + downloadPlugins { + url("https://url.to/plugin1.jar") + url("https://url.to/plugin2.jar") + // ... etc + } + } } + testsDir.set(file("src/test/e2e")) + // If true, always downloads and uses an isolated Node.js version, ignoring the system Node. downloadNode.set(true) } From a9aa27a079e7b55cd8105a2563592379fa4c47b8 Mon Sep 17 00:00:00 2001 From: Drownek Date: Sat, 12 Sep 2026 11:03:39 +0200 Subject: [PATCH 124/125] refactor(runner)!: remove deprecated GUI API and fix RCON edge cases - Remove deprecated GUI methods (waitForGuiItem, clickGuiItem, etc.) - Fix nextId overflow in RCON connection - Stop swallowing RCON probe errors - Fix Minecraft version and remove dev branch in CI --- .github/workflows/ci.yml | 4 +- runner-package/lib/player.ts | 18 --- runner-package/lib/rcon/connection.ts | 2 + runner-package/lib/rcon/index.ts | 4 +- runner-package/lib/wrappers.ts | 165 -------------------------- 5 files changed, 6 insertions(+), 187 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6908792..e2e2ff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,13 @@ name: CI on: push: - branches: [ "master", "v3-dev" ] + branches: [ "master" ] paths-ignore: - 'docs/**' - 'docs.json' - '**/*.md' pull_request: - branches: [ "master", "v3-dev" ] + branches: [ "master" ] paths-ignore: - 'docs/**' - 'docs.json' diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 7dfa548..aec15b2 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -25,21 +25,6 @@ export class PlayerWrapper { return this.bot.username; } - /** - * @deprecated Use `player.gui({ title })` instead. - */ - waitForGui!: (guiMatcher: (gui: GuiWrapper) => boolean, options?: { timeout?: number }) => Promise; - - /** - * @deprecated Use `gui.locator(predicate)` with expectations instead. - */ - waitForGuiItem!: (itemMatcher: (item: ItemWrapper) => boolean, options?: { timeout?: number, pollingRate?: number }) => Promise; - - /** - * @deprecated Use `gui.locator(predicate).click()` instead. - */ - clickGuiItem!: (itemMatcher: (item: ItemWrapper) => boolean, options?: { timeout?: number, pollingRate?: number }) => Promise; - gui!: (options: { title: string | RegExp; timeout?: number }) => Promise; private serverWrapper?: ServerWrapper; private _botOptions?: BotConnectionOptions; @@ -55,9 +40,6 @@ export class PlayerWrapper { private _bindExtensions(bot: Bot): void { const extensions = createPlayerExtensions(bot); - this.waitForGui = extensions.waitForGui.bind(this); - this.waitForGuiItem = extensions.waitForGuiItem.bind(this); - this.clickGuiItem = extensions.clickGuiItem.bind(this); this.gui = extensions.gui.bind(this); } diff --git a/runner-package/lib/rcon/connection.ts b/runner-package/lib/rcon/connection.ts index d6456f8..59e0b17 100644 --- a/runner-package/lib/rcon/connection.ts +++ b/runner-package/lib/rcon/connection.ts @@ -49,6 +49,7 @@ export class RconConnection { reject: (err) => reject(err), }; const id = this.nextId++; + if (this.nextId > 0x7fffffff) this.nextId = 1; socket.write(encodePacket(id, PacketType.AUTH, this.password)); }); @@ -143,6 +144,7 @@ export class RconConnection { if (!socket) throw new Error('RCON connection is not open'); const id = this.nextId++; + if (this.nextId > 0x7fffffff) this.nextId = 1; const result = await new Promise((innerResolve, innerReject) => { const timer = setTimeout(() => { diff --git a/runner-package/lib/rcon/index.ts b/runner-package/lib/rcon/index.ts index 365cb28..46dfb7d 100644 --- a/runner-package/lib/rcon/index.ts +++ b/runner-package/lib/rcon/index.ts @@ -20,8 +20,8 @@ export function rconConsole(config: RconConsoleConfig): ServerConsole { try { await connection.ensureConnected(); return true; - } catch { - return false; + } catch (err) { + throw err; } }, diff --git a/runner-package/lib/wrappers.ts b/runner-package/lib/wrappers.ts index 3a066b3..270831c 100644 --- a/runner-package/lib/wrappers.ts +++ b/runner-package/lib/wrappers.ts @@ -420,175 +420,10 @@ export class GuiWrapper { await this.bot.clickWindow(item.slot, 0, 0); } - - // ----------------------------------------------------------------------- - // Public deprecated methods — warn once then delegate to internal methods. - // ----------------------------------------------------------------------- - - /** - * @deprecated Use gui.locator() with expectations instead. This method will be removed in a future version. - * @internal This class is primarily for internal use. Use LiveGuiHandle and GuiItemLocator instead. - */ - hasItem(predicate: (item: ItemWrapper) => boolean): boolean { - console.warn('[DEPRECATED] GuiWrapper.hasItem() is deprecated. Use gui.locator() instead.'); - return this._hasItemInternal(predicate); - } - - /** - * @deprecated Use gui.locator() to get items. This method will be removed in a future version. - * @internal This class is primarily for internal use. Use LiveGuiHandle and GuiItemLocator instead. - */ - findItem(predicate: (item: ItemWrapper) => boolean): ItemWrapper | undefined { - console.warn('[DEPRECATED] GuiWrapper.findItem() is deprecated. Use gui.locator() instead.'); - return this._findItemInternal(predicate); - } - - /** - * @deprecated Use multiple gui.locator() calls if needed. This method will be removed in a future version. - * @internal This class is primarily for internal use. Use LiveGuiHandle and GuiItemLocator instead. - */ - findAllItems(predicate: (item: ItemWrapper) => boolean): ItemWrapper[] { - console.warn('[DEPRECATED] GuiWrapper.findAllItems() is deprecated. Use gui.locator() instead.'); - return this._findAllItemsInternal(predicate); - } - - /** - * @deprecated Use gui.locator().click() instead. This method will be removed in a future version. - * @internal This class is primarily for internal use. Use LiveGuiHandle and GuiItemLocator instead. - */ - async clickItem(predicate: (item: ItemWrapper) => boolean): Promise { - console.warn('[DEPRECATED] GuiWrapper.clickItem() is deprecated. Use gui.locator().click() instead.'); - return this._clickItemInternal(predicate); - } } export function createPlayerExtensions(bot: Bot) { return { - async waitForGuiItem( - itemMatcher: (item: ItemWrapper) => boolean, - options: { timeout?: number; pollingRate?: number } = {} - ): Promise { - console.warn('[DEPRECATED] player.waitForGuiItem() is deprecated. Use gui.locator() with expectations instead. See documentation for migration guide.'); - - const { timeout = 5000, pollingRate = 100 } = options; - const startTime = Date.now(); - - for (;;) { - if (bot.currentWindow) { - const window = bot.currentWindow as Window; - const items = window.slots - .filter((item): item is RawItem => item != null) - .map(item => new ItemWrapper(item)); - - const matchedItem = items.find(itemMatcher); - - if (matchedItem) { - console.log(`[Player] Found GUI item: ${matchedItem.displayName} at slot ${matchedItem.slot}`); - return matchedItem; - } - } - - if (Date.now() - startTime >= timeout) { - throw new Error(`[Player] Timeout waiting for GUI item (${timeout}ms)`); - } - - await new Promise(resolve => setTimeout(resolve, pollingRate)); - } - }, - - async clickGuiItem( - itemMatcher: (item: ItemWrapper) => boolean, - options: { timeout?: number; pollingRate?: number } = {} - ): Promise { - console.warn('[DEPRECATED] player.clickGuiItem() is deprecated. Use gui.locator().click() instead. See documentation for migration guide.'); - - const { timeout = 5000, pollingRate = 100 } = options; - const startTime = Date.now(); - - for (;;) { - if (bot.currentWindow) { - const window = bot.currentWindow as Window; - const items = window.slots - .filter((item): item is RawItem => item != null) - .map(item => new ItemWrapper(item)); - - const matchedItem = items.find(itemMatcher); - - if (matchedItem) { - const lore = matchedItem.lore; - console.log(`[Player] Clicking GUI item: ${matchedItem.displayName}`); - console.log(` Material: ${matchedItem.name}`); - console.log(` Slot: ${matchedItem.slot}`); - if (lore.length > 0) { - console.log(` Lore: ${lore.join(' | ')}`); - } - - await bot.clickWindow(matchedItem.slot, 0, 0); - return; - } - } - - if (Date.now() - startTime >= timeout) { - throw new Error(`[Player] Timeout waiting for GUI item to click (${timeout}ms)`); - } - - await new Promise(resolve => setTimeout(resolve, pollingRate)); - } - }, - - async waitForGui( - guiMatcher: (gui: GuiWrapper) => boolean, - options: { timeout?: number } = {} - ): Promise { - console.warn('[DEPRECATED] player.waitForGui() is deprecated. Use player.gui({ title }) instead. See documentation for migration guide.'); - - const { timeout = 5000 } = options; - - return new Promise((resolve, reject) => { - let settled = false; - - const tryMatch = (): GuiWrapper | null => { - if (!bot.currentWindow) return null; - const gui = new GuiWrapper(bot, bot.currentWindow as Window); - return guiMatcher(gui) ? gui : null; - }; - - const settle = (gui: GuiWrapper) => { - if (settled) return; - settled = true; - cleanup(); - console.log(`[Player] GUI matched: "${gui.title}"`); - resolve(gui); - }; - - const attempt = () => { - if (settled) return; - const matched = tryMatch(); - if (matched) settle(matched); - }; - - const deadline = setTimeout(() => { - if (settled) return; - settled = true; - cleanup(); - reject(new Error(`[Player] Timeout waiting for GUI matching predicate (${timeout}ms)`)); - }, timeout); - - const onWindowOpen = () => { - setImmediate(attempt); - }; - - const cleanup = () => { - clearTimeout(deadline); - bot.removeListener('windowOpen', onWindowOpen); - }; - - bot.on('windowOpen', onWindowOpen); - - setImmediate(attempt); - }); - }, - /** * Get a live handle to a GUI matching the title. * It waits ONLY until a GUI with matching title exists. From 39028817e56761888975bc8589bd3ccfd1c5a56d Mon Sep 17 00:00:00 2001 From: Drownek Date: Sat, 12 Sep 2026 11:07:22 +0200 Subject: [PATCH 125/125] chore: bump to 3.0.0 --- README.md | 2 +- auth-authme-package/package-lock.json | 8 ++++---- auth-authme-package/package.json | 4 ++-- docs/quickstart.mdx | 2 +- example_plugin/build.gradle.kts | 2 +- example_plugin/src/test/e2e/package-lock.json | 4 ++-- runner-package/package-lock.json | 4 ++-- runner-package/package.json | 2 +- scripts/bump-version.js | 6 ++++++ version.txt | 2 +- 10 files changed, 21 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 06c9d50..817e9e2 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Before you begin, you need: import me.drownek.plugwright.local.LocalMode plugins { - id("io.github.drownek.plugwright") version "2.0.3" + id("io.github.drownek.plugwright") version "3.0.0" } plugwright { diff --git a/auth-authme-package/package-lock.json b/auth-authme-package/package-lock.json index 6cb58aa..ba5f25f 100644 --- a/auth-authme-package/package-lock.json +++ b/auth-authme-package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@plugwright/auth-authme", - "version": "3.0.0-dev.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@plugwright/auth-authme", - "version": "3.0.0-dev.1", + "version": "3.0.0", "license": "MIT", "devDependencies": { "@plugwright/runner": "file:../runner-package", @@ -23,12 +23,12 @@ }, "../runner-package": { "name": "@plugwright/runner", - "version": "3.0.0-dev.1", + "version": "3.0.0", "dev": true, "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", - "mineflayer": "^4.0.0", + "mineflayer": "^4.39.0", "picocolors": "^1.1.1", "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" diff --git a/auth-authme-package/package.json b/auth-authme-package/package.json index 4753065..71b3e85 100644 --- a/auth-authme-package/package.json +++ b/auth-authme-package/package.json @@ -1,6 +1,6 @@ { "name": "@plugwright/auth-authme", - "version": "3.0.0-dev.1", + "version": "3.0.0", "description": "Reference plugwright authentication plugin for an AuthMe-style login/register flow", "type": "module", "main": "dist/index.js", @@ -32,7 +32,7 @@ "url": "https://github.com/Drownek/plugwright/issues" }, "peerDependencies": { - "@plugwright/runner": ">=3.0.0-dev.0" + "@plugwright/runner": ">=3.0.0" }, "devDependencies": { "@plugwright/runner": "file:../runner-package", diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index f1db920..3417393 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -17,7 +17,7 @@ description: "Start running your first test in less than 5 minutes." ```kotlin plugins { - id("io.github.drownek.plugwright") version "2.0.3" + id("io.github.drownek.plugwright") version "3.0.0" } plugwright { diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index ed41333..727b7d3 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -6,7 +6,7 @@ plugins { `java-library` id("de.eldoria.plugin-yml.bukkit") version "0.8.0" id("com.gradleup.shadow") version "9.0.0" - id("io.github.drownek.plugwright") version "3.0.0-dev.1" + id("io.github.drownek.plugwright") version "3.0.0" } // Password every bot on the local server registers with. It guards a server that lives for diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index 784eb99..20ec3c9 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -16,7 +16,7 @@ }, "../../../../auth-authme-package": { "name": "@plugwright/auth-authme", - "version": "3.0.0-dev.1", + "version": "3.0.0", "license": "MIT", "devDependencies": { "@plugwright/runner": "file:../runner-package", @@ -33,7 +33,7 @@ }, "../../../../runner-package": { "name": "@plugwright/runner", - "version": "3.0.0-dev.1", + "version": "3.0.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", diff --git a/runner-package/package-lock.json b/runner-package/package-lock.json index e7358e6..fc1fcd2 100644 --- a/runner-package/package-lock.json +++ b/runner-package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@plugwright/runner", - "version": "3.0.0-dev.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@plugwright/runner", - "version": "3.0.0-dev.1", + "version": "3.0.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", diff --git a/runner-package/package.json b/runner-package/package.json index d69623b..4650b27 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -1,6 +1,6 @@ { "name": "@plugwright/runner", - "version": "3.0.0-dev.1", + "version": "3.0.0", "description": "End-to-end testing framework for Paper/Spigot Minecraft plugins", "type": "module", "main": "dist/runner.js", diff --git a/scripts/bump-version.js b/scripts/bump-version.js index 383a721..e8ce328 100644 --- a/scripts/bump-version.js +++ b/scripts/bump-version.js @@ -56,6 +56,12 @@ function bumpVersionFiles(newVersion, isPrerelease) { /id\("io\.github\.drownek\.plugwright"\) version "[^"]+"/g, `id("io.github.drownek.plugwright") version "${newVersion}"` ); + + replaceRegexInFile( + "auth-authme-package/package.json", + /"@plugwright\/runner":\s*">=[^"]+"/g, + `"@plugwright/runner": ">=${newVersion}"` + ); } async function main() { diff --git a/version.txt b/version.txt index 2813f3b..4a36342 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.0.0-dev.1 +3.0.0
⚠️ Upgrading from Plugwright 2.x? The npm package moved.
The runner is published as @plugwright/runner from 3.0 onwards; @drownek/plugwright stops receiving releases at 2.x. Change the dependency in your package.json, run npm install, and update the import in your test files. Nothing else moves: the Gradle plugin id stays io.github.drownek.plugwright. +See the full
v2 to v3 Migration Guide for layout changes, configuration updates, and new features.