From d7e240c865a949eddcd7bfd6cd26db50b37a0137 Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 10:48:25 +0200 Subject: [PATCH 01/13] refactor(core): streamline v3 architecture (RCON runner, remove journal, admin-bot & abilities) --- auth-authme-package/README.md | 2 +- auth-authme-package/index.ts | 7 +- console-rcon-package/README.md | 4 +- console-rcon-package/index.ts | 10 +- docs/custom-modes.mdx | 1 - docs/external-servers.mdx | 42 +----- docs/plugins.mdx | 2 +- docs/test-filtering.mdx | 1 - .../src/test/e2e/plugins/stand-reset.ts | 18 +-- .../drownek/plugwright/api/PlugwrightMode.kt | 2 +- .../me/drownek/plugwright/AbstractNodeTask.kt | 3 +- .../plugwright/PlugwrightCorePlugin.kt | 7 +- .../plugwright/PlugwrightMatrixTask.kt | 2 - .../drownek/plugwright/PlugwrightTestTask.kt | 6 - .../me/drownek/plugwright/RunnerLauncher.kt | 7 +- .../plugwright/external/AccountsSpec.kt | 3 +- .../plugwright/external/ExternalMode.kt | 10 -- .../external/PlugwrightCleanupTask.kt | 68 ---------- .../plugwright/local/LocalEnvironmentSpec.kt | 6 + .../me/drownek/plugwright/local/LocalMode.kt | 8 +- .../plugwright/local/PaperProvisionTask.kt | 21 ++- runner-package/cli.ts | 6 +- runner-package/lib/admin-bot-console.ts | 82 ------------ runner-package/lib/config.ts | 5 +- runner-package/lib/console.ts | 5 +- runner-package/lib/environment.ts | 1 - runner-package/lib/environments/external.ts | 19 +-- runner-package/lib/environments/local.ts | 121 ++++++++++-------- runner-package/lib/journal.ts | 65 ---------- runner-package/lib/matchers.ts | 6 +- runner-package/lib/player.ts | 95 ++------------ runner-package/lib/plugin-host.ts | 4 +- runner-package/lib/plugin.ts | 3 - runner-package/lib/server.ts | 15 +-- runner-package/lib/session.ts | 5 +- runner-package/lib/test-registry.ts | 4 +- runner-package/lib/test-runner.ts | 2 + runner-package/lib/types.ts | 5 + runner-package/runner.ts | 55 +------- 39 files changed, 162 insertions(+), 566 deletions(-) delete mode 100644 gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt delete mode 100644 runner-package/lib/admin-bot-console.ts delete mode 100644 runner-package/lib/journal.ts diff --git a/auth-authme-package/README.md b/auth-authme-package/README.md index dbe68e0..90bde85 100644 --- a/auth-authme-package/README.md +++ b/auth-authme-package/README.md @@ -2,7 +2,7 @@ Reference [plugwright](https://github.com/Drownek/plugwright) authentication plugin for a server running AuthMe, or anything else that asks for a password in chat. -On every bot connection — the first bot of a test, a second bot from `createPlayer()`, every `player.rejoin()`, and the `external` mode's admin-bot console — it waits for the server's prompt and answers it. Registration is followed through to the login it triggers, because a command sent between the two is still rejected as unauthenticated. +On every bot connection — the first bot of a test, a second bot from `createPlayer()`, and every `player.rejoin()` — it waits for the server's prompt and answers it. Registration is followed through to the login it triggers, because a command sent between the two is still rejected as unauthenticated. Microsoft (online-mode) accounts go through the same handshake by default — whether AuthMe still puts up a login wall for a premium account is a server-side setting, not something this plugin assumes. Set `skipOnMicrosoftAccount` if you've confirmed yours doesn't. diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts index bf4b146..cdde38f 100644 --- a/auth-authme-package/index.ts +++ b/auth-authme-package/index.ts @@ -73,9 +73,10 @@ const isEnabled = (value: boolean | string): boolean => value === true || value /** * Reference authentication plugin for a server running AuthMe (or anything with the same - * login/register-by-chat flow). `onPlayerCreate` fires on every bot connection — the initial - * join and every `player.rejoin()` — and on the `external` mode's admin-bot console too, since - * that connects through the exact same `PlayerWrapper.join()` path a test bot does. + * login/register-by-chat flow). The credentials go straight into the runner's plugin configuration, and the runner manages + * the authentication flow. It happens exactly once per bot — on the very first + * join and every `player.rejoin()` — before test code ever gets a chance to see + * the player. */ export default definePlugin({ name: 'authme', diff --git a/console-rcon-package/README.md b/console-rcon-package/README.md index f3e6d77..6f3e4c7 100644 --- a/console-rcon-package/README.md +++ b/console-rcon-package/README.md @@ -4,7 +4,7 @@ RCON server console for [plugwright](https://github.com/Drownek/plugwright)'s `e A local server gives plugwright a console for free: it owns the process, so it reads stdout and writes stdin. A server someone else started gives it nothing. RCON is how tests reach that server's console instead. -The Source RCON protocol is implemented directly over Node's `net` module, so this package has no dependencies of its own. Every command comes back with the server's answer, which means `executeAndWait` needs none of the client-side sync tricks a fire-and-forget channel does. +The Source RCON protocol is implemented directly over Node's `net` module, so this package has no dependencies of its own. Every command comes back with the server's answer, which means `execute` needs none of the client-side sync tricks a fire-and-forget channel does. ## Usage @@ -35,7 +35,7 @@ rcon.password=… ## What tests can do with it -Commands and their answers, which covers `server.execute(...)`, `server.executeAndWait(...)`, `player.makeOp()` and everything built on them. +Commands and their answers, which covers `server.execute(...)`, `player.makeOp()` and everything built on them. What it cannot do is show a test the rest of the server log. RCON reports `output: 'responses'`, so `expect(server).toHaveReceivedMessage(...)` fails fast with an explanation instead of timing out. Mark those tests `requires: ['consoleOutput:full']` and they skip on an RCON-only environment. diff --git a/console-rcon-package/index.ts b/console-rcon-package/index.ts index fa0939b..8be5683 100644 --- a/console-rcon-package/index.ts +++ b/console-rcon-package/index.ts @@ -8,9 +8,7 @@ export interface RconConsoleConfig { } /** - * `ServerConsole` over RCON: unlike `stdio` and `admin-bot`, the protocol gives a synchronous - * response to every command, so `executeAndWait` doesn't need the `minecraft:say ` - * round-trip trick those two rely on. + * `ServerConsole` over RCON. */ export function rconConsole(config: RconConsoleConfig): ServerConsole { const connection = new RconConnection(config.host, config.port, config.password); @@ -28,11 +26,7 @@ export function rconConsole(config: RconConsoleConfig): ServerConsole { } }, - execute(cmd: string): void { - connection.execute(cmd); - }, - - async executeAndWait(cmd: string, timeoutMs: number = 5000): Promise { + async execute(cmd: string, timeoutMs: number = 5000): Promise { return connection.executeAndWait(cmd, timeoutMs); }, }; diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx index 783c9c4..f982c55 100644 --- a/docs/custom-modes.mdx +++ b/docs/custom-modes.mdx @@ -151,7 +151,6 @@ class VelocityEnvironment implements Environment { freshState: false, arbitraryUsernames: true, lifecycle: true, - cleanupStrategy: 'compensating', }; async setup(session: Session): Promise { /* connect, probe, warm up */ } diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index 80a0445..76f7b2c 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -124,46 +124,6 @@ That's for a scenario tied to state somebody provisioned on that account — a p Connects, probes the console channels, leases one account and authenticates with it, then disconnects. No tests run. When something is wrong with the stand — RCON password rotated, login plugin changed its messages, account pool exhausted — this fails in seconds with a specific message instead of failing test after test five minutes into a run. -## Cleaning up -`plugwrightClean` means something different per mode. For `LocalMode` it wipes the run directory. For `ExternalMode` there is nothing to wipe: it starts the runner in cleanup mode, which connects, loads the plugins and calls their `cleanup({ scope: 'manual' })` handlers. No files are touched. -```kotlin -// in a runner plugin -definePlugin({ - name: 'staging', - async cleanup({ session, scope }) { - // scope: 'session' after a run, 'manual' from plugwrightCleanStaging - await session.console?.executeAndWait('/pw purge-test-data'); - }, -}); -``` - -### The journal - -Finalizers registered with `TestContext.cleanup()` run in a `finally`. A `SIGKILL` skips `finally` blocks, and on a real server the leftovers accumulate — a hundred junk warps a month later. - -For obligations that must survive that, record a typed entry in `build/plugwright/-journal.jsonl`: - -```ts -test('creating a warp', async ({ player, server, cleanup }) => { - const warpName = `pw_${crypto.randomUUID().slice(0, 8)}`; - const id = warpName; - - player.chat(`/setwarp ${warpName}`); - server.session.journal.record(id, { kind: 'warp', name: warpName }); - - cleanup(() => { - server.execute(`/delwarp ${warpName}`); - server.session.journal.forget(id); - }); - - await expect(player).toHaveReceivedMessage('Warp created'); -}); -``` - -Entries are typed records interpreted by a plugin's `cleanup` handler, never raw command strings. A file that replays raw commands against a live server is a way to run arbitrary commands on it. Whatever is still in the journal when the next run starts is what a crash left behind; `plugwrightClean` prints anything a cleanup pass could not resolve. - -## What the runner reports as skipped - -After `setup()`, the environment reports what it actually supports. For `ExternalMode` that is: no fresh state, no server lifecycle, compensating cleanup, arbitrary usernames, and console plus op only if a console channel answered. Tests that declare `requires` are skipped against that list, with the reason in the report. See [Test Filtering](/test-filtering). +After `setup()`, the environment reports what it actually supports. For `ExternalMode` that is: no fresh state, no server lifecycle, arbitrary usernames, and console plus op only if a console channel answered. Tests that declare `requires` are skipped against that list, with the reason in the report. See [Test Filtering](/test-filtering). diff --git a/docs/plugins.mdx b/docs/plugins.mdx index 68a3401..7aa9a77 100644 --- a/docs/plugins.mdx +++ b/docs/plugins.mdx @@ -70,7 +70,7 @@ export default definePlugin({ }); ``` -`onPlayerCreate` fires on every connection: the bot a test starts with, a second bot from `createPlayer()`, every `player.rejoin()`, and the admin-bot console channel. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook. +`onPlayerCreate` fires on every connection: the bot a test starts with, a second bot from `createPlayer()`, and every `player.rejoin()`. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook. ## Hooks and `describe.serial` diff --git a/docs/test-filtering.mdx b/docs/test-filtering.mdx index e897b05..cc52047 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -56,7 +56,6 @@ Capability keys come from the environment's own report, after it has connected: | `freshState` | boolean | Each test gets a clean world and a clean player | | `arbitraryUsernames` | boolean | Bots may pick their own names | | `lifecycle` | boolean | The server can be restarted or stopped | -| `cleanupStrategy` | `wipe` / `compensating` / `none` | How cleanup happens after the run | A bare key is satisfied by anything other than `false`, `'none'` or an absent value. To demand one specific value, use `key:value`: diff --git a/example_plugin/src/test/e2e/plugins/stand-reset.ts b/example_plugin/src/test/e2e/plugins/stand-reset.ts index 7194edd..7bb8103 100644 --- a/example_plugin/src/test/e2e/plugins/stand-reset.ts +++ b/example_plugin/src/test/e2e/plugins/stand-reset.ts @@ -1,4 +1,4 @@ -import { definePlugin, waitUntil } from '@plugwright/runner'; +import { definePlugin, expect } from '@plugwright/runner'; /** What a fresh account starts with, per ExamplePlugin's own default. */ const STARTING_BALANCE = 1000; @@ -21,21 +21,13 @@ export default definePlugin({ name: 'stand-reset', async beforeEach({ player, server }) { - // Nothing to reset with: an environment without a console cannot run commands at all, - // and the tests that depend on this reset are excluded there anyway. - if (!server.session.env.capabilities.console) return; - await player.deOp(); await player.clearInventory(); - await waitUntil(async () => { - const res = await server.executeAndWait(`eco set ${player.username} ${STARTING_BALANCE}`); - return res.includes(`Set balance of ${player.username} to $${STARTING_BALANCE}`); - }, { message: `Console did not confirm balance reset for ${player.username}` }); + const ecoOutput = await server.execute(`eco set ${player.username} ${STARTING_BALANCE}`); + expect(ecoOutput).toContain(`Set balance of ${player.username} to $${STARTING_BALANCE}`); - await waitUntil(async () => { - const res = await server.executeAndWait(`kit reset ${player.username}`); - return res.includes(`Kit cooldown reset for ${player.username}`); - }, { message: `Console did not confirm kit cooldown reset for ${player.username}` }); + const kitOutput = await server.execute(`kit reset ${player.username}`); + expect(kitOutput).toContain(`Kit cooldown reset for ${player.username}`); }, }); diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt index 1e21937..9bfc18c 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt @@ -50,6 +50,6 @@ interface PlugwrightMode { */ fun serialize(spec: S, node: ConfigNodeBuilder) - /** Registers the tasks for this environment: provisioning, cleanup, mode-specific extras. */ + /** Registers the tasks for this environment: provisioning, mode-specific extras. */ fun registerTasks(spec: S, ctx: TaskRegistrationContext) {} } diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt index c9f09e8..fc923c1 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt @@ -119,9 +119,8 @@ abstract class AbstractNodeTask : DefaultTask() { } stderrThread.isDaemon = true - var stdinThread: Thread? = null if (interactive) { - stdinThread = Thread { + val stdinThread = Thread { try { val reader = System.`in`.bufferedReader(Charsets.UTF_8) val out = process.outputStream diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt index 0232ee2..5715d9e 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -117,7 +117,7 @@ class PlugwrightCorePlugin : Plugin { private fun wireEnvironments( project: Project, extension: PlugwrightExtension, - plugwrightCompileTests: org.gradle.api.tasks.TaskProvider, + plugwrightCompileTests: TaskProvider, defaultNodeInstallDir: File ) { // No environments { } block: fold the deprecated flat properties into one implicit @@ -178,7 +178,6 @@ class PlugwrightCorePlugin : Plugin { project, envName, envName == primaryName, projectPluginJarProvider, extension.testsDir.map { it.asFile }, layout, extension, defaultNodeInstallDir ) - val journalFilePath = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl").get().asFile val modePackages = mode.runnerPackages(entry.spec) // The package a mode names an export in is the one holding its environment factory. @@ -202,7 +201,6 @@ class PlugwrightCorePlugin : Plugin { configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$envName.json")) jsonReportFile.set(reportsDir.map { it.file("$envName.json") }) junitReportFile.set(reportsDir.map { it.dir("junit").file("$envName.xml") }) - journalFile.set(journalFilePath) nodeVersion.set(extension.nodeVersion) downloadNode.set(extension.downloadNode) nodeInstallDir.set(defaultNodeInstallDir) @@ -227,7 +225,7 @@ class PlugwrightCorePlugin : Plugin { val environmentConfigProvider = ctx.environmentConfigProvider ?: project.provider { ConfigNodeBuilder().apply { mode.serialize(entry.spec, this) }.build() } - val pluginConfigsProvider = (ctx.pluginConfigsProvider ?: project.provider { emptyList() }) + val pluginConfigsProvider = (ctx.pluginConfigsProvider ?: project.provider { emptyList() }) .map { refs -> refs.map { resolveWorkspacePlugin(it, layout) } } // A plugin declared by npm name is installed alongside the environment's own @@ -257,7 +255,6 @@ class PlugwrightCorePlugin : Plugin { excludeTests = entry.spec.excludeTests.get(), environmentConfig = environmentConfigProvider, pluginConfigs = pluginConfigsProvider, - journalFile = journalFilePath, runtimePackage = runtimeRef?.name, runtimeExport = runtimeRef?.export, ) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt index ad3e9fa..690f7d8 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt @@ -27,7 +27,6 @@ internal data class MatrixEnvironmentInput( val excludeTests: List, val environmentConfig: Provider, val pluginConfigs: Provider>, - val journalFile: File?, val runtimePackage: String? = null, val runtimeExport: String? = null, ) @@ -125,7 +124,6 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { jsonReportFile = env.jsonReportFile, junitReportFile = env.junitReportFile, pluginConfigs = env.pluginConfigs.get(), - journalFile = env.journalFile, runtimePackage = env.runtimePackage, runtimeExport = env.runtimeExport, ) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt index 0578217..bbea1f9 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt @@ -7,7 +7,6 @@ import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* -import java.io.File /** * Runs the compiled test suite against one environment. @@ -61,10 +60,6 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { @get:Internal abstract val pluginConfigs: ListProperty - /** Crash-recovery journal for this environment's run. */ - @get:Internal - abstract val journalFile: RegularFileProperty - /** npm package exporting this environment's factory. Unset for a built-in mode, which the * runner already carries. */ @get:Input @@ -127,7 +122,6 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { jsonReportFile = jsonReportFile.get().asFile, junitReportFile = junitReportFile.get().asFile, pluginConfigs = pluginConfigs.get(), - journalFile = journalFile.orNull?.asFile, runtimePackage = runtimePackage.orNull, runtimeExport = runtimeExport.orNull, ) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt index f10d09c..344d4aa 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt @@ -10,13 +10,13 @@ import java.io.File /** * Config-writing and `cli.js` resolution shared by [PlugwrightTestTask] (one environment), * [PlugwrightMatrixTask] (many, in one process each), and the service tasks a mode registers - * for itself (ping, compensating cleanup). Process execution itself stays on + * for itself (ping). Process execution itself stays on * [AbstractNodeTask] — every task type here extends it and already has `runCommand`/`resolveNode`. */ object RunnerLauncher { /** Everything needed to write one environment's `config.json` and locate its `cli.js`. - * [jsonReportFile]/[junitReportFile] are omitted for service runs (`--ping`, `--cleanup`) + * [jsonReportFile]/[junitReportFile] are omitted for service runs (`--ping`) * that never produce a report. */ data class Entry( val environmentName: String, @@ -36,8 +36,6 @@ object RunnerLauncher { val runtimePackage: String? = null, /** Named export holding the factory; null means the package's default export. */ val runtimeExport: String? = null, - /** Crash-recovery journal path for `Session.journal`; null disables on-disk persistence. */ - val journalFile: File? = null, ) fun writeConfig(entry: Entry) { @@ -86,7 +84,6 @@ object RunnerLauncher { } } } - entry.journalFile?.let { put("journal", it.absolutePath) } ?: putNull("journal") }.build() RunnerConfigWriter.write(entry.configFile, root) diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt index edf5a39..72e9f20 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt @@ -23,8 +23,7 @@ class PoolSpec(private val objects: ObjectFactory) { * accounts on demand, up to [max] connected at once; each one registers on its first login. */ class AutoRegisterSpec(objects: ObjectFactory) { /** - * Must start with `pw_` — generated accounts have to be recognizable as test accounts, the - * same convention the cleanup journal requires of entities it creates. + * Must start with `pw_` — generated accounts have to be recognizable as test accounts. * * The placeholder decides what happens to a name once the test holding it finishes. * `%d` (optionally zero-padded, `%04d`) numbers a fixed set of accounts the run keeps coming diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt index 97b1094..6d4c5e8 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt @@ -119,7 +119,6 @@ object ExternalMode : PlugwrightMode { ctx.pluginConfigs(project.provider { spec.pluginsSpec.refs() }) val configProvider = project.provider { ConfigNodeBuilder().also { serialize(spec, it) }.build() } - val journalFile = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl") ctx.register("Ping", PlugwrightPingTask::class.java) { environmentName.set(envName) @@ -129,15 +128,6 @@ object ExternalMode : PlugwrightMode { environmentConfig.set(configProvider) } - ctx.register("Clean", PlugwrightCleanupTask::class.java) { - environmentName.set(envName) - modeId.set(id) - testsDir.set(ctx.testsDir) - configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$envName-cleanup.json")) - environmentConfig.set(configProvider) - this.journalFile.set(journalFile) - } - // No prepareTask: unlike local, external doesn't provision anything before // plugwrightTest — the stand is assumed to already be up. } diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt deleted file mode 100644 index 849f9b3..0000000 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt +++ /dev/null @@ -1,68 +0,0 @@ -package me.drownek.plugwright.external - -import me.drownek.plugwright.AbstractNodeTask -import me.drownek.plugwright.RunnerLauncher -import me.drownek.plugwright.api.ConfigNode -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.TaskAction -import java.io.File - -/** - * `plugwrightClean` for a mode with a compensating cleanup strategy: no run directory to - * wipe, so instead this runs every loaded plugin's `cleanup({ scope: 'manual' })` handler and - * replays whatever the crash-recovery journal still has outstanding — entries a prior run's - * `finally` never reached because the process died first. - */ -abstract class PlugwrightCleanupTask : AbstractNodeTask() { - - @get:Internal - abstract val testsDir: Property - - @get:Input - abstract val environmentName: Property - - @get:Input - abstract val modeId: Property - - @get:Internal - abstract val environmentConfig: Property - - @get:OutputFile - abstract val configFile: RegularFileProperty - - @get:Internal - abstract val journalFile: RegularFileProperty - - init { - group = "verification" - description = "Runs compensating cleanup and replays the crash-recovery journal for an external environment." - outputs.upToDateWhen { false } - } - - @TaskAction - fun cleanup() { - val nodePaths = resolveNode() - val userTestsDirectory = testsDir.get() - - val entry = RunnerLauncher.Entry( - environmentName = environmentName.get(), - modeId = modeId.get(), - environmentConfig = environmentConfig.get(), - workspaceDir = userTestsDirectory, - configFile = configFile.get().asFile, - testFiles = null, - testNames = null, - excludeTests = emptyList(), - journalFile = journalFile.orNull?.asFile, - ) - RunnerLauncher.writeConfig(entry) - logger.lifecycle("Runner config: ${entry.configFile.absolutePath}") - - val cliJsFile = RunnerLauncher.resolveCliJs(userTestsDirectory) - runCommand(userTestsDirectory, nodePaths.node, cliJsFile.absolutePath, "--config", entry.configFile.absolutePath, "--cleanup") - } -} diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt index e9fa421..69b9419 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt @@ -34,6 +34,12 @@ class LocalEnvironmentSpec(private val environmentName: String, objects: ObjectF /** Port bots connect on. Currently always bound on `localhost`. */ val port: Property = objects.property(Int::class.java).convention(25565) + /** RCON port for the local server. Defaults to 25575. */ + val rconPort: Property = objects.property(Int::class.java).convention(25575) + + /** RCON password for the local server. Static throwaway — the server only listens on localhost. */ + val rconPassword: Property = objects.property(String::class.java).convention("plugwright") + /** URLs of plugins to download before running tests. */ val pluginUrls: ListProperty = objects.listProperty(String::class.java).convention(emptyList()) diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt index c0ff091..98d057f 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt @@ -27,8 +27,10 @@ object LocalMode : PlugwrightMode { override fun createSpec(name: String, objects: ObjectFactory): LocalEnvironmentSpec = LocalEnvironmentSpec(name, objects) - override fun runnerPackages(spec: LocalEnvironmentSpec): List = - listOf(RunnerPackageRef("@plugwright/runner", export = "localEnvironment")) + override fun runnerPackages(spec: LocalEnvironmentSpec): List = listOf( + RunnerPackageRef("@plugwright/runner", export = "localEnvironment"), + RunnerPackageRef("@plugwright/console-rcon", export = "rconConsole"), + ) override fun validate(spec: LocalEnvironmentSpec, ctx: ValidationContext) { if (spec.minecraftVersion.get().isBlank()) { @@ -124,6 +126,8 @@ object LocalMode : PlugwrightMode { builder.put("minecraftVersion", spec.minecraftVersion.get()) builder.put("host", "localhost") builder.put("port", spec.port.get()) + builder.put("rconPort", spec.rconPort.get()) + builder.put("rconPassword", spec.rconPassword.get()) } private fun resolveJavaPath(javaLauncher: Provider?): String { diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt index ddff596..60bb41a 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt @@ -125,12 +125,29 @@ abstract class PaperProvisionTask : DefaultTask() { lines.add("spawn-protection=0") } + // Enable RCON so the runner can send commands over a proper protocol + val rconProperties = mapOf( + "enable-rcon" to "true", + "rcon.port" to "25575", + "rcon.password" to "plugwright" + ) + for ((key, value) in rconProperties) { + val hasKey = lines.any { it.trim().startsWith("$key=") } + if (hasKey) { + lines = lines.map { line -> + if (line.trim().startsWith("$key=")) "$key=$value" else line + }.toMutableList() + } else { + lines.add("$key=$value") + } + } + Files.write(serverProperties.toPath(), lines) } else { - logger.lifecycle("Creating server.properties with online-mode=false, connection-throttle=0, spawn-protection=0 and server-port=${port.get()}") + logger.lifecycle("Creating server.properties with online-mode=false, connection-throttle=0, spawn-protection=0, enable-rcon=true and server-port=${port.get()}") Files.write( serverProperties.toPath(), - listOf("online-mode=false", "connection-throttle=0", "spawn-protection=0", "server-port=${port.get()}") + listOf("online-mode=false", "connection-throttle=0", "spawn-protection=0", "server-port=${port.get()}", "enable-rcon=true", "rcon.port=25575", "rcon.password=plugwright") ) } diff --git a/runner-package/cli.ts b/runner-package/cli.ts index 566361a..4618230 100644 --- a/runner-package/cli.ts +++ b/runner-package/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { runTestSession, runPingSession, runCleanupSession } from './runner.js'; +import { runTestSession, runPingSession } from './runner.js'; const argv = process.argv.slice(2); @@ -9,10 +9,6 @@ async function main(): Promise { await runPingSession(); return; } - if (argv.includes('--cleanup')) { - await runCleanupSession(); - return; - } await runTestSession(); } diff --git a/runner-package/lib/admin-bot-console.ts b/runner-package/lib/admin-bot-console.ts deleted file mode 100644 index 6a962e8..0000000 --- a/runner-package/lib/admin-bot-console.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ServerConsole } from './console.js'; -import type { Session } from './session.js'; -import type { BotConnectionOptions } from './environment.js'; -import type { Account } from './account.js'; -import { PlayerWrapper } from './player.js'; -import { sleep } from './utils.js'; - -/** - * A second mineflayer bot with staff rights, used as a console channel when nothing lower- - * level (RCON) is available. Commands go out through chat; responses are read back from this - * bot's own `PlayerWrapper.messageBuffer` — already isolated per bot, so console traffic - * naturally never mixes with a test player's chat log without this class keeping a second - * copy of the same lines. - * - * Connects lazily, on the first `probe()`: that's also where authentication happens, through - * the exact same `PlayerWrapper.join()` → `session.onPlayerCreate` path a test bot goes - * through, so a plugin's login flow applies here unmodified. - */ -export class AdminBotConsole implements ServerConsole { - readonly kind = 'admin-bot' as const; - readonly output = 'responses' as const; - - private player: PlayerWrapper | null = null; - - constructor( - private readonly session: Session, - private readonly connOpts: BotConnectionOptions, - private readonly identity: { username: string; password?: string }, - ) {} - - async probe(): Promise { - if (this.player) return true; - try { - const bot = this.session.createBot({ ...this.connOpts, username: this.identity.username }); - - const player = new PlayerWrapper(bot, this.session); - player._captureSpawnPromise(); - player._setBotOptions(this.connOpts); - const account: Account = { - username: this.identity.username, - password: this.identity.password, - auth: this.connOpts.auth === 'microsoft' ? 'microsoft' : 'offline', - justCreated: false, - }; - player._setAccount(account); - - await player.join(); - this.player = player; - return true; - } catch (error) { - console.warn(`[console] admin-bot probe failed: ${(error as Error).message}`); - return false; - } - } - - execute(cmd: string): void { - if (!this.player) throw new Error('admin-bot console is not connected'); - this.player.chat(toChatCommand(cmd)); - } - - async executeAndWait(cmd: string, timeoutMs: number = 5000): Promise { - if (!this.player) throw new Error('admin-bot console is not connected'); - const buffer = this.player.messageBuffer; - const since = buffer.length; - this.execute(cmd); - - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const lines = buffer.slice(since); - if (lines.length > 0) return lines.join('\n'); - await sleep(50); - } - throw new Error(`admin-bot console command timed out: ${cmd}`); - } -} - -/** stdio-style console commands use `minecraft:`; a chat-based console needs a leading - * slash instead. */ -function toChatCommand(cmd: string): string { - const stripped = cmd.startsWith('minecraft:') ? cmd.slice('minecraft:'.length) : cmd; - return stripped.startsWith('/') ? stripped : `/${stripped}`; -} diff --git a/runner-package/lib/config.ts b/runner-package/lib/config.ts index cd1f791..9665c66 100644 --- a/runner-package/lib/config.ts +++ b/runner-package/lib/config.ts @@ -66,9 +66,6 @@ export interface RunnerConfig { tests: TestsConfig; reports?: ReportsConfig | null; plugins?: PluginConfig[] | null; - /** Crash-recovery journal path for `Session.journal`. Omitted disables on-disk - * persistence — journal entries only survive within the process. */ - journal?: string | null; } /** Settings of the built-in `local` mode, which spawns its own Paper server. */ @@ -80,6 +77,8 @@ export interface LocalEnvironmentConfig { minecraftVersion?: string | null; host?: string | null; port?: number | null; + rconPort?: number | null; + rconPassword?: string | null; } /** diff --git a/runner-package/lib/console.ts b/runner-package/lib/console.ts index ba0d585..056a40a 100644 --- a/runner-package/lib/console.ts +++ b/runner-package/lib/console.ts @@ -4,11 +4,10 @@ * admin bot) are added by later modes. */ export interface ServerConsole { - readonly kind: 'stdio' | 'rcon' | 'admin-bot'; + readonly kind: 'stdio' | 'rcon'; /** How much of the server's output this channel can see. Matchers must check this, * not just whether a console exists, or tests silently stop working on `'responses'`/`'none'`. */ readonly output: 'full' | 'responses' | 'none'; probe(): Promise; - execute(cmd: string): void; - executeAndWait(cmd: string, timeoutMs?: number): Promise; + execute(cmd: string, timeoutMs?: number): Promise; } diff --git a/runner-package/lib/environment.ts b/runner-package/lib/environment.ts index 61e43c6..017e8d1 100644 --- a/runner-package/lib/environment.ts +++ b/runner-package/lib/environment.ts @@ -11,7 +11,6 @@ export interface EnvironmentCapabilities { freshState: boolean; arbitraryUsernames: boolean; lifecycle: boolean; - cleanupStrategy: 'wipe' | 'compensating' | 'none'; } export interface BotConnectionOptions { diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts index 8a24fbc..e4ccb66 100644 --- a/runner-package/lib/environments/external.ts +++ b/runner-package/lib/environments/external.ts @@ -6,11 +6,10 @@ import type { SecretRef } from '../config.js'; import { resolveSecret } from '../config.js'; import { AccountPool } from '../account.js'; import type { AccountsConfig } from '../account.js'; -import { AdminBotConsole } from '../admin-bot-console.js'; import { sleep, importOptionalPackage } from '../utils.js'; export interface ExternalConsoleChannelConfig { - kind: 'rcon' | 'adminBot'; + kind: 'rcon'; port?: number; username?: string; password?: SecretRef; @@ -34,7 +33,6 @@ const BASE_CAPABILITIES: EnvironmentCapabilities = { freshState: false, arbitraryUsernames: true, lifecycle: false, - cleanupStrategy: 'compensating', }; /** @@ -65,11 +63,9 @@ class ExternalEnvironment implements Environment { return this.accountPool; } - async setup(session: Session): Promise { - const connOpts = this.connection(); - + async setup(_session: Session): Promise { for (const channel of this.config.console ?? []) { - const candidate = await this.buildChannel(channel, session, connOpts); + const candidate = await this.buildChannel(channel); if (!candidate) continue; try { if (await candidate.probe()) { @@ -98,8 +94,6 @@ class ExternalEnvironment implements Environment { private async buildChannel( channel: ExternalConsoleChannelConfig, - session: Session, - connOpts: BotConnectionOptions, ): Promise { if (channel.kind === 'rcon') { // A bare string literal here would make tsc try to resolve @@ -131,13 +125,6 @@ class ExternalEnvironment implements Environment { }); } - if (channel.kind === 'adminBot') { - return new AdminBotConsole(session, connOpts, { - username: channel.username!, - password: channel.password ? resolveSecret(channel.password) : undefined, - }); - } - return null; } diff --git a/runner-package/lib/environments/local.ts b/runner-package/lib/environments/local.ts index 6b05dd5..3555e1c 100644 --- a/runner-package/lib/environments/local.ts +++ b/runner-package/lib/environments/local.ts @@ -1,10 +1,10 @@ import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; -import { randomUUID } from 'node:crypto'; import pc from 'picocolors'; import type { Environment, EnvironmentCapabilities, BotConnectionOptions } from '../environment.js'; import type { ServerConsole } from '../console.js'; import type { LocalEnvironmentConfig } from '../config.js'; import type { Session } from '../session.js'; +import { importOptionalPackage } from '../utils.js'; const CAPABILITIES: EnvironmentCapabilities = { console: true, @@ -13,57 +13,16 @@ const CAPABILITIES: EnvironmentCapabilities = { freshState: true, arbitraryUsernames: true, lifecycle: true, - cleanupStrategy: 'wipe', }; -/** Talks to the Paper process over its stdin/stdout, same as the runner always has. */ -class StdioConsole implements ServerConsole { - readonly kind = 'stdio' as const; - readonly output = 'full' as const; - - constructor( - private readonly serverProcess: ChildProcessWithoutNullStreams, - private readonly session: Session, - ) {} - - async probe(): Promise { - return this.serverProcess.exitCode === null && !this.serverProcess.killed; - } - - execute(cmd: string): void { - console.log(`${pc.yellow('[Server]')} ${pc.dim(`Executing: ${cmd}`)}`); - this.serverProcess.stdin.write(cmd + '\n', (err) => { - if (err) console.error(`[Server] Write error: ${err}`); - }); - } - - /** stdio has no synchronous response channel, so we round-trip through a `/say` marker - * and poll the console log for it, the same trick `PlayerWrapper.executeAndSync` uses. - * Returns all lines produced between command submission and the sync marker. - */ - async executeAndWait(cmd: string, timeoutMs: number = 5000): Promise { - const syncId = `sync_${randomUUID().split('-')[0]}`; - const since = this.session.consoleLog.length; - this.execute(cmd); - this.execute(`say ${syncId}`); - - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const recent = this.session.consoleLog.slice(since); - const syncIdx = recent.findIndex(l => l.includes(syncId)); - if (syncIdx !== -1) { - return recent.slice(0, syncIdx).join('\n'); - } - await new Promise(resolve => setTimeout(resolve, 50)); - } - throw new Error(`Console command sync timed out for: ${cmd}`); - } -} - /** * The mode that's been here all along: download Paper, patch configs (Gradle side), - * spawn it, tear it down. Behavior is unchanged from the pre-Session runner.ts — - * this class just gives it a home that isn't the top-level function body. + * spawn it, tear it down. + * + * Commands are sent over RCON (the same protocol external mode uses) for reliable + * command-response correlation. The full server log is still captured via stdout/stderr + * so `expect(server).toHaveReceivedMessage(...)` keeps working — that's what + * `consoleOutput: 'full'` means. */ export class LocalEnvironment implements Environment { readonly id = 'local'; @@ -73,6 +32,7 @@ export class LocalEnvironment implements Environment { private serverProcess: ChildProcessWithoutNullStreams | null = null; private session: Session | null = null; private cleanupStarted = false; + private _rconConsole: ServerConsole | null = null; constructor(config: LocalEnvironmentConfig) { this.config = config; @@ -100,8 +60,69 @@ export class LocalEnvironment implements Environment { await this._waitForServerStart(serverProcess); console.log(`${pc.green(pc.bold('Server started successfully'))}\n`); + // stdout/stderr continue to feed the full console log — this is what makes + // `consoleOutput: 'full'` true and `expect(server).toHaveReceivedMessage` work. serverProcess.stdout.on('data', (data: Buffer) => session.writeConsoleOutput(data)); serverProcess.stderr.on('data', (data: Buffer) => session.writeConsoleOutput(data)); + + // Connect to the local server's RCON for sending commands. RCON gives a proper + // synchronous response per command, unlike the old stdin `/say ` trick. + await this._connectRcon(); + } + + /** + * Dynamically imports `@plugwright/console-rcon` and connects to the local server. + * This is the same import path `ExternalEnvironment.buildChannel` uses, so the + * protocol handling is shared. + */ + private async _connectRcon(): Promise { + const rconPackage = '@plugwright/console-rcon'; + let mod: any; + try { + mod = await importOptionalPackage(rconPackage); + } catch (error) { + throw new Error( + 'Local mode now uses RCON for command execution. The "@plugwright/console-rcon" package ' + + 'must be installed alongside "@plugwright/runner". If you are using the Gradle plugin, ' + + 'run a clean build to have it installed automatically.\n' + + `(${(error as Error).message})` + ); + } + + const factory = mod.rconConsole ?? mod.default; + if (typeof factory !== 'function') { + throw new Error('"@plugwright/console-rcon" has no "rconConsole" export'); + } + + const rconConsole: ServerConsole = factory({ + host: this.config.host ?? 'localhost', + port: this.config.rconPort ?? 25575, + password: this.config.rconPassword ?? 'plugwright', + }); + + // RCON may need a moment after the server logs "Done" — retry a few times. + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + if (await rconConsole.probe()) { + this._rconConsole = rconConsole; + console.log(pc.green(`[local] RCON connected (port ${this.config.rconPort ?? 25575})`)); + return; + } + } catch (error) { + if (attempt === maxAttempts) { + throw new Error( + `RCON failed to connect to the local server after ${maxAttempts} attempts. ` + + 'Make sure enable-rcon=true is set in server.properties (the Gradle plugin does ' + + `this automatically). (${(error as Error).message})` + ); + } + } + // Wait before retrying — RCON listener may start slightly after the game loop. + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + throw new Error('RCON probe returned false on all attempts'); } connection(): BotConnectionOptions { @@ -114,14 +135,14 @@ export class LocalEnvironment implements Environment { } console(): ServerConsole | null { - if (!this.serverProcess || !this.session) return null; - return new StdioConsole(this.serverProcess, this.session); + return this._rconConsole; } async teardown(): Promise { const serverProcess = this.serverProcess; if (!serverProcess) return; + // Send `stop` through stdin — reliable even if RCON has already disconnected. if (serverProcess.exitCode === null && !serverProcess.killed) { try { serverProcess.stdin.write('stop\n'); diff --git a/runner-package/lib/journal.ts b/runner-package/lib/journal.ts deleted file mode 100644 index 845c73b..0000000 --- a/runner-package/lib/journal.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; -import { dirname } from 'path'; - -/** - * A crash-survivable record of one cleanup obligation. Typed and interpreted by a plugin's - * `cleanup({ scope: 'manual' })` handler — never a raw command string. A journal that - * replayed arbitrary strings would be a way to run arbitrary commands against a live server - * the next time someone runs `plugwrightClean`. - */ -export interface JournalEntry { - kind: string; - [key: string]: unknown; -} - -/** - * Append-only log of pending cleanup obligations, for the case where a test's `finally` - * never runs (SIGKILL, crashed process). `record()`/`forget()` bracket a normal, LIFO - * `TestContext.cleanup()` finalizer; whatever's still in the file when the process dies - * survived a crash and is replayed by the next run, or by a manual `plugwrightClean`. - * - * A plain JS closure can't be serialized to a file, so only entries explicitly journaled as - * a typed record (not a function) survive a crash — this is a lower-level, opt-in companion - * to `TestContext.cleanup()`, not a transparent upgrade of it. - */ -export class CleanupJournal { - private readonly path: string | null; - private readonly pending = new Map(); - - constructor(path: string | null) { - this.path = path; - if (!this.path || !existsSync(this.path)) return; - - for (const line of readFileSync(this.path, 'utf8').split('\n')) { - if (!line.trim()) continue; - try { - const { id, entry } = JSON.parse(line) as { id: string; entry: JournalEntry | null }; - if (entry === null) this.pending.delete(id); - else this.pending.set(id, entry); - } catch { - // A line torn mid-write by a crash. Skip it rather than fail the whole run. - } - } - } - - /** Entries a prior run recorded but never forgot — leftovers from a crash. */ - outstanding(): JournalEntry[] { - return [...this.pending.values()]; - } - - record(id: string, entry: JournalEntry): void { - this.pending.set(id, entry); - this._append({ id, entry }); - } - - forget(id: string): void { - if (!this.pending.delete(id)) return; - this._append({ id, entry: null }); - } - - private _append(line: { id: string; entry: JournalEntry | null }): void { - if (!this.path) return; - mkdirSync(dirname(this.path), { recursive: true }); - appendFileSync(this.path, JSON.stringify(line) + '\n', 'utf8'); - } -} diff --git a/runner-package/lib/matchers.ts b/runner-package/lib/matchers.ts index a18e058..34eec38 100644 --- a/runner-package/lib/matchers.ts +++ b/runner-package/lib/matchers.ts @@ -212,9 +212,9 @@ interface PollOptions { } export class PollMatchers { - private fn: () => T | Promise; - private options: Required> & { message?: string }; - private isNot: boolean; + private readonly fn: () => T | Promise; + private readonly options: Required> & { message?: string }; + private readonly isNot: boolean; constructor(fn: () => T | Promise, options: PollOptions = {}, isNot: boolean = false) { this.fn = fn; diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index cb9e8ef..2376ea4 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -46,12 +46,6 @@ export class PlayerWrapper { private _spawnPromise: Promise | null = null; private _listenersBot: Bot | null = null; private _account?: Account; - /** Labels describing server state this player is known to carry — set automatically by - * `makeOp`/`deOp`/`setGameMode`, and by hand via `mark`/`unmark` for anything else. Survives - * `rejoin()`: it describes server state, which a reconnect doesn't touch. Nothing in the - * core reads a label's meaning; they exist for a test (or a plugin) to leave a note on a - * player one step of a `describe.serial` block can read in the next. */ - private readonly _abilities = new Set(); constructor(bot: Bot, session: Session) { this.bot = bot; @@ -204,29 +198,6 @@ export class PlayerWrapper { this.serverWrapper = server; } - /** Read-only snapshot of this player's ability labels. */ - get abilities(): ReadonlySet { - return this._abilities; - } - - /** Records that this player carries `ability`. A statement, not a check — nothing here - * verifies it against real server state. */ - mark(ability: string): void { - this._abilities.add(ability); - } - - /** Removes `ability`. No-op if the player never carried it. */ - unmark(ability: string): void { - this._abilities.delete(ability); - } - - private markGameMode(mode: string): void { - for (const ability of this._abilities) { - if (ability.startsWith('gamemode:')) this._abilities.delete(ability); - } - this._abilities.add(`gamemode:${mode}`); - } - getCurrentGui(): GuiWrapper | null { let currentWindow = this.bot.currentWindow; return currentWindow ? new GuiWrapper(this.bot, currentWindow as Window) : null; @@ -286,60 +257,33 @@ export class PlayerWrapper { async makeOp(): Promise { this.requireServer(); - const command = `minecraft:op ${this.username}`; - - // A console that answers (RCON) says whether the command worked; the confirmation is - // never broadcast to the player, so there is nothing to wait for in the chat buffer. - if (this.session.console?.output === 'responses') { - const response = await this.serverWrapper!.executeAndWait(command); - // "Made X a server operator" on success, "Nothing changed. The player already is - // an operator" when it was already granted — both mean the player is op now. - if (/operator/i.test(response)) { - this.mark('op'); - return; - } + const response = await this.serverWrapper!.execute(`minecraft:op ${this.username}`); + if (!/operator/i.test(response) && !/nothing changed/i.test(response)) { throw new Error(`Player ${this.username} was not opped: ${response.trim() || 'no response from the console'}`); } - - const messagesSince = this.messageBuffer.length; - const consoleSince = this.session.consoleLog.length; - this.serverWrapper!.execute(command); - - // "Made X a server operator" reaches the player's own chat. "Nothing changed. The - // player already is an operator" — the case a reused, already-op player hits on a - // second `makeOp()` — never does; it only ever shows up in the server's own log. - await poll( - () => - this.messageBuffer.slice(messagesSince).find(m => m.includes(`Made ${this.username} a server operator`)) ?? - this.session.consoleLog.slice(consoleSince).find(m => /operator/i.test(m)), - { message: `Player ${this.username} was not opped` } - ); - this.mark('op'); } async deOp(): Promise { - await this.executeAndSync(`minecraft:deop ${this.username}`); - this.unmark('op'); + this.requireServer(); + await this.serverWrapper!.execute(`minecraft:deop ${this.username}`); } async setGameMode(mode: 'survival' | 'creative' | 'adventure' | 'spectator'): Promise { if (this.bot.game.gameMode === mode) { - this.markGameMode(mode); return; } this.requireServer(); - this.serverWrapper!.execute(`minecraft:gamemode ${mode} ${this.username}`); + await this.serverWrapper!.execute(`minecraft:gamemode ${mode} ${this.username}`); await poll( () => this.bot.game.gameMode === mode ? true : undefined, { message: `Game mode did not change to "${mode}"` } ); - this.markGameMode(mode); } async teleport(x: number, y: number, z: number): Promise { this.requireServer(); - this.serverWrapper!.execute(`minecraft:tp ${this.username} ${x} ${y} ${z}`); + await this.serverWrapper!.execute(`minecraft:tp ${this.username} ${x} ${y} ${z}`); await poll( () => { @@ -396,7 +340,7 @@ export class PlayerWrapper { async giveItem(item: string, count: number = 1): Promise { this.requireServer(); - this.serverWrapper!.execute(`minecraft:give ${this.username} ${item} ${count}`); + await this.serverWrapper!.execute(`minecraft:give ${this.username} ${item} ${count}`); await poll( () => { @@ -425,7 +369,7 @@ export class PlayerWrapper { const timeout = opts.timeout ?? 5000; if (item) { - this.serverWrapper!.execute(`minecraft:clear ${this.username} ${item}`); + await this.serverWrapper!.execute(`minecraft:clear ${this.username} ${item}`); await waitUntil( () => !this.bot.inventory.items().some(i => i.name.includes(item)), { @@ -434,7 +378,7 @@ export class PlayerWrapper { } ); } else { - this.serverWrapper!.execute(`minecraft:clear ${this.username}`); + await this.serverWrapper!.execute(`minecraft:clear ${this.username}`); await waitUntil( () => this.bot.inventory.items().length === 0, { @@ -450,25 +394,4 @@ export class PlayerWrapper { throw new Error('ServerWrapper not set on PlayerWrapper'); } } - - private async executeAndSync(cmd: string): Promise { - this.requireServer(); - - // A console that answers has already finished the command by the time it replies. The - // marker below exists for the stdio console, where output and command completion are - // two unrelated streams. - if (this.session.console?.output === 'responses') { - await this.serverWrapper!.executeAndWait(cmd); - return; - } - - const syncId = `sync_${randomUUID().split('-')[0]}`; - this.serverWrapper!.execute(cmd); - this.serverWrapper!.execute(`minecraft:say ${syncId}`); - - await poll( - () => this.messageBuffer.find(m => m.includes(syncId)), - { message: `Server command sync timed out for: ${cmd}` } - ); - } } \ No newline at end of file diff --git a/runner-package/lib/plugin-host.ts b/runner-package/lib/plugin-host.ts index d1c6da6..b8780c4 100644 --- a/runner-package/lib/plugin-host.ts +++ b/runner-package/lib/plugin-host.ts @@ -115,10 +115,10 @@ export class PluginHost { ); } - async runCleanup(session: Session, scope: 'session' | 'manual'): Promise { + async runCleanup(session: Session): Promise { for (const { plugin } of [...this.plugins].reverse()) { try { - await plugin.cleanup?.({ session, scope }); + await plugin.cleanup?.({ session }); } catch (error) { console.error(pc.red(`[plugin ${plugin.name}] cleanup error: ${(error as Error).message}`)); } diff --git a/runner-package/lib/plugin.ts b/runner-package/lib/plugin.ts index 352666c..fe58c4e 100644 --- a/runner-package/lib/plugin.ts +++ b/runner-package/lib/plugin.ts @@ -17,9 +17,6 @@ export interface SessionContext { export interface CleanupContext { session: Session; - /** 'session' — after the run finishes; 'manual' — a dedicated cleanup invocation - * (e.g. `plugwrightClean` for a mode with a compensating cleanup strategy). */ - scope: 'session' | 'manual'; } export interface PluginTestRef { diff --git a/runner-package/lib/server.ts b/runner-package/lib/server.ts index 65bfd53..4a75fb5 100644 --- a/runner-package/lib/server.ts +++ b/runner-package/lib/server.ts @@ -19,22 +19,13 @@ export class ServerWrapper { this.startIndex = this.session.consoleLog.length; } - /** Executes a console command synchronously. Note: when running under `concurrency: N`, + /** Executes a console command and resolves with the server's response. Note: when running under `concurrency: N`, * console output is shared across all concurrent tests. Prefer player actions or qualify * commands with `player.username`. */ - execute(cmd: string): void { + execute(cmd: string, timeoutMs?: number): Promise { if (!this.session.console) { throw new Error('No server console available for this environment'); } - this.session.console.execute(cmd); - } - - /** Runs a command and resolves with whatever the console gives back. A console with - * `output: 'none'` has nothing to give back and resolves empty. */ - executeAndWait(cmd: string, timeoutMs?: number): Promise { - if (!this.session.console) { - throw new Error('No server console available for this environment'); - } - return this.session.console.executeAndWait(cmd, timeoutMs); + return this.session.console.execute(cmd, timeoutMs); } } diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts index a539435..7d3b891 100644 --- a/runner-package/lib/session.ts +++ b/runner-package/lib/session.ts @@ -1,6 +1,5 @@ import mineflayer, { Bot } from 'mineflayer'; import pc from 'picocolors'; -import { CleanupJournal } from './journal.js'; import type { Environment, BotConnectionOptions } from './environment.js'; import type { ServerConsole } from './console.js'; import type { PlayerWrapper } from './player.js'; @@ -55,16 +54,14 @@ export class Session { console: ServerConsole | null = null; readonly bots: Bot[] = []; readonly consoleLog = new MessageBuffer(); - readonly journal: CleanupJournal; /** Set once by the runner after loading plugins. Fired by `PlayerWrapper.join()` on * every connection (initial join and every `rejoin()`), not called directly by * `Session` itself. */ onPlayerCreate: ((player: PlayerWrapper, ctx: { account: Account; env: Environment }) => Promise | void) | null = null; - constructor(env: Environment, journalPath: string | null = null) { + constructor(env: Environment) { this.env = env; - this.journal = new CleanupJournal(journalPath); } /** Pulls the console channel from the environment. Called once `env.setup()` has produced one. */ diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 40a4819..2749963 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -138,9 +138,7 @@ export function opTest(name: string, fnOrOptions: TestFn | TestOptions, maybeFn? const options = typeof fnOrOptions === 'function' ? {} : fnOrOptions; const fn = typeof fnOrOptions === 'function' ? fnOrOptions : maybeFn!; registerTest(name, options, async (context: TestContext) => { - // The label is what a player already opped earlier in the same block carries, so a - // second `opTest` in one `describe.serial` doesn't re-run the command. - if (!context.player.abilities.has('op')) await context.player.makeOp(); + await context.player.makeOp(); await fn(context); }); } diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index bad1ea5..fcacaff 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -250,6 +250,7 @@ export async function runTestCase(params: RunTestCaseParams): Promise bots.createPlayer(options), invalidatePlayer: () => { /* nothing follows this test — see the serial-block runner */ }, signal: abort.signal, @@ -352,6 +353,7 @@ export async function runSerialBlock(params: RunSerialBlockParams): Promise bots.createPlayer(options), invalidatePlayer: (p, reason) => { if (p === player) invalidatedBy = reason ?? `invalidated by "${testCase.name}"`; diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 90e8989..1eec0c1 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -1,9 +1,14 @@ import type { PlayerWrapper } from './player.js'; import type { ServerWrapper } from './server.js'; +import type { Environment } from './environment.js'; export interface TestContext { player: PlayerWrapper; server: ServerWrapper; + /** The environment this test is running against. Use `env.id` to check whether + * you're on `'local'` or `'external'`, and `env.capabilities` to inspect what + * the environment supports. */ + env: Environment; /** Connects an extra bot. Inside a `describe.serial` block, `as` names it: the same name in * a later test of that block returns the same bot instead of connecting another. Outside a * block the name is scoped to the one test, which is as long as the bot lives anyway. diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 5d911fd..20f5ee8 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -43,9 +43,6 @@ export { definePlugin, PLUGIN_API_VERSION } from './lib/plugin.js'; export type { PlugwrightPlugin, SessionContext, CleanupContext, PluginTestRef, MatcherFn } from './lib/plugin.js'; export { AccountPool } from './lib/account.js'; export type { Account, AccountsConfig } from './lib/account.js'; -export { AdminBotConsole } from './lib/admin-bot-console.js'; -export { CleanupJournal } from './lib/journal.js'; -export type { JournalEntry } from './lib/journal.js'; export { externalEnvironment }; export type { ExternalEnvironmentConfig, ExternalConsoleChannelConfig } from './lib/environments/external.js'; @@ -101,7 +98,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): const testResults: TestResult[] = []; const env = await resolveEnvironment(config.environment); - const session = new Session(env, config.journal ?? null); + const session = new Session(env); const plugins = new PluginHost(); await plugins.load(config.plugins ?? []); // Must happen before the first spec file is imported — see PluginHost.registerMatchers. @@ -289,7 +286,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): } } finally { - await plugins.runCleanup(session, 'session'); + await plugins.runCleanup(session); await plugins.teardown(); await session.disconnectAllBots(); await env.teardown(); @@ -324,7 +321,7 @@ export async function runPingSession(config: RunnerConfig = loadRunnerConfig()): console.log(pc.bold(`plugwright ping: environment "${config.environment.name}" (${config.environment.mode})`)); const env = await resolveEnvironment(config.environment); - const session = new Session(env, null); + const session = new Session(env); const plugins = new PluginHost(); await plugins.load(config.plugins ?? []); plugins.registerMatchers(); @@ -391,49 +388,3 @@ export async function runPingSession(config: RunnerConfig = loadRunnerConfig()): setTimeout(() => process.exit(exitCode), 500).unref(); } -/** - * `--cleanup`: runs every loaded plugin's `cleanup({ scope: 'manual' })` handler and reports - * what the crash-recovery journal still has outstanding afterward. Replaying journal entries - * is the plugin's job — it owns what a typed entry means — this only gives it the chance. - */ -export async function runCleanupSession(config: RunnerConfig = loadRunnerConfig()): Promise { - console.log(pc.bold(`plugwright cleanup: environment "${config.environment.name}"`)); - - const env = await resolveEnvironment(config.environment); - const session = new Session(env, config.journal ?? null); - const plugins = new PluginHost(); - await plugins.load(config.plugins ?? []); - plugins.registerMatchers(); - - let exitCode = 0; - try { - const outstandingBefore = session.journal.outstanding(); - console.log(pc.dim(`journal: ${outstandingBefore.length} outstanding entr${outstandingBefore.length === 1 ? 'y' : 'ies'}`)); - - await env.setup(session); - session.refreshConsole(); - await plugins.setup(session); - - await plugins.runCleanup(session, 'manual'); - - const outstandingAfter = session.journal.outstanding(); - if (outstandingAfter.length > 0) { - console.log(pc.yellow(`journal: ${outstandingAfter.length} entr${outstandingAfter.length === 1 ? 'y' : 'ies'} still outstanding after cleanup`)); - for (const entry of outstandingAfter) console.log(pc.yellow(` - ${JSON.stringify(entry)}`)); - } else { - console.log(pc.green('journal: clean')); - } - } catch (error) { - console.error(pc.red(`cleanup failed: ${(error as Error).message}`)); - exitCode = 1; - } finally { - await plugins.teardown(); - await session.disconnectAllBots(); - await env.teardown(); - } - - // Both: the unref'd timer only fires if something else is still holding the loop - // open (a lingering socket); process.exitCode carries the result when it isn't. - process.exitCode = exitCode; - setTimeout(() => process.exit(exitCode), 500).unref(); -} From 1a1fbab37fc3e21469070bf9b519117f87c3789a Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 13:58:16 +0200 Subject: [PATCH 02/13] fix(gradle-plugin): remove dead AdminBot config & pass RCON config dynamically --- .../me/drownek/plugwright/external/ConsoleSpec.kt | 14 ++------------ .../me/drownek/plugwright/external/ExternalMode.kt | 7 ------- .../me/drownek/plugwright/local/LocalMode.kt | 2 ++ .../drownek/plugwright/local/PaperProvisionTask.kt | 12 +++++++++--- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt index 6033300..0d44f15 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt @@ -14,18 +14,12 @@ sealed class ConsoleChannelSpec { val port: Property = objects.property(Int::class.java).convention(25575) val password: Property = objects.property(SecretRef::class.java) } - - /** `console { adminBot("StaffBot") { password.set(secret.env("STAFF_PASS")) } }`. A second - * mineflayer bot with staff rights, sending commands through chat. */ - class AdminBot(val username: String, objects: ObjectFactory) : ConsoleChannelSpec() { - val password: Property = objects.property(SecretRef::class.java) - } } /** - * `console { rcon { ... }; adminBot("Name") { ... } }`. + * `console { rcon { ... } }`. * - * Declaring neither channel is valid — the environment just runs without a console, and any + * Declaring no channel is valid — the environment just runs without a console, and any * test requiring one is skipped and reported as such. */ class ConsoleSpec(private val objects: ObjectFactory) { @@ -34,8 +28,4 @@ class ConsoleSpec(private val objects: ObjectFactory) { fun rcon(action: ConsoleChannelSpec.Rcon.() -> Unit) { channels.add(ConsoleChannelSpec.Rcon(objects).apply(action)) } - - fun adminBot(username: String, action: ConsoleChannelSpec.AdminBot.() -> Unit) { - channels.add(ConsoleChannelSpec.AdminBot(username, objects).apply(action)) - } } diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt index 6d4c5e8..f65978c 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt @@ -49,8 +49,6 @@ object ExternalMode : PlugwrightMode { when (channel) { is ConsoleChannelSpec.Rcon -> if (!channel.password.isPresent) ctx.error("console.rcon.password must be set") - is ConsoleChannelSpec.AdminBot -> - if (!channel.password.isPresent) ctx.error("console.adminBot(\"${channel.username}\").password must be set") } } } @@ -70,11 +68,6 @@ object ExternalMode : PlugwrightMode { put("port", channel.port.get()) put("password", channel.password.get()) } - is ConsoleChannelSpec.AdminBot -> { - put("kind", "adminBot") - put("username", channel.username) - put("password", channel.password.get()) - } } } } diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt index 98d057f..b48ca30 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt @@ -84,6 +84,8 @@ object LocalMode : PlugwrightMode { pluginJar.set(ctx.projectPluginJar) pluginUrls.set(spec.pluginUrls) runDirFiles.set(spec.runDirFiles) + rconPort.set(spec.rconPort) + rconPassword.set(spec.rconPassword) } val javaLauncherProvider: Provider? = run { diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt index 60bb41a..780a896 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt @@ -34,6 +34,12 @@ abstract class PaperProvisionTask : DefaultTask() { @get:Input abstract val port: Property + @get:Input + abstract val rconPort: Property + + @get:Input + abstract val rconPassword: Property + @get:Input @get:Optional abstract val pluginJar: Property @@ -128,8 +134,8 @@ abstract class PaperProvisionTask : DefaultTask() { // Enable RCON so the runner can send commands over a proper protocol val rconProperties = mapOf( "enable-rcon" to "true", - "rcon.port" to "25575", - "rcon.password" to "plugwright" + "rcon.port" to rconPort.get().toString(), + "rcon.password" to rconPassword.get() ) for ((key, value) in rconProperties) { val hasKey = lines.any { it.trim().startsWith("$key=") } @@ -147,7 +153,7 @@ abstract class PaperProvisionTask : DefaultTask() { logger.lifecycle("Creating server.properties with online-mode=false, connection-throttle=0, spawn-protection=0, enable-rcon=true and server-port=${port.get()}") Files.write( serverProperties.toPath(), - listOf("online-mode=false", "connection-throttle=0", "spawn-protection=0", "server-port=${port.get()}", "enable-rcon=true", "rcon.port=25575", "rcon.password=plugwright") + listOf("online-mode=false", "connection-throttle=0", "spawn-protection=0", "server-port=${port.get()}", "enable-rcon=true", "rcon.port=${rconPort.get()}", "rcon.password=${rconPassword.get()}") ) } From 6b7881e2c9d8fcc2bf3755ff41f108f9b7601b55 Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 13:58:30 +0200 Subject: [PATCH 03/13] fix(rcon): handle authentication failure cleanly & export RconConnection --- console-rcon-package/index.ts | 6 ++++++ console-rcon-package/lib/rcon-connection.ts | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/console-rcon-package/index.ts b/console-rcon-package/index.ts index 8be5683..420fb86 100644 --- a/console-rcon-package/index.ts +++ b/console-rcon-package/index.ts @@ -29,5 +29,11 @@ export function rconConsole(config: RconConsoleConfig): ServerConsole { async execute(cmd: string, timeoutMs: number = 5000): Promise { return connection.executeAndWait(cmd, timeoutMs); }, + + close(): void { + connection.disconnect(); + } }; } + +export { RconConnection }; diff --git a/console-rcon-package/lib/rcon-connection.ts b/console-rcon-package/lib/rcon-connection.ts index 86f6d6b..6ae7600 100644 --- a/console-rcon-package/lib/rcon-connection.ts +++ b/console-rcon-package/lib/rcon-connection.ts @@ -81,8 +81,14 @@ export class RconConnection { if (packet.type === PacketType.AUTH_RESPONSE && this.pendingAuth) { const waiter = this.pendingAuth; this.pendingAuth = null; - if (packet.id === -1) waiter.reject(new Error('RCON authentication failed: wrong password')); - else waiter.resolve(''); + if (packet.id === -1) { + this.socket?.destroy(); + this.socket = null; + this.connectPromise = null; + waiter.reject(new Error('RCON authentication failed: wrong password')); + } else { + waiter.resolve(''); + } return; } @@ -130,4 +136,12 @@ export class RconConnection { console.error(`[rcon] command failed: ${cmd}: ${error.message}`); }); } + + disconnect(): void { + if (this.socket) { + this.socket.end(); + this.socket = null; + } + this.connectPromise = null; + } } From 248567e25c4752127190a1ab20958c9870810847 Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 13:59:07 +0200 Subject: [PATCH 04/13] fix(runner): properly close RCON socket on environment teardown --- runner-package/lib/console.ts | 4 ++-- runner-package/lib/environments/external.ts | 3 +++ runner-package/lib/environments/local.ts | 19 +++++++++++-------- runner-package/runner.ts | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/runner-package/lib/console.ts b/runner-package/lib/console.ts index 056a40a..c4efab7 100644 --- a/runner-package/lib/console.ts +++ b/runner-package/lib/console.ts @@ -1,7 +1,6 @@ /** * A channel for sending admin commands to the server and reading its output. - * `local` speaks to the Paper process over stdio; other channels (RCON, an - * admin bot) are added by later modes. + * e.g., RCON or stdio. */ export interface ServerConsole { readonly kind: 'stdio' | 'rcon'; @@ -10,4 +9,5 @@ export interface ServerConsole { readonly output: 'full' | 'responses' | 'none'; probe(): Promise; execute(cmd: string, timeoutMs?: number): Promise; + close?(): void | Promise; } diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts index e4ccb66..b606c89 100644 --- a/runner-package/lib/environments/external.ts +++ b/runner-package/lib/environments/external.ts @@ -153,6 +153,9 @@ class ExternalEnvironment implements Environment { async teardown(): Promise { // No lifecycle: the tested server isn't ours to stop. + if (this._console?.close) { + await this._console.close(); + } } } diff --git a/runner-package/lib/environments/local.ts b/runner-package/lib/environments/local.ts index 3555e1c..e5ba4e9 100644 --- a/runner-package/lib/environments/local.ts +++ b/runner-package/lib/environments/local.ts @@ -102,6 +102,7 @@ export class LocalEnvironment implements Environment { // RCON may need a moment after the server logs "Done" — retry a few times. const maxAttempts = 5; + let lastError: Error | null = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { if (await rconConsole.probe()) { @@ -110,19 +111,17 @@ export class LocalEnvironment implements Environment { return; } } catch (error) { - if (attempt === maxAttempts) { - throw new Error( - `RCON failed to connect to the local server after ${maxAttempts} attempts. ` + - 'Make sure enable-rcon=true is set in server.properties (the Gradle plugin does ' + - `this automatically). (${(error as Error).message})` - ); - } + lastError = error as Error; } // Wait before retrying — RCON listener may start slightly after the game loop. await new Promise(resolve => setTimeout(resolve, 1000)); } - throw new Error('RCON probe returned false on all attempts'); + throw new Error( + `RCON failed to connect to the local server after ${maxAttempts} attempts. ` + + 'Make sure enable-rcon=true is set in server.properties (the Gradle plugin does ' + + `this automatically). ${lastError ? `(${lastError.message})` : ''}` + ); } connection(): BotConnectionOptions { @@ -139,6 +138,10 @@ export class LocalEnvironment implements Environment { } async teardown(): Promise { + if (this._rconConsole?.close) { + await this._rconConsole.close(); + } + const serverProcess = this.serverProcess; if (!serverProcess) return; diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 20f5ee8..4f1b39f 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -104,7 +104,7 @@ export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): // Must happen before the first spec file is imported — see PluginHost.registerMatchers. plugins.registerMatchers(); // Wired before env.setup(): an environment's own console channel can be a bot that needs - // to authenticate during setup() (see AdminBotConsole), which goes through this same hook. + // to authenticate during setup(), which goes through this same hook. session.onPlayerCreate = (player, ctx) => plugins.onPlayerCreate(player, ctx); let exitCode = 0; From 478989650d7a78d58239aba46ad4b71b77835011 Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 13:59:22 +0200 Subject: [PATCH 05/13] test(e2e): await floating server.execute promises --- example_plugin/src/test/e2e/tests/economy.spec.ts | 2 +- example_plugin/src/test/e2e/tests/minigame.spec.ts | 2 +- example_plugin/src/test/e2e/tests/simple-ts.spec.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example_plugin/src/test/e2e/tests/economy.spec.ts b/example_plugin/src/test/e2e/tests/economy.spec.ts index c86a76a..67ac390 100644 --- a/example_plugin/src/test/e2e/tests/economy.spec.ts +++ b/example_plugin/src/test/e2e/tests/economy.spec.ts @@ -6,7 +6,7 @@ test('player starts with default balance', async ({ player }) => { }); test('player can send money', async ({ player, server }) => { - server.execute(`eco give ${player.username} 500`); + await server.execute(`eco give ${player.username} 500`); player.chat('/pay pw_dummy 100'); await expect(player).toHaveReceivedMessage('Sent $100'); diff --git a/example_plugin/src/test/e2e/tests/minigame.spec.ts b/example_plugin/src/test/e2e/tests/minigame.spec.ts index 35dd034..b9846ed 100644 --- a/example_plugin/src/test/e2e/tests/minigame.spec.ts +++ b/example_plugin/src/test/e2e/tests/minigame.spec.ts @@ -11,7 +11,7 @@ test('join arena game', async ({ player }) => { test('cannot join full arena', async ({ player, server }) => { // Fill arena with fake players for (let i = 0; i < 10; i++) { - server.execute(`arena addplayer Player${i}`); + await server.execute(`arena addplayer Player${i}`); } player.chat('/arena join'); diff --git a/example_plugin/src/test/e2e/tests/simple-ts.spec.ts b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts index dbcd92c..f33d032 100644 --- a/example_plugin/src/test/e2e/tests/simple-ts.spec.ts +++ b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts @@ -28,6 +28,6 @@ test('help displays message', async ({ player }) => { // Reading the server log needs a console that streams all of it. An environment whose // console only answers its own commands skips this test instead of failing it. test('server logs command execution', { requires: ['consoleOutput:full'] }, async ({ server }) => { - server.execute('say hello'); + await server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); \ No newline at end of file From bdb573ca4250432e4fbc04aaae8e82c4f5b8d325 Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 13:59:36 +0200 Subject: [PATCH 06/13] docs: remove outdated references to abilities, journal, admin bot and cleanup task --- docs/environments.mdx | 3 +-- docs/external-servers.mdx | 4 ---- runner-package/README.md | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/environments.mdx b/docs/environments.mdx index 78f5ccf..d6cc8c0 100644 --- a/docs/environments.mdx +++ b/docs/environments.mdx @@ -51,13 +51,12 @@ plugwrightProvisionLocal download Paper, patch configs, copy the plugin jar plugwrightCleanLocal wipe the run directory plugwrightRunServerLocal start the server interactively, no tests plugwrightPingStaging check that an external stand answers, no tests -plugwrightCleanStaging compensating cleanup on an external stand plugwrightTestLocal run the suite against one environment plugwrightTestStaging plugwrightTest the matrix: every environment with includeInMatrix ``` -Which tasks exist depends on the mode. `LocalMode` contributes provisioning, cleaning and a server-run task; `ExternalMode` contributes ping and cleanup, and nothing that touches files. +Which tasks exist depends on the mode. `LocalMode` contributes provisioning, cleaning and a server-run task; `ExternalMode` contributes ping, and nothing that touches files. Tasks for the `primaryEnvironment` also get an unsuffixed alias, so `plugwrightRunServer` still means what it used to. `plugwrightTest` is the exception: it belongs to the matrix. diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index 76f7b2c..9280c5a 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -21,7 +21,6 @@ environments { console { rcon { port.set(25575); password.set(secret.env("RCON_PASSWORD")) } - adminBot("StaffBot") { password.set(secret.env("STAFF_PASSWORD")) } } accounts { @@ -56,7 +55,6 @@ Channels are probed in declaration order, and the first one that answers becomes | Channel | Output level | Notes | |---|---|---| | `rcon { }` | `responses` | Needs `enable-rcon=true` on the server. Installs `@plugwright/console-rcon` | -| `adminBot("Name") { }` | `responses` | A second bot with staff rights that sends commands through chat | | stdio | `full` | `LocalMode` only — Plugwright owns the process | The output level matters more than it looks. `full` means the whole server log is readable, so `expect(server).toHaveReceivedMessage(...)` works. `responses` means you get back what the command printed and nothing else. A test that reads the server log should say so: @@ -70,8 +68,6 @@ test('command is logged', { requires: ['consoleOutput:full'] }, async ({ server Declaring no channel at all is valid. The environment runs without a console, and every test that requires one is skipped and reported as skipped. -The admin bot connects through the same code path as a test bot, which means it goes through your authentication plugin too, and it connects before any test bot does. - ## Accounts A local server accepts any username; a stand usually does not. `accounts { }` builds a pool that tests lease from and return to, merged from three sources: diff --git a/runner-package/README.md b/runner-package/README.md index 0e8bb26..16feeef 100644 --- a/runner-package/README.md +++ b/runner-package/README.md @@ -41,7 +41,7 @@ The runner takes a config file describing one environment: npx plugwright --config build/tmp/plugwright/local.json ``` -The Gradle plugin writes that file, but nothing stops you from writing it yourself. `local` starts and stops its own Paper server; `external` connects to one that is already running, with an account pool, a console channel and authentication handled by a plugin. Two service modes exist for the second case: `--ping` checks that the server answers without running tests, and `--cleanup` replays outstanding cleanup work. +The Gradle plugin writes that file, but nothing stops you from writing it yourself. `local` starts and stops its own Paper server; `external` connects to one that is already running, with an account pool, a console channel and authentication handled by a plugin. Two service modes exist for the second case: `--ping` checks that the server answers without running tests. ## Documentation From cd7b1373afe6b469e443a1745330355bb22682fc Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 14:05:15 +0200 Subject: [PATCH 07/13] chore: update lockfiles --- auth-authme-package/package-lock.json | 1 + console-rcon-package/package-lock.json | 1 + package-lock.json | 6 ++++++ 3 files changed, 8 insertions(+) create mode 100644 package-lock.json diff --git a/auth-authme-package/package-lock.json b/auth-authme-package/package-lock.json index 157d1cf..6cb58aa 100644 --- a/auth-authme-package/package-lock.json +++ b/auth-authme-package/package-lock.json @@ -30,6 +30,7 @@ "js-yaml": "^4.1.0", "mineflayer": "^4.0.0", "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" }, "bin": { diff --git a/console-rcon-package/package-lock.json b/console-rcon-package/package-lock.json index db49a3f..a7e05d7 100644 --- a/console-rcon-package/package-lock.json +++ b/console-rcon-package/package-lock.json @@ -30,6 +30,7 @@ "js-yaml": "^4.1.0", "mineflayer": "^4.0.0", "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" }, "bin": { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1ce7483 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "plugwright", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 9dd8d5897b105a153199e8d2d05c93ce81f8e6f0 Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 14:22:24 +0200 Subject: [PATCH 08/13] docs: fix remaining outdated references (await server.execute, AdminBot) --- docs/api-reference.mdx | 2 +- docs/external-servers.mdx | 2 +- docs/test-filtering.mdx | 4 ++-- docs/writing-tests.mdx | 4 ++-- .../me/drownek/plugwright/external/ExternalEnvironmentSpec.kt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/api-reference.mdx b/docs/api-reference.mdx index 5fca846..aef80dd 100644 --- a/docs/api-reference.mdx +++ b/docs/api-reference.mdx @@ -114,7 +114,7 @@ Executes a command from the server console. ```javascript -server.execute(`give ${player.username} diamond 64`); +await server.execute(`give ${player.username} diamond 64`); ``` ## Exported Utilities diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index 9280c5a..2881b52 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -61,7 +61,7 @@ The output level matters more than it looks. `full` means the whole server log i ```ts test('command is logged', { requires: ['consoleOutput:full'] }, async ({ server }) => { - server.execute('say hello'); + await server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); ``` diff --git a/docs/test-filtering.mdx b/docs/test-filtering.mdx index cc52047..0492d7a 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -60,8 +60,8 @@ Capability keys come from the environment's own report, after it has connected: A bare key is satisfied by anything other than `false`, `'none'` or an absent value. To demand one specific value, use `key:value`: ```ts -test('command is logged', { requires: ['consoleOutput:full'] }, async ({ server }) => { - server.execute('say hello'); +test('console matters', { requires: ['consoleOutput:full'] }, async ({ server }) => { + await server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); ``` diff --git a/docs/writing-tests.mdx b/docs/writing-tests.mdx index dc510dc..8c6166a 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -52,7 +52,7 @@ test('player starts with default balance', async ({ player }) => { }); test('player can purchase items', async ({ player, server }) => { - server.execute(`eco give ${player.username} 500`); + await server.execute(`eco give ${player.username} 500`); player.chat('/buy diamond'); await expect(player).toHaveReceivedMessage('Purchased'); await expect(player).toContainItem('diamond'); @@ -73,7 +73,7 @@ test('player inventory has starter items', async ({ player }) => { ```typescript test('server executes commands', async ({ player, server }) => { - server.execute(`give ${player.username} diamond 64`); + await server.execute(`give ${player.username} diamond 64`); await expect(player).toContainItem('diamond'); }); ``` diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt index 8c1b451..15a0e70 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt @@ -36,7 +36,7 @@ class ExternalEnvironmentSpec(private val environmentName: String, private val o internal val accountsSpec: AccountsSpec = AccountsSpec(objects) internal val pluginsSpec: PluginsSpec = PluginsSpec() - /** `console { rcon { ... }; adminBot("Name") { ... } }`. */ + /** `console { rcon { ... } }`. */ fun console(action: ConsoleSpec.() -> Unit) { consoleSpec = ConsoleSpec(objects).apply(action) } From 8456fe5f21b3384f8953f7738313fa4f3d744467 Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 14:40:37 +0200 Subject: [PATCH 09/13] refactor(core): merge console-rcon package into runner --- console-rcon-package/README.md | 44 ---- console-rcon-package/package-lock.json | 207 ------------------ console-rcon-package/package.json | 49 ----- console-rcon-package/tsconfig.json | 25 --- docs/external-servers.mdx | 2 +- .../plugwright/external/ConsoleSpec.kt | 3 +- .../plugwright/external/ExternalMode.kt | 10 +- .../me/drownek/plugwright/local/LocalMode.kt | 3 +- package.json | 4 +- runner-package/lib/environments/external.ts | 27 +-- runner-package/lib/environments/local.ts | 30 +-- .../lib/rcon/connection.ts | 0 .../lib/rcon}/index.ts | 4 +- .../lib/rcon}/protocol.ts | 0 scripts/publish.js | 1 - 15 files changed, 18 insertions(+), 391 deletions(-) delete mode 100644 console-rcon-package/README.md delete mode 100644 console-rcon-package/package-lock.json delete mode 100644 console-rcon-package/package.json delete mode 100644 console-rcon-package/tsconfig.json rename console-rcon-package/lib/rcon-connection.ts => runner-package/lib/rcon/connection.ts (100%) rename {console-rcon-package => runner-package/lib/rcon}/index.ts (87%) rename {console-rcon-package/lib => runner-package/lib/rcon}/protocol.ts (100%) diff --git a/console-rcon-package/README.md b/console-rcon-package/README.md deleted file mode 100644 index 6f3e4c7..0000000 --- a/console-rcon-package/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# @plugwright/console-rcon - -RCON server console for [plugwright](https://github.com/Drownek/plugwright)'s `external` mode. - -A local server gives plugwright a console for free: it owns the process, so it reads stdout and writes stdin. A server someone else started gives it nothing. RCON is how tests reach that server's console instead. - -The Source RCON protocol is implemented directly over Node's `net` module, so this package has no dependencies of its own. Every command comes back with the server's answer, which means `execute` needs none of the client-side sync tricks a fire-and-forget channel does. - -## Usage - -Declared through the `external` environment's DSL rather than imported: - -```kotlin -environments { - create("staging", ExternalMode) { - console { - rcon { - port.set(25575) - password.set(secret.env("RCON_PASSWORD")) - } - } - } -} -``` - -The server has to be listening. In `server.properties`: - -```properties -enable-rcon=true -rcon.port=25575 -rcon.password=… -``` - -`plugwrightCompileTests` installs this package once a build script declares an `rcon` block. If it is missing from `node_modules` anyway, the runner says which package to install and where, rather than printing a stack trace. - -## What tests can do with it - -Commands and their answers, which covers `server.execute(...)`, `player.makeOp()` and everything built on them. - -What it cannot do is show a test the rest of the server log. RCON reports `output: 'responses'`, so `expect(server).toHaveReceivedMessage(...)` fails fast with an explanation instead of timing out. Mark those tests `requires: ['consoleOutput:full']` and they skip on an RCON-only environment. - -## License - -MIT diff --git a/console-rcon-package/package-lock.json b/console-rcon-package/package-lock.json deleted file mode 100644 index a7e05d7..0000000 --- a/console-rcon-package/package-lock.json +++ /dev/null @@ -1,207 +0,0 @@ -{ - "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.1", - "license": "MIT", - "devDependencies": { - "@plugwright/runner": "file:../runner-package", - "@types/node": "^22.10.5", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@plugwright/runner": ">=3.0.0-dev.0" - } - }, - "../runner-package": { - "name": "@plugwright/runner", - "version": "3.0.0-dev.1", - "dev": true, - "license": "MIT", - "dependencies": { - "js-yaml": "^4.1.0", - "mineflayer": "^4.0.0", - "picocolors": "^1.1.1", - "prismarine-auth": "^3.1.1", - "source-map-support": "^0.5.21" - }, - "bin": { - "plugwright": "dist/cli.js" - }, - "devDependencies": { - "@types/js-yaml": "^4.0.9", - "@types/node": "^22.10.5", - "@types/source-map-support": "^0.5.10", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@plugwright/runner": { - "resolved": "../runner-package", - "link": true - }, - "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/console-rcon-package/package.json b/console-rcon-package/package.json deleted file mode 100644 index fa5c159..0000000 --- a/console-rcon-package/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.1", - "description": "RCON server console for plugwright's \"external\" mode", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "rimraf dist && tsc", - "prepublishOnly": "npm run build", - "watch": "tsc --watch", - "typecheck": "tsc --noEmit" - }, - "files": [ - "dist" - ], - "keywords": [ - "minecraft", - "rcon", - "plugwright", - "testing" - ], - "author": "drownek", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/Drownek/plugwright.git", - "directory": "console-rcon-package" - }, - "homepage": "https://github.com/Drownek/plugwright#readme", - "bugs": { - "url": "https://github.com/Drownek/plugwright/issues" - }, - "peerDependencies": { - "@plugwright/runner": ">=3.0.0-dev.0" - }, - "devDependencies": { - "@plugwright/runner": "file:../runner-package", - "@types/node": "^22.10.5", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - }, - "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/console-rcon-package/tsconfig.json b/console-rcon-package/tsconfig.json deleted file mode 100644 index f2df9c3..0000000 --- a/console-rcon-package/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "moduleResolution": "node", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "resolveJsonModule": true - }, - "include": [ - "**/*.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] -} diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index 2881b52..e3e8e55 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -54,7 +54,7 @@ Channels are probed in declaration order, and the first one that answers becomes | Channel | Output level | Notes | |---|---|---| -| `rcon { }` | `responses` | Needs `enable-rcon=true` on the server. Installs `@plugwright/console-rcon` | +| `rcon { }` | `responses` | Needs `enable-rcon=true` on the server | | stdio | `full` | `LocalMode` only — Plugwright owns the process | The output level matters more than it looks. `full` means the whole server log is readable, so `expect(server).toHaveReceivedMessage(...)` works. `responses` means you get back what the command printed and nothing else. A test that reads the server log should say so: diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt index 0d44f15..dc05c48 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt @@ -8,8 +8,7 @@ import org.gradle.api.provider.Property * at runtime; the first one that connects becomes the session's console. */ sealed class ConsoleChannelSpec { - /** `console { rcon { port.set(25575); password.set(secret.env("RCON_PASS")) } }`. Needs the - * separate `@plugwright/console-rcon` runner package. */ + /** `console { rcon { port.set(25575); password.set(secret.env("RCON_PASS")) } }`. */ class Rcon(objects: ObjectFactory) : ConsoleChannelSpec() { val port: Property = objects.property(Int::class.java).convention(25575) val password: Property = objects.property(SecretRef::class.java) diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt index f65978c..c5cf9a0 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt @@ -19,13 +19,9 @@ object ExternalMode : PlugwrightMode { override fun createSpec(name: String, objects: ObjectFactory): ExternalEnvironmentSpec = ExternalEnvironmentSpec(name, objects) - override fun runnerPackages(spec: ExternalEnvironmentSpec): List = buildList { - add(RunnerPackageRef("@plugwright/runner", export = "externalEnvironment")) - val needsRcon = spec.consoleSpec?.channels?.any { it is ConsoleChannelSpec.Rcon } == true - if (needsRcon) { - add(RunnerPackageRef("@plugwright/console-rcon", export = "rconConsole")) - } - } + override fun runnerPackages(spec: ExternalEnvironmentSpec): List = listOf( + RunnerPackageRef("@plugwright/runner", export = "externalEnvironment") + ) override fun validate(spec: ExternalEnvironmentSpec, ctx: ValidationContext) { if (!spec.host.isPresent || spec.host.get().isBlank()) { diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt index b48ca30..e736b34 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt @@ -28,8 +28,7 @@ object LocalMode : PlugwrightMode { LocalEnvironmentSpec(name, objects) override fun runnerPackages(spec: LocalEnvironmentSpec): List = listOf( - RunnerPackageRef("@plugwright/runner", export = "localEnvironment"), - RunnerPackageRef("@plugwright/console-rcon", export = "rconConsole"), + RunnerPackageRef("@plugwright/runner", export = "localEnvironment") ) override fun validate(spec: LocalEnvironmentSpec, ctx: ValidationContext) { diff --git a/package.json b/package.json index f7e998b..22eb671 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "private": true, "scripts": { "bump": "node scripts/bump-version.js", - "install:packages": "npm ci --prefix runner-package && npm ci --prefix auth-authme-package && npm ci --prefix console-rcon-package", - "build:packages": "npm run build --prefix runner-package && npm run build --prefix auth-authme-package && npm run build --prefix console-rcon-package", + "install:packages": "npm ci --prefix runner-package && npm ci --prefix auth-authme-package", + "build:packages": "npm run build --prefix runner-package && npm run build --prefix auth-authme-package", "publish:packages": "node scripts/publish.js" } } diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts index b606c89..56e9587 100644 --- a/runner-package/lib/environments/external.ts +++ b/runner-package/lib/environments/external.ts @@ -6,7 +6,8 @@ import type { SecretRef } from '../config.js'; import { resolveSecret } from '../config.js'; import { AccountPool } from '../account.js'; import type { AccountsConfig } from '../account.js'; -import { sleep, importOptionalPackage } from '../utils.js'; +import { sleep } from '../utils.js'; +import { rconConsole } from '../rcon/index.js'; export interface ExternalConsoleChannelConfig { kind: 'rcon'; @@ -96,29 +97,7 @@ class ExternalEnvironment implements Environment { channel: ExternalConsoleChannelConfig, ): Promise { if (channel.kind === 'rcon') { - // A bare string literal here would make tsc try to resolve - // "@plugwright/console-rcon"'s types even though it's an optional peer package - // this repo doesn't depend on — routing through a variable keeps the import - // dynamic (untyped) without an ambient module declaration. - const rconPackage = '@plugwright/console-rcon'; - let mod: any; - try { - mod = await importOptionalPackage(rconPackage); - } catch (error) { - console.error(pc.red( - 'Mode "external": console { rcon { } } needs the "@plugwright/console-rcon" package.\n' + - 'It installs automatically as part of plugwrightCompileTests — check that npm install\n' + - 'completed in your tests directory and that the package appears under node_modules.\n' + - `(${(error as Error).message})` - )); - return null; - } - const factory = mod.rconConsole ?? mod.default; - if (typeof factory !== 'function') { - console.error(pc.red('"@plugwright/console-rcon" has no "rconConsole" export')); - return null; - } - return factory({ + return rconConsole({ host: this.config.host, port: channel.port ?? 25575, password: channel.password ? resolveSecret(channel.password) : '', diff --git a/runner-package/lib/environments/local.ts b/runner-package/lib/environments/local.ts index e5ba4e9..22826e0 100644 --- a/runner-package/lib/environments/local.ts +++ b/runner-package/lib/environments/local.ts @@ -4,7 +4,7 @@ import type { Environment, EnvironmentCapabilities, BotConnectionOptions } from import type { ServerConsole } from '../console.js'; import type { LocalEnvironmentConfig } from '../config.js'; import type { Session } from '../session.js'; -import { importOptionalPackage } from '../utils.js'; +import { rconConsole } from '../rcon/index.js'; const CAPABILITIES: EnvironmentCapabilities = { console: true, @@ -71,30 +71,10 @@ export class LocalEnvironment implements Environment { } /** - * Dynamically imports `@plugwright/console-rcon` and connects to the local server. - * This is the same import path `ExternalEnvironment.buildChannel` uses, so the - * protocol handling is shared. + * Connects to the local server via RCON. */ private async _connectRcon(): Promise { - const rconPackage = '@plugwright/console-rcon'; - let mod: any; - try { - mod = await importOptionalPackage(rconPackage); - } catch (error) { - throw new Error( - 'Local mode now uses RCON for command execution. The "@plugwright/console-rcon" package ' + - 'must be installed alongside "@plugwright/runner". If you are using the Gradle plugin, ' + - 'run a clean build to have it installed automatically.\n' + - `(${(error as Error).message})` - ); - } - - const factory = mod.rconConsole ?? mod.default; - if (typeof factory !== 'function') { - throw new Error('"@plugwright/console-rcon" has no "rconConsole" export'); - } - - const rconConsole: ServerConsole = factory({ + const consoleInstance: ServerConsole = rconConsole({ host: this.config.host ?? 'localhost', port: this.config.rconPort ?? 25575, password: this.config.rconPassword ?? 'plugwright', @@ -105,8 +85,8 @@ export class LocalEnvironment implements Environment { let lastError: Error | null = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - if (await rconConsole.probe()) { - this._rconConsole = rconConsole; + if (await consoleInstance.probe()) { + this._rconConsole = consoleInstance; console.log(pc.green(`[local] RCON connected (port ${this.config.rconPort ?? 25575})`)); return; } diff --git a/console-rcon-package/lib/rcon-connection.ts b/runner-package/lib/rcon/connection.ts similarity index 100% rename from console-rcon-package/lib/rcon-connection.ts rename to runner-package/lib/rcon/connection.ts diff --git a/console-rcon-package/index.ts b/runner-package/lib/rcon/index.ts similarity index 87% rename from console-rcon-package/index.ts rename to runner-package/lib/rcon/index.ts index 420fb86..b2b2da9 100644 --- a/console-rcon-package/index.ts +++ b/runner-package/lib/rcon/index.ts @@ -1,5 +1,5 @@ -import type { ServerConsole } from '@plugwright/runner'; -import { RconConnection } from './lib/rcon-connection.js'; +import type { ServerConsole } from '../console.js'; +import { RconConnection } from './connection.js'; export interface RconConsoleConfig { host: string; diff --git a/console-rcon-package/lib/protocol.ts b/runner-package/lib/rcon/protocol.ts similarity index 100% rename from console-rcon-package/lib/protocol.ts rename to runner-package/lib/rcon/protocol.ts diff --git a/scripts/publish.js b/scripts/publish.js index 33c435a..893d482 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -49,7 +49,6 @@ const path = require("path"); const PACKAGES = [ "runner-package", "auth-authme-package", - "console-rcon-package", ]; const PUBLIC_REGISTRY = "https://registry.npmjs.org/"; From 4ce34a2398d64af920ed4eb222ed928f5a429526 Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 14:58:43 +0200 Subject: [PATCH 10/13] fix: remove remaining references to console-rcon --- docs/publishing.mdx | 11 ++++----- example_plugin/src/test/e2e/package-lock.json | 23 +------------------ example_plugin/src/test/e2e/package.json | 3 +-- scripts/bump-version.js | 1 - 4 files changed, 7 insertions(+), 31 deletions(-) diff --git a/docs/publishing.mdx b/docs/publishing.mdx index fae3405..3845ae8 100644 --- a/docs/publishing.mdx +++ b/docs/publishing.mdx @@ -7,13 +7,12 @@ This page is for people releasing Plugwright itself, or running a fork of it ins organisation. If you are writing tests for your own plugin you want [Quickstart](/quickstart) instead. -Plugwright ships as four artifacts that move as one version: +Plugwright ships as three artifacts that move as one version: | Artifact | Kind | Public home | | --- | --- | --- | | `@plugwright/runner` | npm | npmjs.com | | `@plugwright/auth-authme` | npm | npmjs.com | -| `@plugwright/console-rcon` | npm | npmjs.com | | `io.github.drownek.plugwright` | gradle plugin | Gradle Plugin Portal | Both destinations — the public one and a private one — use the same two commands. What @@ -32,12 +31,12 @@ cd gradle-plugin ./gradlew publishToPublicRepository ``` -`publish:packages` with nothing configured publishes all three npm packages to npmjs.com. It +`publish:packages` with nothing configured publishes both npm packages to npmjs.com. It uses whatever credentials npm already has, so an `npm login` session or an `NPM_TOKEN` is enough. In CI it is an `NPM_TOKEN` secret rather than the job's OIDC token: a trusted publisher is configured per package, and the `@plugwright` names have never been published, so there is nothing to authenticate against until the first release has gone out. Provenance -is signed from the OIDC token either way, so `--provenance` works with both. Once all three +is signed from the OIDC token either way, so `--provenance` works with both. Once both packages exist, `npm trust github --file release.yml` replaces the secret. `publishToPublicRepository` is the Gradle Plugin Portal, and reads `GRADLE_PUBLISH_KEY` and @@ -140,8 +139,8 @@ repository for the plugin. That is covered in ## Moving the version -All four artifacts carry one version, kept in `version.txt`. `npm run bump` moves it -everywhere at once — the three `package.json` files and the lockfiles that record the +All three artifacts carry one version, kept in `version.txt`. `npm run bump` moves it +everywhere at once — the two `package.json` files and the lockfiles that record the runner's version, plus the README, the quickstart and the example plugin for a stable release — then tags the commit. diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index 93e29f4..1e5dd95 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -6,7 +6,6 @@ "": { "dependencies": { "@plugwright/auth-authme": "file:../../../../auth-authme-package", - "@plugwright/console-rcon": "file:../../../../console-rcon-package", "@plugwright/runner": "file:../../../../runner-package" }, "devDependencies": { @@ -32,23 +31,6 @@ "@plugwright/runner": ">=3.0.0-dev.0" } }, - "../../../../console-rcon-package": { - "name": "@plugwright/console-rcon", - "version": "3.0.0-dev.1", - "license": "MIT", - "devDependencies": { - "@plugwright/runner": "file:../runner-package", - "@types/node": "^22.10.5", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@plugwright/runner": ">=3.0.0-dev.0" - } - }, "../../../../runner-package": { "name": "@plugwright/runner", "version": "3.0.0-dev.1", @@ -57,6 +39,7 @@ "js-yaml": "^4.1.0", "mineflayer": "^4.0.0", "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" }, "bin": { @@ -77,10 +60,6 @@ "resolved": "../../../../auth-authme-package", "link": true }, - "node_modules/@plugwright/console-rcon": { - "resolved": "../../../../console-rcon-package", - "link": true - }, "node_modules/@plugwright/runner": { "resolved": "../../../../runner-package", "link": true diff --git a/example_plugin/src/test/e2e/package.json b/example_plugin/src/test/e2e/package.json index 6cfe34f..c921806 100644 --- a/example_plugin/src/test/e2e/package.json +++ b/example_plugin/src/test/e2e/package.json @@ -5,8 +5,7 @@ }, "dependencies": { "@plugwright/runner": "file:../../../../runner-package", - "@plugwright/auth-authme": "file:../../../../auth-authme-package", - "@plugwright/console-rcon": "file:../../../../console-rcon-package" + "@plugwright/auth-authme": "file:../../../../auth-authme-package" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/scripts/bump-version.js b/scripts/bump-version.js index d3aaf65..383a721 100644 --- a/scripts/bump-version.js +++ b/scripts/bump-version.js @@ -10,7 +10,6 @@ const readline = require("readline"); const NPM_PACKAGES = [ "runner-package", "auth-authme-package", - "console-rcon-package", ]; function prompt(question) { From cab535c5ed910246fb019b4d21a6ad2d541e36bd Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 16:05:22 +0200 Subject: [PATCH 11/13] refactor(gradle-plugin): scope useExternalPluginsOnly to LocalMode Removes deprecated global extension.useExternalPluginsOnly check from PlugwrightCorePlugin and resolves pluginJar dynamically in LocalMode based on the environment spec, preserving backward compatibility while enabling per-environment configuration in v3. --- .../kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt | 9 +++------ .../main/kotlin/me/drownek/plugwright/local/LocalMode.kt | 5 ++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt index 5715d9e..8de5861 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -136,7 +136,7 @@ class PlugwrightCorePlugin : Plugin { } val layout = PlugwrightLayout.of(extension.testsDir.get().asFile) - val projectPluginJarProvider = resolveProjectPluginJar(project, extension) + val projectPluginJarProvider = resolveProjectPluginJar(project) val validationProblems = mutableListOf() validationProblems += extension.npm.toConfig().problems().map { "[npm] $it" } val reportsDir = project.layout.buildDirectory.dir("reports/plugwright") @@ -312,11 +312,8 @@ class PlugwrightCorePlugin : Plugin { } /** The jar of the plugin under test, from `shadowJar` / `reobfJar` / `jar`. Absent when - * the build asked for external plugins only, or when no jar-producing task exists. */ - private fun resolveProjectPluginJar(project: Project, extension: PlugwrightExtension): Provider { - if (extension.useExternalPluginsOnly.get()) { - return project.objects.property(File::class.java) - } + * no jar-producing task exists. */ + private fun resolveProjectPluginJar(project: Project): Provider { val jarTask = when { project.tasks.findByName("shadowJar") != null -> project.tasks.named("shadowJar") project.tasks.findByName("reobfJar") != null -> project.tasks.named("reobfJar") diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt index e736b34..ccf611d 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt @@ -80,7 +80,10 @@ object LocalMode : PlugwrightMode { runDir.set(spec.runDir) minecraftVersion.set(spec.minecraftVersion) port.set(spec.port) - pluginJar.set(ctx.projectPluginJar) + pluginJar.set(spec.useExternalPluginsOnly.flatMap { externalOnly -> + if (externalOnly) project.objects.property(File::class.java) + else ctx.projectPluginJar + }) pluginUrls.set(spec.pluginUrls) runDirFiles.set(spec.runDirFiles) rconPort.set(spec.rconPort) From 6c3b5bcd8775b0265bec0a31728fbc3b6dbddc16 Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 20:24:19 +0200 Subject: [PATCH 12/13] refactor: migrate requires from string array to typed object map --- docs/custom-modes.mdx | 2 +- docs/external-servers.mdx | 2 +- docs/test-filtering.mdx | 8 ++-- .../src/test/e2e/tests/concurrency.spec.ts | 2 +- .../src/test/e2e/tests/simple-ts.spec.ts | 2 +- runner-package/lib/matchers.ts | 2 +- runner-package/lib/skip-reason.ts | 42 +++++++++++++------ runner-package/lib/test-registry.ts | 16 ++++--- runner-package/runner.ts | 2 +- 9 files changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx index f982c55..6ed49a1 100644 --- a/docs/custom-modes.mdx +++ b/docs/custom-modes.mdx @@ -162,7 +162,7 @@ class VelocityEnvironment implements Environment { } ``` -Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. +Capabilities are a promise the runner holds you to. Tests declaring `{ requires: { op: true } }` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. `accounts()` and `beforeJoin()` are optional. Returning no pool means every bot gets a throwaway `pw_` username, which is what `local` does. A pool is also what makes `describe.serial('...', { account: 'pw_0001' })` possible: without one, a block asking for a named account fails rather than running as somebody else. diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index e3e8e55..413a008 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -60,7 +60,7 @@ Channels are probed in declaration order, and the first one that answers becomes The output level matters more than it looks. `full` means the whole server log is readable, so `expect(server).toHaveReceivedMessage(...)` works. `responses` means you get back what the command printed and nothing else. A test that reads the server log should say so: ```ts -test('command is logged', { requires: ['consoleOutput:full'] }, async ({ server }) => { +test('command is logged', { requires: { consoleOutput: 'full' } }, async ({ server }) => { await server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); diff --git a/docs/test-filtering.mdx b/docs/test-filtering.mdx index 0492d7a..5a729cc 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -41,7 +41,7 @@ Two filters, meant for different problems. **By capability** — for a test that needs something the environment might not have. This travels with the test and doesn't care what the environments are called: ```ts -test('give command hands over the item', { requires: ['console', 'op'] }, async ({ player }) => { +test('give command hands over the item', { requires: { console: true, op: true } }, async ({ player }) => { await player.giveItem('diamond', 1); }); ``` @@ -57,16 +57,16 @@ Capability keys come from the environment's own report, after it has connected: | `arbitraryUsernames` | boolean | Bots may pick their own names | | `lifecycle` | boolean | The server can be restarted or stopped | -A bare key is satisfied by anything other than `false`, `'none'` or an absent value. To demand one specific value, use `key:value`: +A boolean `true` capability is satisfied by anything other than `false`, `'none'` or an absent value. To demand one specific value, map it in the object: ```ts -test('console matters', { requires: ['consoleOutput:full'] }, async ({ server }) => { +test('console matters', { requires: { consoleOutput: 'full' } }, async ({ server }) => { await server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); ``` -That form exists because `requires: ['console']` is satisfied by an RCON console that answers its own commands, while reading the server log needs a console that streams all of it. Without the distinction you get tests that neither skip nor work. +That form exists because `{ requires: { console: true } }` is satisfied by an RCON console that answers its own commands, while reading the server log needs a console that streams all of it. Without the distinction you get tests that neither skip nor work. **By environment name** — for when the difference isn't a capability but what's installed on that particular server: diff --git a/example_plugin/src/test/e2e/tests/concurrency.spec.ts b/example_plugin/src/test/e2e/tests/concurrency.spec.ts index 12776d0..3ec86a2 100644 --- a/example_plugin/src/test/e2e/tests/concurrency.spec.ts +++ b/example_plugin/src/test/e2e/tests/concurrency.spec.ts @@ -14,7 +14,7 @@ import { describe, expect, test } from '@plugwright/runner'; test( 'concurrent bots each see their own marker and stay connected', - { concurrency: 3, requires: ['consoleOutput:full'] }, + { concurrency: 3, requires: { consoleOutput: 'full' } }, async ({ player, server }) => { const marker = `concurrency-marker-${player.username}`; player.chat(marker); diff --git a/example_plugin/src/test/e2e/tests/simple-ts.spec.ts b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts index f33d032..068a8a3 100644 --- a/example_plugin/src/test/e2e/tests/simple-ts.spec.ts +++ b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts @@ -27,7 +27,7 @@ test('help displays message', async ({ player }) => { // Reading the server log needs a console that streams all of it. An environment whose // console only answers its own commands skips this test instead of failing it. -test('server logs command execution', { requires: ['consoleOutput:full'] }, async ({ server }) => { +test('server logs command execution', { requires: { consoleOutput: 'full' } }, async ({ server }) => { await server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); \ No newline at end of file diff --git a/runner-package/lib/matchers.ts b/runner-package/lib/matchers.ts index 34eec38..0dac074 100644 --- a/runner-package/lib/matchers.ts +++ b/runner-package/lib/matchers.ts @@ -94,7 +94,7 @@ export class RunnerMatchers extends Matchers { if (!(this.actual instanceof PlayerWrapper) && session.env.capabilities.consoleOutput !== 'full') { throw new Error( `Cannot read the server log on environment "${session.env.id}": its console output level is ` + - `"${session.env.capabilities.consoleOutput}". Mark the test with requires: ['consoleOutput:full'] ` + + `"${session.env.capabilities.consoleOutput}". Mark the test with { requires: { consoleOutput: 'full' } } ` + 'to have it skipped there instead.' ); } diff --git a/runner-package/lib/skip-reason.ts b/runner-package/lib/skip-reason.ts index 554dbf6..35f90bf 100644 --- a/runner-package/lib/skip-reason.ts +++ b/runner-package/lib/skip-reason.ts @@ -1,21 +1,37 @@ import type { Environment } from './environment.js'; +import type { RequiresMap } from './test-registry.js'; -/** Capability keys from a `requires` list that `env` does not actually satisfy. A value of +/** Capability keys from a `requires` map that `env` does not actually satisfy. A value of * `false`, `'none'`, or an absent key all count as unmet. * - * `'key:value'` demands one specific value instead — `'consoleOutput:full'` for a test that + * `{ consoleOutput: 'full' }` demands one specific value instead — for a test that * reads the server log, which a console answering only its own commands cannot provide even - * though it satisfies plain `'console'`. */ -export function missingCapabilities(env: Environment, required: string[]): string[] { - const capabilities = env.capabilities as unknown as Record; - return required.filter(key => { - const separator = key.indexOf(':'); - if (separator !== -1) { - return String(capabilities[key.slice(0, separator)]) !== key.slice(separator + 1); + * though it satisfies plain `console: true`. */ +export function missingCapabilities(env: Environment, required: RequiresMap): string[] { + if (Array.isArray(required)) { + throw new Error('Test "requires" must be an object map (e.g. { requires: { console: true } }), not an array.'); + } + const capabilities = (env?.capabilities ?? {}) as unknown as Record; + const missing: string[] = []; + for (const [key, expectedValue] of Object.entries(required ?? {})) { + if (expectedValue === undefined) continue; + const actualValue = capabilities[key]; + + if (expectedValue === true) { + if (actualValue === false || actualValue === 'none' || actualValue == null) { + missing.push(key); + } + } else if (expectedValue === false) { + if (actualValue !== false && actualValue !== 'none' && actualValue != null) { + missing.push(`!${key}`); + } + } else { + if (String(actualValue) !== String(expectedValue)) { + missing.push(`${key}:${expectedValue}`); + } } - const value = capabilities[key]; - return value === false || value === 'none' || value === undefined; - }); + } + return missing; } /** The two `TestOptions` fields a test itself declares — `environments` and `requires` — @@ -24,7 +40,7 @@ export function missingCapabilities(env: Environment, required: string[]): strin export function skipReasonForOptions( env: Environment, environmentName: string, - requires: string[], + requires: RequiresMap, environments: string[] | null, ): string | null { if (environments && !environments.includes(environmentName)) { diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index 2749963..7559266 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -3,16 +3,20 @@ import type { TestContext } from './types.js'; export type Hook = (context: TestContext) => Promise | void; type TestFn = (context: TestContext) => Promise; +import type { EnvironmentCapabilities } from './environment.js'; + /** * Filters usable from a spec file, independent of environment names. * - * `requires` checks capability flags on `env.capabilities` (e.g. `'console'`, `'op'`) — + * `requires` checks capability flags on `env.capabilities` (e.g. `console: true`, `op: true`) — * a value of `false` or `'none'` fails the check. `environments` checks the running * environment's name directly, for cases that aren't about capability but about the * content of a specific stand. */ +export type RequiresMap = Partial; + export interface TestOptions { - requires?: string[]; + requires?: RequiresMap; environments?: string[]; /** Runs this many independent instances of the test concurrently, each with its own bot * leased from the account pool, to exercise races between players hitting the same feature @@ -35,7 +39,7 @@ export interface TestCase { /** Spec-level `afterEach` hooks in run order (innermost `describe` first) — already * reversed at registration time, see `registerTest`. */ afterHooks: Hook[]; - requires: string[]; + requires: RequiresMap; environments: string[] | null; concurrency: number; } @@ -54,7 +58,7 @@ export interface SerialBlock { name: string; account: string | null; tests: TestCase[]; - requires: string[]; + requires: RequiresMap; environments: string[] | null; concurrency: number; } @@ -87,7 +91,7 @@ function scopedEntry(name: string, options: TestOptions) { name: [...labels, name].join(' > '), beforeHooks: scopeStack.flatMap(s => s.beforeHooks), afterHooks: [...scopeStack].reverse().flatMap(s => s.afterHooks), - requires: options.requires ?? [], + requires: options.requires ?? {}, environments: options.environments ?? null, concurrency: normalizeConcurrency(options.concurrency), }; @@ -180,7 +184,7 @@ function serialImpl(label: string, optionsOrFn: SerialOptions | (() => void), ma name: [...labels, label].join(' > '), account: options.account ?? null, tests: [], - requires: options.requires ?? [], + requires: options.requires ?? {}, environments: options.environments ?? null, concurrency: normalizeConcurrency(options.concurrency), }; diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 4f1b39f..1a51ac7 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -30,7 +30,7 @@ export { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator }; export { PlayerWrapper }; export { ServerWrapper } from './lib/server.js'; export { test, opTest, describe, beforeEach, afterEach } from './lib/test-registry.js'; -export type { TestOptions, TestCase, SerialOptions, SerialBlock } from './lib/test-registry.js'; +export type { TestOptions, TestCase, SerialOptions, SerialBlock, RequiresMap } from './lib/test-registry.js'; export { expect } from './lib/matchers.js'; export { loadRunnerConfig, resolveSecret, isSecretRef } from './lib/config.js'; export type { RunnerConfig, EnvironmentConfig, TestsConfig, LocalEnvironmentConfig, SecretRef, PluginConfig } from './lib/config.js'; From a1ae7aff41f8db218da224856b25c321ace19f76 Mon Sep 17 00:00:00 2001 From: Drownek Date: Mon, 7 Sep 2026 20:26:18 +0200 Subject: [PATCH 13/13] refactor: remove redundant freshState, lifecycle, and arbitraryUsernames capabilities --- docs/custom-modes.mdx | 3 --- docs/external-servers.mdx | 4 ++-- docs/test-filtering.mdx | 3 --- runner-package/lib/environment.ts | 3 --- runner-package/lib/environments/external.ts | 3 --- runner-package/lib/environments/local.ts | 3 --- 6 files changed, 2 insertions(+), 17 deletions(-) diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx index 6ed49a1..471b9d0 100644 --- a/docs/custom-modes.mdx +++ b/docs/custom-modes.mdx @@ -148,9 +148,6 @@ class VelocityEnvironment implements Environment { console: true, consoleOutput: 'responses', op: true, - freshState: false, - arbitraryUsernames: true, - lifecycle: true, }; async setup(session: Session): Promise { /* connect, probe, warm up */ } diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index 413a008..0ea82b9 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -89,7 +89,7 @@ That is a request for a specific identity, not for whatever is free, so the pool Most second bots don't need this. A test that just wants another player should call `createPlayer()` with no arguments and let the pool answer — a name is worth asking for when the identity is, because somebody provisioned that account with a permission group or a balance, or because the name came from somewhere outside the test. -A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, exclude what you can't, and treat `capabilities.freshState = false` as the honest description it is. +A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, and exclude what you can't. ## Numbered slots or fresh names @@ -122,4 +122,4 @@ Connects, probes the console channels, leases one account and authenticates with -After `setup()`, the environment reports what it actually supports. For `ExternalMode` that is: no fresh state, no server lifecycle, arbitrary usernames, and console plus op only if a console channel answered. Tests that declare `requires` are skipped against that list, with the reason in the report. See [Test Filtering](/test-filtering). +After `setup()`, the environment reports what it actually supports. For `ExternalMode` that is: console plus op only if a console channel answered. Tests that declare `requires` are skipped against that list, with the reason in the report. See [Test Filtering](/test-filtering). diff --git a/docs/test-filtering.mdx b/docs/test-filtering.mdx index 5a729cc..9c6cce6 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -53,9 +53,6 @@ Capability keys come from the environment's own report, after it has connected: | `console` | boolean | Commands can be run at all | | `consoleOutput` | `full` / `responses` / `none` | Whole server log, only command answers, or nothing | | `op` | boolean | The environment can grant operator status | -| `freshState` | boolean | Each test gets a clean world and a clean player | -| `arbitraryUsernames` | boolean | Bots may pick their own names | -| `lifecycle` | boolean | The server can be restarted or stopped | A boolean `true` capability is satisfied by anything other than `false`, `'none'` or an absent value. To demand one specific value, map it in the object: diff --git a/runner-package/lib/environment.ts b/runner-package/lib/environment.ts index 017e8d1..e6df4f0 100644 --- a/runner-package/lib/environment.ts +++ b/runner-package/lib/environment.ts @@ -8,9 +8,6 @@ export interface EnvironmentCapabilities { console: boolean; consoleOutput: 'full' | 'responses' | 'none'; op: boolean; - freshState: boolean; - arbitraryUsernames: boolean; - lifecycle: boolean; } export interface BotConnectionOptions { diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts index 56e9587..7d411e3 100644 --- a/runner-package/lib/environments/external.ts +++ b/runner-package/lib/environments/external.ts @@ -31,9 +31,6 @@ const BASE_CAPABILITIES: EnvironmentCapabilities = { // Never assumed true: nothing here proves the leased accounts actually have op rights // on the stand. A mode that can prove it would override this after setup(). op: false, - freshState: false, - arbitraryUsernames: true, - lifecycle: false, }; /** diff --git a/runner-package/lib/environments/local.ts b/runner-package/lib/environments/local.ts index 22826e0..4ec8c0c 100644 --- a/runner-package/lib/environments/local.ts +++ b/runner-package/lib/environments/local.ts @@ -10,9 +10,6 @@ const CAPABILITIES: EnvironmentCapabilities = { console: true, consoleOutput: 'full', op: true, - freshState: true, - arbitraryUsernames: true, - lifecycle: true, }; /**