diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 31c0ff3..081308b 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -1,56 +1,31 @@ +// Loaded once here so every subproject resolves the same Kotlin plugin classes: applying +// `kotlin-dsl` from each subproject's own plugins block loads the Kotlin plugin several +// times over, which Gradle warns about and does not support. plugins { - `kotlin-dsl` - `maven-publish` - id("com.gradle.plugin-publish") version "1.2.1" + `kotlin-dsl` apply false } -group = "io.github.drownek" val projectVersion = file("../version.txt").readText().trim() -version = projectVersion -repositories { - mavenCentral() - gradlePluginPortal() -} - -dependencies { - implementation(gradleApi()) - implementation("com.google.code.gson:gson:2.10.1") - implementation("org.yaml:snakeyaml:2.0") - implementation("org.jetbrains.gradle.plugin.idea-ext:org.jetbrains.gradle.plugin.idea-ext.gradle.plugin:1.4.1") -} +allprojects { + group = "io.github.drownek" + version = projectVersion -gradlePlugin { - website.set("https://github.com/drownek/plugwright") - vcsUrl.set("https://github.com/drownek/plugwright.git") - plugins { - create("plugwright") { - id = "io.github.drownek.plugwright" - displayName = "Plugwright Testing Plugin" - description = "End-to-end testing framework for Paper/Spigot Minecraft plugins" - tags.set(listOf("minecraft", "paper", "spigot", "testing", "e2e")) - implementationClass = "me.drownek.plugwright.PlugwrightPlugin" - } + repositories { + mavenCentral() + // The idea-ext plugin marker plugwright-core compiles against lives here, not in Central. + gradlePluginPortal() } } -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } -} +subprojects { + apply(plugin = "org.gradle.kotlin.kotlin-dsl") -val generateVersionResource = tasks.register("generateVersionResource") { - val outFile = layout.buildDirectory.file("generated/version-resource/plugwright-version.properties") - inputs.property("version", projectVersion) - outputs.file(outFile) - doLast { - val f = outFile.get().asFile - f.parentFile.mkdirs() - f.writeText("version=$projectVersion\n") + plugins.withId("java") { + extensions.configure { + 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..db63032 --- /dev/null +++ b/gradle-plugin/plugwright-api/build.gradle.kts @@ -0,0 +1,3 @@ +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/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/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/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..9763180 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt @@ -0,0 +1,45 @@ +package me.drownek.plugwright.api + +import org.gradle.api.model.ObjectFactory + +/** + * How one kind of environment is declared in the build script and prepared for a test run. + * + * Implementations are stateless singletons: everything configurable lives in the spec, and + * everything executed lives in the tasks registered by [registerTasks]. + */ +interface PlugwrightMode { + + /** 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) {} + + /** + * Seeds [spec] from the deprecated flat extension properties, for a build with no + * `environments { }` block. No-op for modes with no legacy shape to migrate from. + */ + fun applyLegacyDefaults(spec: S, legacy: LegacyEnvironmentProperties) {} + + /** + * Writes the mode-specific part of the runner config, landing under + * `environment.config`. Runs at configuration time, so secrets stay [SecretRef]s. + */ + fun serialize(spec: S, node: ConfigNodeBuilder) + + /** Registers the tasks for this environment: provisioning, cleanup, mode-specific extras. */ + fun registerTasks(spec: S, ctx: TaskRegistrationContext) {} +} 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/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..c3fbd0e --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt @@ -0,0 +1,44 @@ +package me.drownek.plugwright.api + +import org.gradle.api.Project +import java.io.File +import java.io.Serializable + +/** + * A pointer to a secret value, never the value itself. + * + * Secrets are resolved by the runner at execution time. Resolving them during the + * configuration phase would put passwords into the configuration cache and into + * build artifacts. + */ +sealed class SecretRef : Serializable { + + /** Read the secret from the environment variable [name]. */ + data class FromEnv(val name: String) : SecretRef() + + /** Read the secret from the first line of [path]. */ + data class FromFile(val path: String) : SecretRef() { + constructor(file: File) : this(file.absolutePath) + } + + /** Read the secret from the system property [name]. */ + data class FromSystemProperty(val name: String) : SecretRef() + + companion object { + private const val serialVersionUID: Long = 1L + } +} + +/** + * Factory for [SecretRef] values, exposed to build scripts as `secret`. + */ +object Secrets { + fun env(name: String): SecretRef = SecretRef.FromEnv(name) + fun file(path: String): SecretRef = SecretRef.FromFile(path) + fun file(file: File): SecretRef = SecretRef.FromFile(file) + fun systemProperty(name: String): SecretRef = SecretRef.FromSystemProperty(name) +} + +/** `secret.env("X")` / `secret.file(path)` in a build script, anywhere the implicit `Project` + * receiver is reachable — including nested `environments { create(...) { ... } }` blocks. */ +val Project.secret: Secrets get() = Secrets 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..01e8c03 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt @@ -0,0 +1,69 @@ +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 + + /** 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. + */ + 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) + + /** + * 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) + + /** + * 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]. */ +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-bundle/build.gradle.kts b/gradle-plugin/plugwright-bundle/build.gradle.kts new file mode 100644 index 0000000..71ea4d5 --- /dev/null +++ b/gradle-plugin/plugwright-bundle/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + `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-bundle/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt b/gradle-plugin/plugwright-bundle/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt new file mode 100644 index 0000000..6053412 --- /dev/null +++ b/gradle-plugin/plugwright-bundle/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt @@ -0,0 +1,22 @@ +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 + +/** + * Entry point for the `io.github.drownek.plugwright` id. + * + * 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-core/build.gradle.kts b/gradle-plugin/plugwright-core/build.gradle.kts new file mode 100644 index 0000000..c86e57c --- /dev/null +++ b/gradle-plugin/plugwright-core/build.gradle.kts @@ -0,0 +1,38 @@ +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. + compileOnly(project(":plugwright-api")) +} + +// 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") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(apiJar.map { zipTree(it.archiveFile) }) +} + +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/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/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/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/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/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt new file mode 100644 index 0000000..5fd75e3 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -0,0 +1,387 @@ +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 +import org.gradle.process.ExecOperations + +interface InjectedExecOps { + @get:Inject + val execOperations: ExecOperations +} + +object BannerState { + val printed = AtomicBoolean(false) +} + +/** + * Name of the implicit environment used while the build script has no `environments { }` + * block: the flat extension properties describe one environment under this name. + */ +const val DEFAULT_ENVIRONMENT_NAME = "local" + +/** + * 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) + + // Shared per-user cache so Node.js is downloaded once for all projects + // and survives 'gradle clean'. Safe for concurrent builds thanks to the + // file lock in NodeManager. + val defaultNodeInstallDir = File(project.gradle.gradleUserHomeDir, "caches/plugwright/node") + + val plugwrightCompileTests = project.tasks.register("plugwrightCompileTests", PlugwrightCompileTestsTask::class.java) { + doFirst { + if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) + } + + testsDir.set(extension.testsDir) + nodeVersion.set(extension.nodeVersion) + downloadNode.set(extension.downloadNode) + nodeInstallDir.set(defaultNodeInstallDir) + } + + 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. + * + * 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, + plugwrightCompileTests: TaskProvider, + ) { + project.plugins.withId("idea") { + project.pluginManager.apply("org.jetbrains.gradle.plugin.idea-ext") + 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, + 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) + } + + 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()}" + ) + } + + 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>() + 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, defaultNodeInstallDir + ) + val journalFilePath = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl").get().asFile + + val testTask = ctx.registerWithoutAlias("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")) + 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) + + if (project.hasProperty("testFiles")) testFiles.set(project.property("testFiles") as String) + if (project.hasProperty("testNames")) testNames.set(project.property("testNames") as String) + } + + val validation = ValidationContextImpl(envName, project.logger) + mode.validate(entry.spec, validation) + validationProblems += validation.errors.map { "[$envName] $it" } + + mode.registerTasks(entry.spec, ctx) + + 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)) { + 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, + pluginConfigs = pluginConfigsProvider, + journalFile = journalFilePath, + ) + 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 + * 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) + } + + doLast { + val defaultDir = "src/test/e2e" + val propertyDir = project.findProperty("plugwrightDir") as? String + + val inputDir = propertyDir ?: run { + if (System.console() != null) { + project.logger.lifecycle("Enter the test directory location [default: $defaultDir]:") + val consoleInput = readlnOrNull()?.trim() + if (consoleInput.isNullOrEmpty()) defaultDir else consoleInput + } else { + project.logger.lifecycle("Non-interactive environment detected. Using default test directory: $defaultDir") + defaultDir + } + } + + project.logger.lifecycle("Using directory: $inputDir") + + val projectRootDir = project.projectDir.canonicalFile + val targetDir = projectRootDir.resolve(inputDir).canonicalFile + + if (!targetDir.path.startsWith(projectRootDir.path)) { + throw GradleException("SECURITY ERROR: Target directory ($targetDir) resolves outside the project root directory. Path traversal aborted.") + } + + if (!targetDir.exists() && !targetDir.mkdirs()) { + 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}") + } + + project.logger.lifecycle("Executing 'npm install' in ${targetDir.absolutePath}...") + val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get()) + + try { + 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 + throw GradleException("EXEC FATAL: Failed to launch npm process. Original error: ${e.message}", e) + } + } + } + } +} 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 new file mode 100644 index 0000000..5413963 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt @@ -0,0 +1,147 @@ +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) : LegacyEnvironmentProperties { + /** + * Directory containing test files (.spec.js / .spec.ts) + */ + val testsDir: DirectoryProperty = project.objects.directoryProperty().convention( + project.layout.projectDirectory.dir("src/test/e2e") + ) + + /** + * The Node.js version to download and use if downloadNode is true. + */ + val nodeVersion: Property = project.objects.property(String::class.java).convention("22.14.0") + + /** + * Whether to automatically download Node.js. Disabled by default: the system-installed + * node/npm on PATH is used, and the build fails with instructions if Node.js is missing. + * Set to true to download a verified Node.js distribution into a shared per-user cache. + */ + val downloadNode: Property = project.objects.property(Boolean::class.java).convention(false) + + /** + * Environment the unsuffixed task aliases (`plugwrightTest`, `plugwrightClean`, …) point + * at. Only meaningful once more than one environment is declared. + */ + val primaryEnvironment: Property = project.objects.property(String::class.java).convention(DEFAULT_ENVIRONMENT_NAME) + + /** + * Mode registry and declared environments. See [registerMode] and [environments]. + */ + val environments: EnvironmentContainer = EnvironmentContainer(project.objects) + + /** 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() + } + + /** 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. + + @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") + ) + + @Deprecated("Use environments { create(\"local\", LocalMode) { acceptEula.set(...) } }") + override val acceptEula: Property = project.objects.property(Boolean::class.java).convention(true) + + @Deprecated("Use environments { create(\"local\", LocalMode) { runDir.set(...) } }") + override val runDir: DirectoryProperty = project.objects.directoryProperty().convention( + project.layout.projectDirectory.dir("run") + ) + + @Deprecated("Use environments { create(\"local\", LocalMode) { cleanExcludePatterns.set(...) } }") + override val cleanExcludePatterns: ListProperty = project.objects.listProperty(String::class.java).convention( + listOf("server.jar", "cache", "libraries") + ) + + @Deprecated("Use environments { create(\"local\", LocalMode) { downloadPlugins { ... } } }") + override val pluginUrls: ListProperty = project.objects.listProperty(String::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. + */ + @Deprecated("Use environments { create(\"local\", LocalMode) { writeFiles { ... } } }") + fun writeFiles(action: RunDirFileSpec.() -> Unit) { + val spec = RunDirFileSpec() + action(spec) + runDirFiles.set(spec.entries) + } + + /** + * Specification for run-dir file staging. + */ + 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)) + } + } + + /** + * DSL method for configuring plugin downloads. + */ + @Deprecated("Use environments { create(\"local\", LocalMode) { downloadPlugins { ... } } }") + fun downloadPlugins(action: PluginDownloadSpec.() -> Unit) { + val spec = PluginDownloadSpec() + action(spec) + pluginUrls.set(spec.urls) + } + + /** + * Specification for plugin downloads. + */ + class PluginDownloadSpec { + internal val urls = mutableListOf() + + /** + * Add a plugin URL to download. + */ + fun url(pluginUrl: String) { + urls.add(pluginUrl) + } + } +} 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..559c204 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt @@ -0,0 +1,181 @@ +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 +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, + 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) + +/** + * `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, + pluginConfigs = env.pluginConfigs.get(), + journalFile = env.journalFile, + ) + 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 new file mode 100644 index 0000000..20e16cf --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt @@ -0,0 +1,124 @@ +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 +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* +import java.io.File + +/** + * 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 + abstract val testsDir: DirectoryProperty + + @get:Input + @get:Optional + abstract val testFiles: Property + + @get:Input + @get:Optional + abstract val testNames: Property + + /** 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 + + /** 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 + + /** 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" + // 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 = resolveNode() + + 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 + } + + logger.lifecycle("Running E2E tests for environment '${environmentName.get()}'...") + + val configDestination = configFile.get().asFile + 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, + pluginConfigs = pluginConfigs.get(), + journalFile = journalFile.orNull?.asFile, + ) + RunnerLauncher.writeConfig(entry) + logger.lifecycle("Runner config: ${configDestination.absolutePath}") + + val cliJsFile = RunnerLauncher.resolveCliJs(userTestsDirectory) + + runCommand(userTestsDirectory, 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/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/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..01ddb59 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt @@ -0,0 +1,97 @@ +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), + * [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`. + * [jsonReportFile]/[junitReportFile] are omitted for service runs (`--ping`, `--cleanup`) + * that never produce a report. */ + 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? = 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) { + 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") + } + 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) + } + + /** 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 new file mode 100644 index 0000000..4fae8ff --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt @@ -0,0 +1,90 @@ +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 +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, + 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. */ + 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 + + /** 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 = + 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) { + // 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" + 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 + } + + override fun pluginConfigs(refs: Provider>) { + pluginConfigsProvider = refs + } +} 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-external/build.gradle.kts b/gradle-plugin/plugwright-external/build.gradle.kts new file mode 100644 index 0000000..ce5af71 --- /dev/null +++ b/gradle-plugin/plugwright-external/build.gradle.kts @@ -0,0 +1,8 @@ +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/plugwright-local/build.gradle.kts b/gradle-plugin/plugwright-local/build.gradle.kts new file mode 100644 index 0000000..d2c2352 --- /dev/null +++ b/gradle-plugin/plugwright-local/build.gradle.kts @@ -0,0 +1,10 @@ +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 the bundle module's + // merged jar, which is what actually gets published. + compileOnly(project(":plugwright-api")) +} 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/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt similarity index 64% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt rename to gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt index 4c9ab9a..b7587d9 100644 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt @@ -1,13 +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 @@ -16,57 +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 : DefaultTask() { - @get:Input - abstract val serverJarPath: Property +/** + * 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 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 + abstract val runDirFiles: ListProperty - @get:Input - abstract val nodeVersion: Property + init { + group = "verification" + description = "Downloads Paper and prepares the local test server" + } - @get:Input - abstract val downloadNode: Property - - @get:Internal - abstract val nodeInstallDir: DirectoryProperty - - 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) + @TaskAction + fun provision() { + val runDirectory = runDir.get().asFile if (!runDirectory.exists() && !runDirectory.mkdirs()) { throw GradleException("Failed to create run directory at ${runDirectory.absolutePath}") } @@ -78,17 +61,19 @@ abstract class AbstractPlugwrightTask : DefaultTask() { 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}") } } } @@ -98,8 +83,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { 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 -> @@ -108,8 +92,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } 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 -> @@ -128,17 +111,14 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } 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 @@ -146,7 +126,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { 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() @@ -172,55 +152,53 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } // 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 { @@ -232,7 +210,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { downloadsJson.getAsJsonObject(firstKey) } } - + val downloadUrl = if (downloadEntry.has("url")) { downloadEntry.get("url").asString } else { @@ -240,29 +218,29 @@ abstract class AbstractPlugwrightTask : DefaultTask() { "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) @@ -306,7 +284,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } } - protected fun configureBukkitSettings(serverDirectory: File) { + private fun configureBukkitSettings(serverDirectory: File) { val bukkitYmlFile = File(serverDirectory, "bukkit.yml") try { @@ -336,7 +314,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } } - protected fun configureSpigotSettings(serverDirectory: File) { + private fun configureSpigotSettings(serverDirectory: File) { val spigotYmlFile = File(serverDirectory, "spigot.yml") try { @@ -366,119 +344,4 @@ abstract class AbstractPlugwrightTask : DefaultTask() { logger.warn("Warning: Could not configure spigot.yml: ${e.message}") } } - - 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/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/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/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/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 065db97..d5455e2 100644 --- a/gradle-plugin/settings.gradle.kts +++ b/gradle-plugin/settings.gradle.kts @@ -1 +1,12 @@ -rootProject.name = "plugwright-gradle-plugin" +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 +// 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") diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt b/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt deleted file mode 100644 index 584fa11..0000000 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt +++ /dev/null @@ -1,169 +0,0 @@ -package me.drownek.plugwright - -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) { - /** - * Directory containing test files (.spec.js) - */ - val testsDir: DirectoryProperty = project.objects.directoryProperty().convention( - project.layout.projectDirectory.dir("src/test/e2e") - ) - - /** - * The Node.js version to download and use if downloadNode is true. - */ - val nodeVersion: Property = project.objects.property(String::class.java).convention("22.14.0") - - /** - * Whether to automatically download Node.js. Disabled by default: the system-installed - * node/npm on PATH is used, and the build fails with instructions if Node.js is missing. - * Set to true to download a verified Node.js distribution into a shared per-user cache. - */ - 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. - */ - val runDir: DirectoryProperty = project.objects.directoryProperty().convention( - project.layout.projectDirectory.dir("run") - ) - - /** - * Minecraft version for the Paper server (e.g., "1.19.4", "1.20.4") - */ - val minecraftVersion: Property = project.objects.property(String::class.java).convention("1.19.4") - - /** - * JVM arguments to pass when starting the server. - */ - 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) - - /** - * 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" - ) - ) - - /** - * 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()) - - /** - * 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) - - /** - * 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()) - - /** - * 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")) - * } - * ``` - */ - fun writeFiles(action: RunDirFileSpec.() -> Unit) { - val spec = RunDirFileSpec() - action(spec) - runDirFiles.set(spec.entries) - } - - /** - * Specification for run-dir file staging. - */ - 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)) - } - } - - /** - * 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") - * } - * ``` - */ - fun downloadPlugins(action: PluginDownloadSpec.() -> Unit) { - val spec = PluginDownloadSpec() - action(spec) - pluginUrls.set(spec.urls) - } - - /** - * Specification for plugin downloads. - */ - class PluginDownloadSpec { - internal val urls = mutableListOf() - - /** - * Add a plugin URL to download. - */ - fun url(pluginUrl: String) { - urls.add(pluginUrl) - } - } -} diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt b/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt deleted file mode 100644 index 6b45a9b..0000000 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt +++ /dev/null @@ -1,358 +0,0 @@ -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 -import org.gradle.process.ExecOperations - -interface InjectedExecOps { - @get:Inject - val execOperations: ExecOperations -} - -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)) - } - } -} - -class PlugwrightPlugin : Plugin { - override fun apply(project: Project) { - val extension = project.extensions.create("plugwright", PlugwrightExtension::class.java, project) - - // Shared per-user cache so Node.js is downloaded once for all projects - // and survives 'gradle clean'. Safe for concurrent builds thanks to the - // 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 plugwrightNpmInstall = project.tasks.register("plugwrightNpmInstall") { - group = "verification" - description = "Installs Node.js dependencies for Plugwright tests." - - 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) - } - } - } - - project.tasks.register("plugwrightTest", PlugwrightTestTask::class.java) { - // Ensure clean and setup runs before test - dependsOn(plugwrightClean) - dependsOn(plugwrightNpmInstall) - - configureCommon(project, extension, defaultNodeInstallDir) - - testsDir.set(extension.testsDir) - - // Support command line properties for filtering - 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) - - configureCommon(project, extension, defaultNodeInstallDir) - } - - 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) - } - - doLast { - val defaultDir = "src/test/e2e" - val propertyDir = project.findProperty("plugwrightDir") as? String - - val inputDir = propertyDir ?: run { - if (System.console() != null) { - project.logger.lifecycle("Enter the test directory location [default: $defaultDir]:") - val consoleInput = readlnOrNull()?.trim() - if (consoleInput.isNullOrEmpty()) defaultDir else consoleInput - } else { - project.logger.lifecycle("Non-interactive environment detected. Using default test directory: $defaultDir") - defaultDir - } - } - - project.logger.lifecycle("Using directory: $inputDir") - - val projectRootDir = project.projectDir.canonicalFile - val targetDir = projectRootDir.resolve(inputDir).canonicalFile - - if (!targetDir.path.startsWith(projectRootDir.path)) { - throw GradleException("SECURITY ERROR: Target directory ($targetDir) resolves outside the project root directory. Path traversal aborted.") - } - - if (!targetDir.exists() && !targetDir.mkdirs()) { - 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}") - } - - project.logger.lifecycle("Executing 'npm install' in ${targetDir.absolutePath}...") - val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get()) - - try { - runNpmInstall(project, targetDir, nodePaths) - project.logger.lifecycle("\nYou're all set! Run tests with: ./gradlew plugwrightTest") - } catch (e: Exception) { - if (e is GradleException) throw e - throw GradleException("EXEC FATAL: Failed to launch npm process. Original error: ${e.message}", e) - } - } - } - - 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 }) - } - } - } - } - - // 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/PlugwrightTestTask.kt b/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt deleted file mode 100644 index b3d755c..0000000 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt +++ /dev/null @@ -1,129 +0,0 @@ -package me.drownek.plugwright - -import org.gradle.api.GradleException -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.* -import java.io.File - -abstract class PlugwrightTestTask : AbstractPlugwrightTask() { - - @get:InputDirectory - @get:Optional - abstract val testsDir: DirectoryProperty - - @get:Input - @get:Optional - abstract val testFiles: Property - - @get:Input - @get:Optional - abstract val testNames: Property - - init { - group = "verification" - description = "Run E2E tests for Paper plugin" - } - - @TaskAction - fun runTests() { - val nodePaths = NodeManager.getOrDownloadNode(nodeInstallDir.get().asFile, nodeVersion.get(), downloadNode.get()) - 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 { - 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 - 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") - - 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 - 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}?" - ) - - runCommand( - userTestsDirectory, - nodePaths.node, cliJsFile.absolutePath, - env = envMap - ) - - logger.lifecycle("E2E tests completed successfully") - } -} 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 new file mode 100644 index 0000000..7bb7a36 --- /dev/null +++ b/runner-package/lib/account.ts @@ -0,0 +1,112 @@ +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 + * one needs to log in. + */ +export interface Account { + username: string; + password?: string; + auth: 'offline' | 'microsoft'; + justCreated: boolean; + /** Set for `microsoft` accounts: where mineflayer should cache the device-code token. */ + microsoftCacheDir?: string; +} + +/** + * 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 }; +} + +/** 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; + 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 { + /** 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: SecretRef; max: number } | null; + + constructor(config: AccountsConfig | null | undefined) { + for (const entry of config?.pool ?? []) { + this.queue.push({ username: entry.username, secret: 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: config.autoRegister.password, + max: config.autoRegister.max, + } + : null; + } + + async lease(): Promise { + 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: resolveSecret(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/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/config.ts b/runner-package/lib/config.ts new file mode 100644 index 0000000..d129e84 --- /dev/null +++ b/runner-package/lib/config.ts @@ -0,0 +1,237 @@ +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 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 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. */ +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 ?? {}; + parsed.reports = parsed.reports ?? {}; + parsed.plugins = parsed.plugins ?? []; + 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/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..61e43c6 --- /dev/null +++ b/runner-package/lib/environment.ts @@ -0,0 +1,49 @@ +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. */ +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'; + /** 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` attaches to an already-running one 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; + /** 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/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/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/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..859bca3 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -1,14 +1,21 @@ 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 type { Account } from './account.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 +42,14 @@ 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; + private account?: Account; - constructor(bot: Bot) { + constructor(bot: Bot, session: Session) { this.bot = bot; + this.session = session; this._bindExtensions(bot); } @@ -109,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 { @@ -160,7 +178,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 +246,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 +263,12 @@ export class PlayerWrapper { const botUsername = this.username; const oldBot = this.bot; - await disconnectBot(oldBot, botUsername); - - const idx = activeBots.indexOf(oldBot); - if (idx !== -1) activeBots.splice(idx, 1); + await this.session.disconnectBot(oldBot, botUsername); + this.session.removeBot(oldBot); - 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 +280,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/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 44edb65..8211386 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,76 @@ 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, + plugin: r.plugin ?? 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`. */ +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 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}`; + }); + + 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/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..93f201c --- /dev/null +++ b/runner-package/lib/session.ts @@ -0,0 +1,188 @@ +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 + * (`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(); + 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) { + this.env = env; + this.journal = new CleanupJournal(journalPath); + } + + /** 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, + // 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}`)); + }); + + 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/lib/test-registry.ts b/runner-package/lib/test-registry.ts index ac9a259..5e4705e 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; +export type Hook = (context: TestContext) => Promise | void; +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. + */ +export interface TestOptions { + requires?: string[]; + environments?: string[]; +} interface DescribeScope { label: string; @@ -8,46 +22,60 @@ interface DescribeScope { afterHooks: Hook[]; } -interface TestCase { +export interface TestCase { name: string; - fn: (context: TestContext) => Promise; + 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; } export const testRegistry: TestCase[] = []; export const scopeStack: DescribeScope[] = [{ label: '', beforeHooks: [], afterHooks: [] }]; -export function test(name: string, fn: (context: TestContext) => Promise): void { +/** 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, + beforeHooks: scopeStack.flatMap(s => s.beforeHooks), + afterHooks: [...scopeStack].reverse().flatMap(s => s.afterHooks), + requires: options.requires ?? [], + environments: options.environments ?? null, + }); +} - testRegistry.push({ name: fullName, fn: wrappedFn }); +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: (context: TestContext) => Promise): void { - test(name, async (context: TestContext) => { +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 +96,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/test-runner.ts b/runner-package/lib/test-runner.ts new file mode 100644 index 0000000..6088d0b --- /dev/null +++ b/runner-package/lib/test-runner.ts @@ -0,0 +1,148 @@ +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 { 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 { 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> = []; + + // 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". + 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)}`); + + 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; + }; + + 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(); + for (const { account, pool } of leasedAccounts) pool.release(account); + } +} diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 2c70f5b..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 { @@ -14,4 +17,10 @@ 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; + /** 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 b03b91f..887e0dd 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -1,65 +1,90 @@ -import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; 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 { 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 { externalEnvironment } from './lib/environments/external.js'; 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 { formatDuration, printTestSummary } 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 { ExternalEnvironmentConfig } from './lib/environments/external.js'; import type { TestResult } from './lib/types.js'; +import type { TestCase } from './lib/test-registry.js'; +import type { Account, AccountPool } from './lib/account.js'; // Enable source map support for accurate TypeScript stack traces installSourceMapSupport(); // Re-export public API export { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator }; -export { PlayerWrapper } from './lib/player.js'; +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 { 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 { 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 { 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'; + +/** + * `local` and `external` are built into this package; anything else is a third-party mode, + * loaded through the `runtime` reference the Gradle plugin wrote into the config. + */ +async function resolveEnvironment(cfg: EnvironmentConfig): Promise { + if (cfg.mode === 'local') { + 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.`); +} -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`)); - } - }); +/** 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; }); } @@ -75,90 +100,89 @@ 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 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[] = []; - if (!serverJar || !serverDir || !javaPath) { - throw new Error('SERVER_JAR, JAVA_PATH and SERVER_DIR environment variables must be set'); - } + 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; - console.log(`${pc.bold('Starting Paper server...')}`); + await env.setup(session); + session.refreshConsole(); + await plugins.setup(session); - const jvmArgsString = process.env.JVM_ARGS || ''; - const jvmArgs = jvmArgsString.split(' ').filter(arg => arg.trim() !== ''); + try { + const connOpts = env.connection(); + + /** 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: TestCase): 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; + } - console.log(pc.dim(`JVM Arguments: ${jvmArgs.join(' ')}`)); + /** 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); - const serverProcess = spawn(javaPath!, [...jvmArgs, '-jar', serverJar, '--nogui'], { - cwd: serverDir, - stdio: ['pipe', 'pipe', 'pipe'] - }); + 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, plugin: pluginName }); + continue; + } - // 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'); + const result = await runTestCase({ file, testCase, session, plugins, connOpts, timeoutMs, pluginName }); + testResults.push(result); } - } 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 */ } - } - try { - await waitForServerStart(serverProcess); - console.log(`${pc.green(pc.bold('Server started successfully'))}\n`); - - serverProcess.stdout.on('data', writeMcOutput); - serverProcess.stderr.on('data', writeMcOutput); + // 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(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,138 +194,33 @@ 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}`))}`); + await runFile(file, null); + } - testRegistry.length = 0; - scopeStack.length = 0; - scopeStack.push({ label: '', beforeHooks: [], afterHooks: [] }); - 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 (!matches) { - console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (filter: ${testNameFilter})`)); - continue; - } - } - - console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); - - serverConsoleBuffer.length = 0; - - 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 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: 'localhost', - port: 25565, - username: botUsername, - version: process.env.MC_VERSION, - auth: 'offline', - }); - - const player = new PlayerWrapper(bot); - player._captureSpawnPromise(); - player.setServerWrapper(server); - player._setBotOptions({ - host: 'localhost', - port: 25565, - version: process.env.MC_VERSION, - auth: 'offline', - }); - - await player.join(); - return player; - }; - - const player = await createPlayer(); - - const testStartTime = Date.now(); - - try { - const abortController = new AbortController(); - const 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 disconnectAllBots(); - } - } + // 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 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 plugins.runCleanup(session, 'session'); + await plugins.teardown(); + 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}`)); } - - 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(); exitCode = printTestSummary(testResults); @@ -312,3 +231,127 @@ export async function runTestSession(): Promise { } 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')); + } + + // 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(); +} + +/** + * `--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 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", ); }