diff --git a/README.md b/README.md index 870a497..4629518 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,49 @@ This command is interactive, so simply follow the prompts on your screen: > **πŸ’‘ Want to see a working example?** Check out the [example_plugin](./example_plugin) directory in this repository. +## Testing against more than one server + +The block above describes a single local Paper server, which is all most projects need. When you also want to run the same suite against a staging server someone else keeps running, name the servers explicitly: + +```kotlin +import me.drownek.plugwright.api.secret +import me.drownek.plugwright.external.ExternalMode +import me.drownek.plugwright.local.LocalMode + +plugwright { + testsDir.set(file("src/test/e2e")) + + environments { + create("local", LocalMode) { + minecraftVersion.set("1.21.11") + acceptEula.set(true) + } + + create("staging", ExternalMode) { + host.set("mc.example.com") + minecraftVersion.set("1.20.4") + + console { rcon { port.set(25575); password.set(secret.env("RCON_PASSWORD")) } } + accounts { + autoRegister { + usernamePattern.set("pw_%04d") + password.set(secret.env("BOT_PASSWORD")) + max.set(4) + } + } + plugins { npm("@plugwright/auth-authme") } + } + } +} +``` + +`./gradlew plugwrightTest` runs the matrix and prints a summary per environment; `./gradlew plugwrightTestStaging` runs one. A server behind a login wall needs a runner plugin to get past it, and `@plugwright/auth-authme` is the reference implementation for AuthMe-style login. Writing your own kind of environment β€” a proxy, a Compose stack β€” is a Kotlin mode plus an npm package. + +- [Environments](https://plugwright.dev/environments) β€” modes, tasks, the matrix +- [External servers](https://plugwright.dev/external-servers) β€” console channels, account pools, cleanup +- [Runner plugins](https://plugwright.dev/plugins) β€” hooks, fixtures, matchers, inherited tests +- [Writing a mode](https://plugwright.dev/custom-modes) + ## Why Plugwright vs MockBukkit? | | **Plugwright** | **MockBukkit** | diff --git a/auth-authme-package/README.md b/auth-authme-package/README.md new file mode 100644 index 0000000..07483ec --- /dev/null +++ b/auth-authme-package/README.md @@ -0,0 +1,77 @@ +# @plugwright/auth-authme + +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. + +Microsoft (online-mode) accounts are left alone; AuthMe never prompts them. + +## Usage + +```kotlin +environments { + create("staging", ExternalMode) { + accounts { + autoRegister { + usernamePattern.set("pw_%04d") + password.set(secret.env("BOT_PASSWORD")) + max.set(4) + } + } + plugins { + npm("@plugwright/auth-authme") { + options["loginCommand"] = "/log" + } + } + } +} +``` + +The same block works on a `LocalMode` environment. A local server running AuthMe puts up the same wall as a remote one. + +## Which command it sends + +The server decides, not the account. `account.justCreated` is a hint from the account pool, and it is wrong every time a pool account outlives the run that created it β€” that is the second run against any stand. So the plugin waits for either prompt and answers whichever arrived. The register pattern is tested first, since AuthMe's register prompt mentions the password too and would otherwise look like a login prompt. + +## Options + +| Option | Default | Meaning | +|---|---|---| +| `loginCommand` | `/login` | Sent with the password appended | +| `registerCommand` | `/register` | Sent with the password twice | +| `loginPromptPattern` | `log ?in\|password` | Regex identifying the login prompt | +| `registerPromptPattern` | `regist` | Regex identifying the register prompt | +| `successPattern` | `success\|welcome\|logged in\|authenticat` | Regex confirming the command was accepted | +| `authenticatedPattern` | `logged in\|authenticat` | Narrower regex confirming the player is actually authenticated | +| `timeoutMs` | `15000` | How long to wait for each prompt or confirmation | +| `password` | β€” | Fallback password for accounts that carry none | + +All patterns are matched case-insensitively, and only against messages that arrived after the step they belong to. A greeting containing the word "welcome" would otherwise pass for a login confirmation, and the test would start before the player could run a single command. + +`password` covers accounts an environment invents rather than leases: `LocalMode` hands every test a throwaway `Test_` with no password of its own. Plugin options travel as plain strings, so use it only where the password protects nothing β€” a local server that is deleted after the run. Anywhere else, put the accounts in `accounts { }`, where the password stays a secret reference until the runner reads it. + +## Preflight test + +A `preflight` test ships with the plugin and runs before any user spec. The handshake above already throws on the first connection if it fails, so the test mostly exists to put a named failure at the top of the report instead of a stack trace buried in someone else's test. + +## Server-side settings that matter + +A stock AuthMe config is tuned for humans and rejects a test suite in three specific ways. On a disposable local server: + +```yaml +settings: + registration: + dialog: + preJoin: { enable: false } # bots cannot answer a dialog + postJoin: { enable: false } + restrictions: + maxRegPerIp: 0 # every test registers from 127.0.0.1 +Protection: + enableAntiBot: false # a test suite looks exactly like a bot attack +``` + +The `example_plugin` in this repository writes that file through `writeFiles { }` and runs its full suite against it. + +## License + +MIT diff --git a/auth-authme-package/auth.spec.ts b/auth-authme-package/auth.spec.ts new file mode 100644 index 0000000..879f4da --- /dev/null +++ b/auth-authme-package/auth.spec.ts @@ -0,0 +1,10 @@ +import { test } from '@drownek/plugwright'; + +// If the login/register handshake in `onPlayerCreate` failed or timed out, `createPlayer()` +// would already have thrown before this test body ever runs β€” so reaching here at all is +// the actual assertion. The check below just makes that visible in the report. +test('authme login/register flow completes', async ({ player }) => { + if (!player.username) { + throw new Error('authme preflight: player has no username after join'); + } +}); diff --git a/auth-authme-package/index.ts b/auth-authme-package/index.ts new file mode 100644 index 0000000..76ff2ed --- /dev/null +++ b/auth-authme-package/index.ts @@ -0,0 +1,136 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { definePlugin, poll } from '@drownek/plugwright'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface AuthAuthmeOptions { + /** Command sent for an existing account. */ + loginCommand?: string; + /** Command sent for a freshly generated account (`account.justCreated`); receives the + * password twice, matching AuthMe's own `/register `. */ + registerCommand?: string; + /** Regex (source only, case-insensitive) matched against server messages to detect the + * login prompt. */ + loginPromptPattern?: string; + /** Regex matched against server messages to detect the register prompt. */ + registerPromptPattern?: string; + /** Regex matched against server messages to confirm the command was accepted. */ + successPattern?: string; + /** Regex matched against server messages to confirm the player is now authenticated. + * Narrower than [successPattern]: a registration is acknowledged before the login that + * follows it, and commands sent in between are still rejected. Deliberately excludes + * "success" and "welcome" β€” both fire on AuthMe's own "Successfully registered!" line, + * which would otherwise pass for the login that hasn't happened yet. Also avoids a bare + * "login" alternative: this pattern also gates the redundant-login fallback below, and a + * bare "login" would match AuthMe's login prompt ("Please, login with the command: + * /login ") too, turning a failed retry into a false "authenticated". */ + authenticatedPattern?: string; + /** How long to wait for each prompt/confirmation before giving up. */ + timeoutMs?: number; + /** Password used for accounts that carry none of their own β€” the throwaway identities an + * environment without an account pool generates per bot. Plugin options travel as plain + * values, so only use this where the password is worth nothing: a local, disposable + * server. Anywhere else, put the accounts in the pool and let the password be a secret. */ + password?: string; +} + +const DEFAULTS: Required> = { + loginCommand: '/login', + registerCommand: '/register', + loginPromptPattern: 'log ?in|password', + registerPromptPattern: 'regist', + successPattern: 'success|welcome|logged in|authenticat', + authenticatedPattern: 'success(ful)? login|logged in|authenticat', + timeoutMs: 15000, +}; + +// `onPlayerCreate` doesn't receive the plugin's options β€” only `setup()` does β€” so the +// resolved settings live here, captured once when the session starts. Safe because a runner +// process only ever runs one session at a time (see Session's own module-level caveats). +let resolved: Required> & { password?: string } = DEFAULTS; + +/** + * Reference authentication plugin for a server running AuthMe (or anything with the same + * login/register-by-chat flow). `onPlayerCreate` fires on every bot connection β€” the initial + * join and every `player.rejoin()` β€” and on the `external` mode's admin-bot console too, since + * that connects through the exact same `PlayerWrapper.join()` path a test bot does. + */ +export default definePlugin({ + name: 'authme', + apiVersion: 1, + tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight' }], + + setup({ options }) { + resolved = { ...DEFAULTS, ...options }; + }, + + async onPlayerCreate(player, { account }) { + // Online-mode (Microsoft) accounts never see AuthMe's offline-mode login wall. + if (account.auth === 'microsoft') return; + + const password = account.password ?? resolved.password; + if (!password) { + throw new Error( + `authme: account "${account.username}" has no password to log in with. ` + + 'Give the environment an accounts pool, or set the plugin\'s "password" option ' + + 'for a throwaway local server.' + ); + } + + const registerPrompt = new RegExp(resolved.registerPromptPattern, 'i'); + const loginPrompt = new RegExp(resolved.loginPromptPattern, 'i'); + const successPattern = new RegExp(resolved.successPattern, 'i'); + + // Which of the two the server asks for is the server's decision, not ours: + // `account.justCreated` is a hint from the account pool, and it is wrong whenever a + // pool account outlives the run that created it. So wait for either prompt and answer + // the one that actually arrived. Register is tested first because AuthMe's register + // prompt names the password too, and would otherwise match the login pattern. + const joinIndex = player.getMessageBufferIndex(); + const since = (index: number, pattern: RegExp): string | undefined => + player.messageBuffer.slice(index).find((m: string) => pattern.test(m)); + + const isRegistration = await poll( + () => { + if (since(joinIndex, registerPrompt)) return true; + if (since(joinIndex, loginPrompt)) return false; + return undefined; + }, + { + timeout: resolved.timeoutMs, + message: `authme: never saw a login or register prompt for "${account.username}"`, + }, + ); + + // Everything below only looks at messages newer than the command. A server's greeting + // often carries a word like "welcome", which would otherwise pass for confirmation + // and let the test start before the player is actually authenticated. + const commandIndex = player.getMessageBufferIndex(); + player.chat(isRegistration + ? `${resolved.registerCommand} ${password} ${password}` + : `${resolved.loginCommand} ${password}`, { secrets: [password] }); + + await poll(() => since(commandIndex, successPattern), { + timeout: resolved.timeoutMs, + message: `authme: "${account.username}" did not confirm ${isRegistration ? 'registration' : 'login'} in time`, + }); + + if (!isRegistration) return; + + // A registration is confirmed before the login it triggers, and a command sent in + // between is rejected as unauthenticated. AuthMe normally logs the player in itself; + // with forceLoginAfterRegister it does not, and the login has to be sent by hand. + const authenticated = new RegExp(resolved.authenticatedPattern, 'i'); + const autoLoggedIn = await poll(() => since(commandIndex, authenticated), { timeout: 3000 }) + .catch(() => null); + if (autoLoggedIn) return; + + const loginIndex = player.getMessageBufferIndex(); + player.chat(`${resolved.loginCommand} ${password}`, { secrets: [password] }); + await poll(() => since(loginIndex, authenticated), { + timeout: resolved.timeoutMs, + message: `authme: "${account.username}" registered but never logged in`, + }); + }, +}); diff --git a/auth-authme-package/package-lock.json b/auth-authme-package/package-lock.json new file mode 100644 index 0000000..51e9f33 --- /dev/null +++ b/auth-authme-package/package-lock.json @@ -0,0 +1,203 @@ +{ + "name": "@plugwright/auth-authme", + "version": "3.0.0-dev.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@plugwright/auth-authme", + "version": "3.0.0-dev.0", + "license": "MIT", + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@drownek/plugwright": ">=3.0.0-dev.0" + } + }, + "../runner-package": { + "name": "@drownek/plugwright", + "version": "3.0.0-dev.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "mineflayer": "^4.0.0", + "picocolors": "^1.1.1", + "source-map-support": "^0.5.21" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.5", + "@types/source-map-support": "^0.5.10", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@drownek/plugwright": { + "resolved": "../runner-package", + "link": true + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/auth-authme-package/package.json b/auth-authme-package/package.json new file mode 100644 index 0000000..3a8f25e --- /dev/null +++ b/auth-authme-package/package.json @@ -0,0 +1,50 @@ +{ + "name": "@plugwright/auth-authme", + "version": "3.0.0-dev.0", + "description": "Reference plugwright authentication plugin for an AuthMe-style login/register flow", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "rimraf dist && tsc", + "prepare": "npm run build", + "prepublishOnly": "npm run build", + "watch": "tsc --watch", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "keywords": [ + "minecraft", + "authme", + "plugwright", + "testing" + ], + "author": "drownek", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Drownek/plugwright.git", + "directory": "auth-authme-package" + }, + "homepage": "https://github.com/Drownek/plugwright#readme", + "bugs": { + "url": "https://github.com/Drownek/plugwright/issues" + }, + "peerDependencies": { + "@drownek/plugwright": ">=3.0.0-dev.0" + }, + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/auth-authme-package/tsconfig.json b/auth-authme-package/tsconfig.json new file mode 100644 index 0000000..f2df9c3 --- /dev/null +++ b/auth-authme-package/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/console-rcon-package/README.md b/console-rcon-package/README.md new file mode 100644 index 0000000..f3e6d77 --- /dev/null +++ b/console-rcon-package/README.md @@ -0,0 +1,44 @@ +# @plugwright/console-rcon + +RCON server console for [plugwright](https://github.com/Drownek/plugwright)'s `external` mode. + +A local server gives plugwright a console for free: it owns the process, so it reads stdout and writes stdin. A server someone else started gives it nothing. RCON is how tests reach that server's console instead. + +The Source RCON protocol is implemented directly over Node's `net` module, so this package has no dependencies of its own. Every command comes back with the server's answer, which means `executeAndWait` needs none of the client-side sync tricks a fire-and-forget channel does. + +## Usage + +Declared through the `external` environment's DSL rather than imported: + +```kotlin +environments { + create("staging", ExternalMode) { + console { + rcon { + port.set(25575) + password.set(secret.env("RCON_PASSWORD")) + } + } + } +} +``` + +The server has to be listening. In `server.properties`: + +```properties +enable-rcon=true +rcon.port=25575 +rcon.password=… +``` + +`plugwrightCompileTests` installs this package once a build script declares an `rcon` block. If it is missing from `node_modules` anyway, the runner says which package to install and where, rather than printing a stack trace. + +## What tests can do with it + +Commands and their answers, which covers `server.execute(...)`, `server.executeAndWait(...)`, `player.makeOp()` and everything built on them. + +What it cannot do is show a test the rest of the server log. RCON reports `output: 'responses'`, so `expect(server).toHaveReceivedMessage(...)` fails fast with an explanation instead of timing out. Mark those tests `requires: ['consoleOutput:full']` and they skip on an RCON-only environment. + +## License + +MIT diff --git a/console-rcon-package/index.ts b/console-rcon-package/index.ts new file mode 100644 index 0000000..36073d5 --- /dev/null +++ b/console-rcon-package/index.ts @@ -0,0 +1,39 @@ +import type { ServerConsole } from '@drownek/plugwright'; +import { RconConnection } from './lib/rcon-connection.js'; + +export interface RconConsoleConfig { + host: string; + port: number; + password: string; +} + +/** + * `ServerConsole` over RCON: unlike `stdio` and `admin-bot`, the protocol gives a synchronous + * response to every command, so `executeAndWait` doesn't need the `minecraft:say ` + * round-trip trick those two rely on. + */ +export function rconConsole(config: RconConsoleConfig): ServerConsole { + const connection = new RconConnection(config.host, config.port, config.password); + + return { + kind: 'rcon', + output: 'responses', + + async probe(): Promise { + try { + await connection.ensureConnected(); + return true; + } catch { + return false; + } + }, + + execute(cmd: string): void { + connection.execute(cmd); + }, + + async executeAndWait(cmd: string, timeoutMs: number = 5000): Promise { + return connection.executeAndWait(cmd, timeoutMs); + }, + }; +} diff --git a/console-rcon-package/lib/protocol.ts b/console-rcon-package/lib/protocol.ts new file mode 100644 index 0000000..85de2de --- /dev/null +++ b/console-rcon-package/lib/protocol.ts @@ -0,0 +1,40 @@ +/** + * Wire format for the Source RCON protocol (used unmodified by vanilla/Paper/Spigot): + * a 4-byte little-endian length prefix, a 4-byte request id, a 4-byte packet type, the + * payload as a null-terminated string, and one extra trailing null byte. + */ +export const PacketType = { + RESPONSE_VALUE: 0, + EXECCOMMAND: 2, + AUTH_RESPONSE: 2, + AUTH: 3, +} as const; + +export interface DecodedPacket { + id: number; + type: number; + payload: string; +} + +export function encodePacket(id: number, type: number, payload: string): Buffer { + const payloadBuf = Buffer.from(payload, 'utf8'); + const bodySize = 4 + 4 + payloadBuf.length + 2; // id + type + payload + 2 null terminators + const buf = Buffer.alloc(4 + bodySize); + let offset = 0; + buf.writeInt32LE(bodySize, offset); offset += 4; + buf.writeInt32LE(id, offset); offset += 4; + buf.writeInt32LE(type, offset); offset += 4; + payloadBuf.copy(buf, offset); offset += payloadBuf.length; + buf.writeUInt8(0, offset); offset += 1; + buf.writeUInt8(0, offset); + return buf; +} + +/** Decodes one packet body β€” everything after the 4-byte length prefix a caller already + * stripped off while reassembling the stream. */ +export function decodePacketBody(body: Buffer): DecodedPacket { + const id = body.readInt32LE(0); + const type = body.readInt32LE(4); + const payload = body.toString('utf8', 8, body.length - 2); + return { id, type, payload }; +} diff --git a/console-rcon-package/lib/rcon-connection.ts b/console-rcon-package/lib/rcon-connection.ts new file mode 100644 index 0000000..6e7b515 --- /dev/null +++ b/console-rcon-package/lib/rcon-connection.ts @@ -0,0 +1,121 @@ +import { createConnection, Socket } from 'net'; +import { PacketType, decodePacketBody, encodePacket } from './protocol.js'; + +interface Waiter { + resolve: (payload: string) => void; + reject: (error: Error) => void; +} + +/** + * One authenticated RCON connection: connects and authenticates lazily on first use, + * reassembles the length-prefixed packet stream, and matches responses back to callers by + * request id. Reconnects on the next call after the socket closes β€” an RCON server dropping + * an idle connection is normal, not a hard failure. + */ +export class RconConnection { + private socket: Socket | null = null; + private connectPromise: Promise | null = null; + private inbound: Buffer = Buffer.alloc(0); + private nextId = 1; + private pendingAuth: Waiter | null = null; + private readonly pending = new Map(); + + constructor( + private readonly host: string, + private readonly port: number, + private readonly password: string, + ) {} + + async ensureConnected(): Promise { + if (this.connectPromise) return this.connectPromise; + + this.connectPromise = new Promise((resolve, reject) => { + const socket = createConnection({ host: this.host, port: this.port }); + this.socket = socket; + + socket.once('connect', () => { + this.pendingAuth = { + resolve: () => resolve(), + reject: (err) => reject(err), + }; + const id = this.nextId++; + socket.write(encodePacket(id, PacketType.AUTH, this.password)); + }); + + socket.on('data', (chunk) => this.onData(chunk)); + + socket.once('error', (err) => { + this.connectPromise = null; + reject(err); + }); + + socket.once('close', () => { + this.connectPromise = null; + this.socket = null; + const closedError = new Error('RCON connection closed'); + this.pendingAuth?.reject(closedError); + this.pendingAuth = null; + for (const waiter of this.pending.values()) waiter.reject(closedError); + this.pending.clear(); + }); + }); + + return this.connectPromise; + } + + private onData(chunk: Buffer): void { + this.inbound = this.inbound.length > 0 ? Buffer.concat([this.inbound, chunk]) : chunk; + + while (this.inbound.length >= 4) { + const size = this.inbound.readInt32LE(0); + if (this.inbound.length < 4 + size) break; + + const body = this.inbound.subarray(4, 4 + size); + this.inbound = this.inbound.subarray(4 + size); + this.handlePacket(decodePacketBody(body)); + } + } + + private handlePacket(packet: { id: number; type: number; payload: string }): void { + if (packet.type === PacketType.AUTH_RESPONSE && this.pendingAuth) { + const waiter = this.pendingAuth; + this.pendingAuth = null; + if (packet.id === -1) waiter.reject(new Error('RCON authentication failed: wrong password')); + else waiter.resolve(''); + return; + } + + const waiter = this.pending.get(packet.id); + if (waiter) { + this.pending.delete(packet.id); + waiter.resolve(packet.payload); + } + } + + async executeAndWait(cmd: string, timeoutMs: number): Promise { + await this.ensureConnected(); + const socket = this.socket; + if (!socket) throw new Error('RCON connection is not open'); + + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`RCON command timed out after ${timeoutMs}ms: ${cmd}`)); + }, timeoutMs); + + this.pending.set(id, { + resolve: (payload) => { clearTimeout(timer); resolve(payload); }, + reject: (err) => { clearTimeout(timer); reject(err); }, + }); + + socket.write(encodePacket(id, PacketType.EXECCOMMAND, cmd)); + }); + } + + execute(cmd: string): void { + this.executeAndWait(cmd, 5000).catch((error: Error) => { + console.error(`[rcon] command failed: ${cmd}: ${error.message}`); + }); + } +} diff --git a/console-rcon-package/package-lock.json b/console-rcon-package/package-lock.json new file mode 100644 index 0000000..eb1a967 --- /dev/null +++ b/console-rcon-package/package-lock.json @@ -0,0 +1,203 @@ +{ + "name": "@plugwright/console-rcon", + "version": "3.0.0-dev.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@plugwright/console-rcon", + "version": "3.0.0-dev.0", + "license": "MIT", + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@drownek/plugwright": ">=3.0.0-dev.0" + } + }, + "../runner-package": { + "name": "@drownek/plugwright", + "version": "3.0.0-dev.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "mineflayer": "^4.0.0", + "picocolors": "^1.1.1", + "source-map-support": "^0.5.21" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.5", + "@types/source-map-support": "^0.5.10", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@drownek/plugwright": { + "resolved": "../runner-package", + "link": true + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/console-rcon-package/package.json b/console-rcon-package/package.json new file mode 100644 index 0000000..a182928 --- /dev/null +++ b/console-rcon-package/package.json @@ -0,0 +1,50 @@ +{ + "name": "@plugwright/console-rcon", + "version": "3.0.0-dev.0", + "description": "RCON server console for plugwright's \"external\" mode", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "rimraf dist && tsc", + "prepare": "npm run build", + "prepublishOnly": "npm run build", + "watch": "tsc --watch", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "keywords": [ + "minecraft", + "rcon", + "plugwright", + "testing" + ], + "author": "drownek", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Drownek/plugwright.git", + "directory": "console-rcon-package" + }, + "homepage": "https://github.com/Drownek/plugwright#readme", + "bugs": { + "url": "https://github.com/Drownek/plugwright/issues" + }, + "peerDependencies": { + "@drownek/plugwright": ">=3.0.0-dev.0" + }, + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/console-rcon-package/tsconfig.json b/console-rcon-package/tsconfig.json new file mode 100644 index 0000000..f2df9c3 --- /dev/null +++ b/console-rcon-package/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 2ae3768..bbe5ac4 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -3,6 +3,34 @@ title: "Configuration" description: "Complete reference for Gradle plugin configuration options." --- +There are two ways to write this block, and they describe the same thing. + +The short one is everything below: flat properties on `plugwright { }`, describing a single local Paper server. Nothing about it has changed, and builds that use it keep working. + +The long one names its servers explicitly, which is what you want as soon as there is more than one: + +```kotlin +import me.drownek.plugwright.local.LocalMode + +plugwright { + testsDir.set(file("src/test/e2e")) + + environments { + create("local", LocalMode) { + minecraftVersion.set("1.21.11") + runDir.set(file("run")) + acceptEula.set(true) + } + } +} +``` + +Inside `create("local", LocalMode) { }` you get the same properties documented on this page, plus `includeInMatrix`, `allowFailure`, `excludeTests` and `plugins { }`. Adding a second environment β€” a staging server, a proxy, anything β€” is a second `create` call. See [Environments](/environments) and [External Servers](/external-servers). + + +Without an `environments { }` block, the flat properties below describe one implicit environment named `local`. They are deprecated and will be removed in 4.0. + + ## Example Configuration In your `build.gradle.kts`: @@ -167,6 +195,59 @@ downloadNode.set(true) // no local Node.js required - download it automatically nodeVersion.set("22.14.0") ``` +## Multi-environment options + +These live on `plugwright { }` itself, next to `testsDir`. + + + Environment the unsuffixed task aliases point at. `plugwrightRunServer` means `plugwrightRunServerLocal` when this is `"local"`. Default is `"local"`. It does not mean "the only environment that runs" β€” the matrix runs all of them. + + +```kotlin +primaryEnvironment.set("local") +``` + + + Settings for the `plugwrightTest` matrix run. `parallel` runs environments concurrently (off by default), `maxParallel` caps how many at once (default `2`). + + +```kotlin +matrix { + parallel.set(true) + maxParallel.set(2) +} +``` + +Per-environment, inside `create(...) { }`: + + + Whether `plugwrightTest` includes this environment. `true` for `LocalMode`, `false` for `ExternalMode`. Ignored when the per-environment task is called directly. + + + + Whether failures here fail the matrix build. Failures are still reported as failures. Default `false`, and ignored when the per-environment task is called directly. + + + + Test name substrings to skip in this environment. Skipped tests appear in the report with the reason. + + + + Port the local server binds and bots connect on. Default `25565`. + + + + Runner plugins this environment loads: `npm("@scope/name") { options["key"] = "value" }` for a published package, `local(file("…"))` for a compiled file in your test project. See [Runner Plugins](/plugins). + + +```kotlin +plugins { + npm("@plugwright/auth-authme") { + options["loginCommand"] = "/login" + } +} +``` + ## Environment Variables diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx new file mode 100644 index 0000000..b422cc2 --- /dev/null +++ b/docs/custom-modes.mdx @@ -0,0 +1,165 @@ +--- +title: "Writing a Mode" +description: "Teach Plugwright about a kind of server it doesn't ship support for." +--- + +`local` and `external` cover the two common cases: a server Plugwright owns, and one it doesn't. A mode of your own is for the cases in between β€” a Velocity proxy with backend servers, a Docker Compose stack, a server your company provisions through an internal API. + +A mode has two halves that version independently: + +- **Kotlin**, in the build: how the environment is declared and what has to happen before tests run. +- **JavaScript**, in the runner: where the bots connect and what the environment can do. + +The build writes a config file; the runner reads it. Nothing else passes between them. + +## The Kotlin half + +Your module compiles against the API classes, which ship inside the published plugin jar: + +```kotlin +// buildSrc, or a separate published module +plugins { `kotlin-dsl` } + +dependencies { + compileOnly("io.github.drownek:plugwright-bundle:3.0.0") +} +``` + +`compileOnly` on purpose. The plugin is already on the build's classpath at runtime, and a second copy is how you get a `NoSuchMethodError` that takes an afternoon to read. + +### The spec + +The spec is what a build script fills in. Use Gradle property types so laziness and the configuration cache keep working: + +```kotlin +class VelocityEnvironmentSpec( + private val environmentName: String, + objects: ObjectFactory, +) : EnvironmentSpec { + + override fun getName() = environmentName + + override val includeInMatrix: Property = objects.property(Boolean::class.java).convention(false) + override val allowFailure: Property = objects.property(Boolean::class.java).convention(false) + override val excludeTests: ListProperty = objects.listProperty(String::class.java).convention(emptyList()) + + val composeFile: RegularFileProperty = objects.fileProperty() + val proxyPort: Property = objects.property(Int::class.java).convention(25577) +} +``` + +### The mode + +```kotlin +object VelocityMode : PlugwrightMode { + override val id = "velocity" + override val specType = VelocityEnvironmentSpec::class.java + + override fun createSpec(name: String, objects: ObjectFactory) = + VelocityEnvironmentSpec(name, objects) + + override fun runnerPackages(spec: VelocityEnvironmentSpec) = listOf( + RunnerPackageRef("@acme/plugwright-velocity", "^1.0.0", export = "velocityEnvironment") + ) + + override fun validate(spec: VelocityEnvironmentSpec, ctx: ValidationContext) { + if (!spec.composeFile.isPresent) ctx.error("composeFile must be set") + } + + override fun serialize(spec: VelocityEnvironmentSpec, node: ConfigNodeBuilder) { + node.put("proxyPort", spec.proxyPort.get()) + node.put("composeFile", spec.composeFile.get().asFile.absolutePath) + } + + override fun registerTasks(spec: VelocityEnvironmentSpec, ctx: TaskRegistrationContext) { + val up = ctx.register("Up", ComposeUpTask::class.java) { + composeFile.set(spec.composeFile) + pluginJar.set(ctx.projectPluginJar) + } + ctx.register("Down", ComposeDownTask::class.java) { composeFile.set(spec.composeFile) } + ctx.prepareTask(up) + } +} +``` + +What each piece is for: + +- `id` lands in the config as `environment.mode` and names the mode in error messages. +- `runnerPackages` is installed by `plugwrightCompileTests`, merged with every other environment's packages into one `npm install`. The first entry with an `export` becomes the runtime reference the runner loads the environment from, so name it there. +- `validate` reports problems through the context instead of throwing. Every environment is validated before the build fails, so a script with three mistakes reports three, not the first. +- `serialize` writes `environment.config` at configuration time. Secrets stay `SecretRef`s here β€” `node.put("password", spec.password.get())` writes a reference, not a password. +- `registerTasks` adds tasks named `plugwright`, so `register("Up", ...)` in an environment called `proxy` gives `plugwrightUpProxy`. `prepareTask` marks the one that has to run before the tests do. + +Preparation belongs in a task rather than a callback. A callback executed inside someone else's `@TaskAction` drags your mode object into that task's state, breaks the configuration cache, and can never be run on its own. A task with declared inputs and outputs gets up-to-date checks and a name someone can type. + +If a config value needs something only a task can reach β€” the Java toolchain, a Gradle service β€” set it from `registerTasks` with `ctx.environmentConfig(provider)` instead of from `serialize`. That is what `LocalMode` does for the Java executable path. + +### Registering it + +```kotlin +buildscript { + dependencies { classpath("com.acme:plugwright-velocity:1.0.0") } +} + +plugwright { + registerMode(com.acme.VelocityMode) + + environments { + create("proxy", com.acme.VelocityMode) { + composeFile.set(file("docker/compose.yml")) + proxyPort.set(25577) + } + } +} +``` + +`create` is generic over the mode, so the block has your spec type as its receiver with no cast. + +## The JavaScript half + +The npm package named in `runnerPackages` exports a factory. It takes the `environment.config` object your `serialize` wrote and returns an `Environment`: + +```ts +import type { Environment, EnvironmentCapabilities, BotConnectionOptions } from '@drownek/plugwright'; + +export function velocityEnvironment(config: VelocityConfig): Environment { + return new VelocityEnvironment(config); +} + +class VelocityEnvironment implements Environment { + readonly id = 'velocity'; + readonly capabilities: EnvironmentCapabilities = { + console: true, + consoleOutput: 'responses', + op: true, + freshState: false, + arbitraryUsernames: true, + lifecycle: true, + cleanupStrategy: 'compensating', + }; + + async setup(session: Session): Promise { /* connect, probe, warm up */ } + connection(): BotConnectionOptions { /* host, port, version, auth */ } + console(): ServerConsole | null { /* the channel tests run commands through */ } + accounts(): AccountPool | null { return null; } // optional + async beforeJoin(): Promise { /* throttle, if the server needs it */ } + async teardown(): Promise { /* disconnect, stop what you started */ } +} +``` + +Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. + +`accounts()` and `beforeJoin()` are optional. Returning no pool means every bot gets a throwaway `Test_` username, which is what `local` does. + +## Checking it works + +```bash +./gradlew plugwrightTestProxy --info +cat build/tmp/plugwright/proxy.json +``` + +The config file is the contract between the two halves, and reading it answers most of the questions that come up while a mode is half-written. If the runner says the mode is one it "cannot run yet", the runtime reference is missing β€” check that a `RunnerPackageRef` in `runnerPackages` names an `export`. + +## Versioning + +`PlugwrightMode.apiVersion` defaults to the API version your module compiled against, and Plugwright refuses to load a mode whose version it doesn't understand. On the runner side, `RunnerPackageRef` carries an npm range for the same reason: the Kotlin module and the npm package are released separately, and the pair has to agree. diff --git a/docs/docs.json b/docs/docs.json index 792fdee..55dccec 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -35,6 +35,16 @@ "api-reference", "examples" ] + }, + { + "group": "Environments", + "pages": [ + "environments", + "external-servers", + "plugins", + "reports", + "custom-modes" + ] } ] } diff --git a/docs/environments.mdx b/docs/environments.mdx new file mode 100644 index 0000000..d9f8251 --- /dev/null +++ b/docs/environments.mdx @@ -0,0 +1,133 @@ +--- +title: "Environments" +description: "Declare the servers your tests run against, and run the same suite on all of them." +--- + +An environment is one server your tests can run against. Every environment is backed by a **mode**, which decides where that server comes from: + +| Mode | Where the server comes from | +|---|---| +| `LocalMode` | Plugwright downloads Paper, patches the configs, starts it, and kills it afterwards | +| `ExternalMode` | Someone else started it. Plugwright connects and leaves it running | + +Both ship with the plugin. A third mode is something you write yourself β€” see [Writing a mode](/custom-modes). + +## Declaring environments + +```kotlin +import me.drownek.plugwright.local.LocalMode +import me.drownek.plugwright.external.ExternalMode + +plugwright { + testsDir.set(file("src/test/e2e")) + primaryEnvironment.set("local") + + environments { + create("local", LocalMode) { + minecraftVersion.set("1.21.11") + acceptEula.set(true) + runDir.set(file("run")) + } + + create("staging", ExternalMode) { + host.set("mc.example.com") + port.set(25565) + minecraftVersion.set("1.20.4") + } + } +} +``` + +The name you pass to `create` becomes the task suffix and the report file name: `local` gives you `plugwrightTestLocal` and `build/reports/plugwright/local.json`. + + +A build script with no `environments { }` block still works. The flat properties (`minecraftVersion`, `runDir`, `downloadPlugins`, and the rest) describe one implicit `local` environment, exactly as they did before. See [Configuration](/configuration). + + +## Tasks + +``` +plugwrightCompileTests npm install + tsc, shared by every environment +plugwrightProvisionLocal download Paper, patch configs, copy the plugin jar +plugwrightCleanLocal wipe the run directory +plugwrightRunServerLocal start the server interactively, no tests +plugwrightPingStaging check that an external stand answers, no tests +plugwrightCleanStaging compensating cleanup on an external stand +plugwrightTestLocal run the suite against one environment +plugwrightTestStaging +plugwrightTest the matrix: every environment with includeInMatrix +``` + +Which tasks exist depends on the mode. `LocalMode` contributes provisioning, cleaning and a server-run task; `ExternalMode` contributes ping and cleanup, and nothing that touches files. + +Tasks for the `primaryEnvironment` also get an unsuffixed alias, so `plugwrightRunServer` still means what it used to. `plugwrightTest` is the exception: it belongs to the matrix. + +## The matrix + +`plugwrightTest` runs every environment whose `includeInMatrix` is true, one runner process each, and prints a summary: + +``` +Environment summaries: + local 42 passed, 0 failed, 0 skipped (1m 12s) + staging 31 passed, 2 failed, 9 skipped (2m 03s) [allowFailure] +``` + +It launches the runner itself rather than depending on the per-environment tasks. A `dependsOn` chain would stop at the first failing environment and hide the results of the rest. + +Defaults differ by mode on purpose. `LocalMode` sets `includeInMatrix` to true β€” a server that only exists during the run belongs in every run. `ExternalMode` sets it to false, because a shared stand should not be pulled into someone's local `plugwrightTest` unasked. + +```kotlin +create("staging", ExternalMode) { + // Only in CI, and never fail the build when the stand is flaky + includeInMatrix.set(providers.environmentVariable("CI").map { it == "true" }.orElse(false)) + allowFailure.set(true) +} +``` + +`allowFailure` keeps a failing environment from failing the matrix build. The failures are still reported as failures. Calling `plugwrightTestStaging` directly ignores both flags: an explicit request deserves an honest exit code. + +### Narrowing the matrix + +```bash +./gradlew plugwrightTest -Pplugwright.env=local,staging +``` + +### Running environments in parallel + +```kotlin +plugwright { + matrix { + parallel.set(true) + maxParallel.set(2) + } +} +``` + +Off by default, and worth thinking about before you turn it on. Two local Paper servers means twice the `-Xmx`. Several environments sharing one outbound IP means more join throttling and more ban risk on a public stand. Account pools must not overlap. Output is interleaved, so each environment's log is also written separately to `build/reports/plugwright/.log`. + +## Per-environment test selection + +`excludeTests` skips tests whose name contains any of the given substrings. It is matched against the test name, not the file name: + +```kotlin +create("staging", ExternalMode) { + excludeTests.set(listOf("balance", "kit", "arena")) +} +``` + +Skipped tests appear in the report with the reason. Silence would be worse than a failure here: a test that quietly disappears on one environment looks like coverage you don't have. + +Tests can also select environments themselves, either by capability or by name. See [Test Filtering](/test-filtering). + +## Secrets + +Passwords never belong in the config file Gradle writes into `build/`. Declare them as references instead: + +```kotlin +import me.drownek.plugwright.api.secret + +password.set(secret.env("BOT_PASSWORD")) +password.set(secret.file(file("/etc/plugwright/bot.pass"))) +``` + +`secret.env` reads an environment variable, `secret.file` the first line of a file. Both are resolved by the runner at run time, so the value stays out of the configuration cache and out of build artifacts. `secret.systemProperty` exists for symmetry but fails at run time β€” the runner is a separate Node process and cannot see JVM system properties. diff --git a/docs/examples.mdx b/docs/examples.mdx index 5eb5512..3b28ab9 100644 --- a/docs/examples.mdx +++ b/docs/examples.mdx @@ -14,6 +14,8 @@ The **[`example_plugin`](https://github.com/Drownek/plugwright/tree/main/example - **Events**: Testing actions triggered by player joins or scheduled server tasks. - **Teleportation**: Warps, commands, and movement. +Its `build.gradle.kts` also declares two environments for the same suite. `local` downloads Paper, installs AuthMe next to the plugin under test, writes an AuthMe config a bot can actually get through, and logs every bot in with `@plugwright/auth-authme`. `stand` connects to a server started by hand from the same run directory, leasing accounts from a pool, reaching the console over RCON, and resetting op and inventory between tests through a small local plugin. Reading the two side by side is the shortest way to see what changes when Plugwright stops owning the server. + Explore the Java source code and TypeScript test specs to see how to implement robust E2E tests for your own plugins. diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx new file mode 100644 index 0000000..f8dc16e --- /dev/null +++ b/docs/external-servers.mdx @@ -0,0 +1,147 @@ +--- +title: "External Servers" +description: "Run the same suite against a server Plugwright does not own." +--- + +`ExternalMode` points bots at a server that is already running: a staging stand, a colleague's box, the production copy someone keeps for QA. Plugwright starts nothing, patches nothing and shuts nothing down. + +That changes what the suite can assume. A local server hands every test a fresh world and a brand new username. A stand hands you whatever the last test left behind, on an account you have to log in as, and the plugin under test is already installed there β€” deploying it is out of scope for this mode by design. + +```kotlin +import me.drownek.plugwright.api.secret +import me.drownek.plugwright.external.ExternalMode + +environments { + create("staging", ExternalMode) { + host.set("mc.example.com") + port.set(25565) + minecraftVersion.set("1.20.4") + joinThrottleMs.set(3000) + excludeTests.set(listOf("arena", "kit")) + + console { + rcon { port.set(25575); password.set(secret.env("RCON_PASSWORD")) } + adminBot("StaffBot") { password.set(secret.env("STAFF_PASSWORD")) } + } + + accounts { + pool { + account("TestBot1") { password.set(secret.env("BOT1_PASSWORD")) } + account("TestBot2") { password.set(secret.env("BOT2_PASSWORD")) } + } + autoRegister { + usernamePattern.set("pw_%04d") + password.set(secret.env("BOT_PASSWORD")) + max.set(4) + } + } + + plugins { + npm("@plugwright/auth-authme") + } + } +} +``` + +`minecraftVersion` is required here, unlike in `LocalMode` where the version is what Plugwright downloaded. A proxy in front of the stand (ViaVersion and friends) defeats protocol autodetection, so guessing would produce a confusing connection failure instead of a clear one. + +`joinThrottleMs` is the minimum delay between two bot connections. Public servers treat a burst of logins as an attack; a few seconds of spacing is cheaper than getting the CI runner's IP banned. + +## Console channels + +Without a process of its own, the mode has no stdout to read and no stdin to write. A console channel is how tests reach `server.execute(...)`, `player.makeOp()` and everything else that needs the server side. + +Channels are probed in declaration order, and the first one that answers becomes the session's console. The chosen channel is printed in the run header. + +| Channel | Output level | Notes | +|---|---|---| +| `rcon { }` | `responses` | Needs `enable-rcon=true` on the server. Installs `@plugwright/console-rcon` | +| `adminBot("Name") { }` | `responses` | A second bot with staff rights that sends commands through chat | +| stdio | `full` | `LocalMode` only β€” Plugwright owns the process | + +The output level matters more than it looks. `full` means the whole server log is readable, so `expect(server).toHaveReceivedMessage(...)` works. `responses` means you get back what the command printed and nothing else. A test that reads the server log should say so: + +```ts +test('command is logged', { requires: ['consoleOutput:full'] }, async ({ server }) => { + server.execute('say hello'); + await expect(server).toHaveReceivedMessage('hello'); +}); +``` + +Declaring no channel at all is valid. The environment runs without a console, and every test that requires one is skipped and reported as skipped. + +The admin bot connects through the same code path as a test bot, which means it goes through your authentication plugin too, and it connects before any test bot does. + +## Accounts + +A local server accepts any username; a stand usually does not. `accounts { }` builds a pool that tests lease from and return to, merged from three sources: + +- **`pool`** β€” accounts that already exist, with their passwords. +- **`autoRegister`** β€” generated names from a pattern, marked `justCreated` on their first lease so an authentication plugin registers them instead of logging in. The pattern must start with `pw_`, so test accounts stay recognizable on a server full of real players. +- **`microsoft`** β€” online-mode accounts. No password; mineflayer authenticates with a cached device-code token. Point `cacheDir` somewhere outside `build/`, and warm the cache before CI ever needs it, because the device-code flow is interactive. + +One account is leased per bot and returned in a `finally`, whatever the test did. When the pool is empty and `autoRegister` has hit `max`, `lease()` throws rather than hand the same identity to two connected bots. + +An explicitly named bot bypasses the pool entirely: + +```ts +const friend = await createPlayer({ username: 'FriendBot' }); +``` + +That is a request for a specific identity, not for whatever is free β€” so nothing knows its password. On a stand behind a login wall, either leave those tests to the local environment or give the account a password some other way. + + +A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, exclude what you can't, and treat `capabilities.freshState = false` as the honest description it is. + + +## Checking the stand before you test + +```bash +./gradlew plugwrightPingStaging +``` + +Connects, probes the console channels, leases one account and authenticates with it, then disconnects. No tests run. When something is wrong with the stand β€” RCON password rotated, login plugin changed its messages, account pool exhausted β€” this fails in seconds with a specific message instead of failing test after test five minutes into a run. + +## Cleaning up + +`plugwrightClean` means something different per mode. For `LocalMode` it wipes the run directory. For `ExternalMode` there is nothing to wipe: it starts the runner in cleanup mode, which connects, loads the plugins and calls their `cleanup({ scope: 'manual' })` handlers. No files are touched. + +```kotlin +// in a runner plugin +definePlugin({ + name: 'staging', + async cleanup({ session, scope }) { + // scope: 'session' after a run, 'manual' from plugwrightCleanStaging + await session.console?.executeAndWait('/pw purge-test-data'); + }, +}); +``` + +### The journal + +Finalizers registered with `TestContext.cleanup()` run in a `finally`. A `SIGKILL` skips `finally` blocks, and on a real server the leftovers accumulate β€” a hundred junk warps a month later. + +For obligations that must survive that, record a typed entry in `build/plugwright/-journal.jsonl`: + +```ts +test('creating a warp', async ({ player, server, cleanup }) => { + const warpName = `pw_${crypto.randomUUID().slice(0, 8)}`; + const id = warpName; + + player.chat(`/setwarp ${warpName}`); + server.session.journal.record(id, { kind: 'warp', name: warpName }); + + cleanup(() => { + server.execute(`/delwarp ${warpName}`); + server.session.journal.forget(id); + }); + + await expect(player).toHaveReceivedMessage('Warp created'); +}); +``` + +Entries are typed records interpreted by a plugin's `cleanup` handler, never raw command strings. A file that replays raw commands against a live server is a way to run arbitrary commands on it. Whatever is still in the journal when the next run starts is what a crash left behind; `plugwrightClean` prints anything a cleanup pass could not resolve. + +## What the runner reports as skipped + +After `setup()`, the environment reports what it actually supports. For `ExternalMode` that is: no fresh state, no server lifecycle, compensating cleanup, arbitrary usernames, and console plus op only if a console channel answered. Tests that declare `requires` are skipped against that list, with the reason in the report. See [Test Filtering](/test-filtering). diff --git a/docs/plugins.mdx b/docs/plugins.mdx new file mode 100644 index 0000000..9e33281 --- /dev/null +++ b/docs/plugins.mdx @@ -0,0 +1,154 @@ +--- +title: "Runner Plugins" +description: "Hooks, fixtures, matchers and inherited tests, without touching the test engine." +--- + +A runner plugin extends what happens around your tests. Logging in through AuthMe, adding an `expect(player).toHaveBalance(100)` matcher, resetting state between tests on a shared stand, shipping a suite of tests that any server running your plugin should pass β€” all of that is a plugin, and none of it requires the test engine to know about it. + +Plugins are declared per environment: + +```kotlin +create("staging", ExternalMode) { + plugins { + npm("@plugwright/auth-authme") { + options["loginCommand"] = "/log" + } + local(file("src/test/e2e/dist/plugins/staging.js")) { + inheritTests = false + } + } +} +``` + +`npm(...)` names a published package, installed by `plugwrightCompileTests` along with the rest of the environment's packages. `local(...)` points at a compiled file in your own test project. Options are plain strings β€” anything secret belongs in `accounts { }`, where it stays a secret reference. + +`LocalMode` takes the same block. A local server running an authentication plugin needs the login hook exactly as much as a remote one does. + +## What a plugin can do + +```ts +export interface PlugwrightPlugin { + name: string; + apiVersion?: number; + setup?(ctx: { session, env, options: O }): Promise | void; + onPlayerCreate?(player, ctx: { account, env }): Promise | void; + beforeEach?(ctx: TestContext): Promise | void; + afterEach?(ctx: TestContext): Promise | void; + extendContext?(ctx: TestContext): Record | void; + matchers?: Record; + tests?: Array<{ file: string; mode: 'preflight' | 'suite' }>; + cleanup?(ctx: { session, scope: 'session' | 'manual' }): Promise | void; + teardown?(): Promise | void; +} +``` + +Order over one run: + +``` +env.setup() β†’ console probe β†’ load plugins β†’ register matchers β†’ plugins.setup() + β†’ preflight tests (a failure here aborts the run) + β†’ user specs + suite tests + per test: lease account β†’ connect β†’ onPlayerCreate β†’ beforeEach + β†’ body β†’ cleanup finalizers β†’ afterEach β†’ return account + β†’ reports β†’ cleanup('session') β†’ teardown() β†’ env.teardown() +``` + +Matchers are merged into the shared prototype before the first spec file is imported. That ordering is not incidental: `expect(x).toHaveBalance()` looks the matcher up when it is called, but the spec file has to typecheck and import first. + +## Authentication is a hook, not a test + +```ts +import { definePlugin, poll } from '@drownek/plugwright'; + +export default definePlugin({ + name: 'authme', + async onPlayerCreate(player, { account }) { + if (account.auth === 'microsoft') return; + // wait for the prompt, answer it, wait for the confirmation + }, +}); +``` + +`onPlayerCreate` fires on every connection: the first bot of a test, a second bot from `createPlayer()`, every `player.rejoin()`, and the admin-bot console channel. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook. + +## Inherited tests + +```ts +tests: [ + { file: join(__dirname, 'auth.spec.js'), mode: 'preflight' }, + { file: join(__dirname, 'economy.spec.js'), mode: 'suite' }, +] +``` + +`preflight` tests run before any user spec and abort the run when they fail β€” there is no point testing a shop when nobody can log in. `suite` tests run alongside your own and are tagged with the plugin's name in the report. + +Spec discovery skips `node_modules`, so this is the only way a packaged test ever runs. Per-plugin, `inheritTests = false` loads the hooks and matchers without the tests. + +## Fixtures + +`extendContext` adds fields to the object every test destructures: + +```ts +extendContext(ctx) { + return { auth: new AuthApi(ctx.player) }; +} +``` + +```ts +declare module '@drownek/plugwright' { + interface TestContext { + auth: AuthApi; + } +} +``` + +The declaration merging block is what gives you types and autocompletion at the call site. Without it the fixture still works, and TypeScript still complains. + +## Matchers + +```ts +matchers: { + async toHaveBalance(this: any, expected: number) { + await this.pollAssertion( + () => currentBalance(this.actual) === expected, + () => `Expected NOT to have balance ${expected}`, + () => `Expected balance ${expected}, got ${currentBalance(this.actual)}`, + ); + }, +} +``` + +Anything that reads the server log has to check the console output level first, because a console that only answers its own commands leaves that buffer empty. See [External Servers](/external-servers). + +## Versioning + +```ts +export default definePlugin({ name: 'authme', apiVersion: 1 }); +``` + +A plugin built against a newer contract than the runner supports fails to load with a message saying so. Leaving `apiVersion` unset skips the check. + +## Writing one + +A plugin is an npm package (or a single compiled file) whose default export implements the interface: + +```ts +import { definePlugin } from '@drownek/plugwright'; + +export default definePlugin<{ resetCommand?: string }>({ + name: 'staging-reset', + + setup({ options }) { + resetCommand = options.resetCommand ?? '/pw reset'; + }, + + async beforeEach({ player, server }) { + if (!server.session.env.capabilities.console) return; + await server.executeAndWait(`minecraft:clear ${player.username}`); + }, +}); +``` + +`definePlugin` is an identity function; it exists so TypeScript infers your options type at the definition site. Depend on `@drownek/plugwright` as a peer dependency, ship compiled JavaScript, and point `main` at it. + +`@plugwright/auth-authme` in this repository is a complete, working example: a hook, an options interface, a preflight test, and a README. diff --git a/docs/reports.mdx b/docs/reports.mdx new file mode 100644 index 0000000..a125849 --- /dev/null +++ b/docs/reports.mdx @@ -0,0 +1,84 @@ +--- +title: "Reports" +description: "JSON and JUnit XML output, per environment." +--- + +Every run writes two report files, whether it was started by `plugwrightTest` or by the matrix: + +``` +build/reports/plugwright/.json machine-readable, what the matrix aggregates +build/reports/plugwright/junit/.xml JUnit XML for CI +build/reports/plugwright/.log per-environment output, matrix runs only +``` + +## JSON + +```json +{ + "environment": "staging", + "summary": { "total": 47, "passed": 33, "failed": 0, "skipped": 14, "durationMs": 152340 }, + "tests": [ + { + "file": "…/dist/commands.spec.js", + "name": "help command shows available commands", + "status": "pass", + "durationMs": 63, + "error": null, + "skipReason": null, + "plugin": null + }, + { + "file": "…/dist/simple-ts.spec.js", + "name": "server logs command execution", + "status": "skip", + "durationMs": 0, + "error": null, + "skipReason": "requires capability [consoleOutput:full], unavailable on \"staging\"", + "plugin": null + } + ] +} +``` + +`status` is `pass`, `fail` or `skip`. `plugin` names the plugin a test came from when it was inherited rather than found in your test directory. + +Every skip carries its reason: excluded by name, wrong environment, or a capability the environment doesn't have. A skipped test that doesn't say why is worse than a failing one, because it reads as coverage. + +## JUnit XML + +```xml + + + + + + +``` + +The suite name is `plugwright.`, so a matrix run produces one suite per environment and CI keeps them apart. `classname` is the spec file, `name` is the full test name including its `describe` chain. Failures carry the message as the attribute and the stack as the body. + +Most CI systems pick these up with a glob: + +```yaml +- uses: actions/upload-artifact@v4 + if: always() + with: + name: plugwright-reports + path: build/reports/plugwright/ +``` + +## Matrix summary + +``` +Environment summaries: + local 47 passed, 0 failed, 0 skipped (4m 09s) + staging 33 passed, 2 failed, 14 skipped (2m 35s) [allowFailure] +``` + +An environment that produced no report at all gets an `ERROR:` line instead of counts: + +``` + staging ERROR: Command '…cli.js --config …' failed with exit code: 1 [allowFailure] +``` + +Failed tests and an unreachable server are different problems, and the summary keeps them apart so you know whether to read the diff or fix the stand. diff --git a/docs/test-filtering.mdx b/docs/test-filtering.mdx index 12ede14..f3ebc12 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -1,43 +1,88 @@ --- title: "Test Filtering" -description: "Run specific tests using `-PtestFiles` and `-PtestNames`." +description: "Pick tests by file, by name, by environment, or by what the environment can do." --- -### Syntax Rules -* **Matching:** Case-sensitive substring matching. -* **Multiple Patterns:** Comma-separated (no spaces). -* **Extensions:** No need to include `.spec.js` or `.spec.ts`. +## From the command line -## Filter by File -Run specific test files. +Matching is case-sensitive substring matching. Multiple patterns are comma-separated with no spaces, and file patterns don't need the `.spec.js` / `.spec.ts` suffix. ```bash -# Run basic.spec.js +# One file, or several ./gradlew plugwrightTest -PtestFiles="basic" - -# Run files matching "basic" OR "commands" ./gradlew plugwrightTest -PtestFiles="basic,commands" + +# By test name +./gradlew plugwrightTest -PtestNames="should connect" + +# Both: "purchase" tests inside "shop" files +./gradlew plugwrightTest -PtestFiles="shop" -PtestNames="purchase" + +# Narrow the matrix to specific environments +./gradlew plugwrightTest -Pplugwright.env=local,staging ``` -## Filter by Test Name -Run specific test cases. +Running `./gradlew plugwrightTest` with no arguments runs everything, on every environment in the matrix. -```bash -# Run tests containing "should connect" -./gradlew plugwrightTest -PtestNames="should connect" +## From the build script + +`excludeTests` skips tests whose name contains one of the substrings, for one environment only: -# Run tests matching "teleport" OR "spawn" -./gradlew plugwrightTest -PtestNames="teleport,spawn" +```kotlin +create("staging", ExternalMode) { + excludeTests.set(listOf("balance", "kit", "arena")) +} ``` -## Combine Filters -Run tests that match **both** the file and the name criteria. +## From the test itself -```bash -# Run "purchase" tests, but only inside "shop" files -./gradlew plugwrightTest -PtestFiles="shop" -PtestNames="purchase" +Two filters, meant for different problems. + +**By capability** β€” for a test that needs something the environment might not have. This travels with the test and doesn't care what the environments are called: + +```ts +test('give command hands over the item', { requires: ['console', 'op'] }, async ({ player }) => { + await player.giveItem('diamond', 1); +}); +``` + +Capability keys come from the environment's own report, after it has connected: + +| Key | Values | Meaning | +|---|---|---| +| `console` | boolean | Commands can be run at all | +| `consoleOutput` | `full` / `responses` / `none` | Whole server log, only command answers, or nothing | +| `op` | boolean | The environment can grant operator status | +| `freshState` | boolean | Each test gets a clean world and a clean player | +| `arbitraryUsernames` | boolean | Bots may pick their own names | +| `lifecycle` | boolean | The server can be restarted or stopped | +| `cleanupStrategy` | `wipe` / `compensating` / `none` | How cleanup happens after the run | + +A bare key is satisfied by anything other than `false`, `'none'` or an absent value. To demand one specific value, use `key:value`: + +```ts +test('command is logged', { requires: ['consoleOutput:full'] }, async ({ server }) => { + server.execute('say hello'); + await expect(server).toHaveReceivedMessage('hello'); +}); +``` + +That form exists because `requires: ['console']` is satisfied by an RCON console that answers its own commands, while reading the server log needs a console that streams all of it. Without the distinction you get tests that neither skip nor work. + +**By environment name** β€” for when the difference isn't a capability but what's installed on that particular server: + +```ts +test('the /debug dev command', { environments: ['local'] }, async ({ player }) => { + player.chat('/debug'); +}); +``` + +## Skips are reported + +Every skipped test lands in the report with its reason: + +``` + Test: server logs command execution - SKIPPED (requires capability [consoleOutput:full], unavailable on "staging") ``` - -**Note:** Running `./gradlew plugwrightTest` without arguments runs all tests. - +The reason is in the JSON report and in the `` element of the JUnit XML too. See [Reports](/reports). diff --git a/example_plugin/README.md b/example_plugin/README.md new file mode 100644 index 0000000..f5b2c0f --- /dev/null +++ b/example_plugin/README.md @@ -0,0 +1,60 @@ +# example_plugin + +A small Bukkit plugin and the E2E suite that tests it. Everything here runs against the plugwright build in this repository through `includeBuild("../gradle-plugin")`, so changes to the plugin or the runner show up without publishing anything. + +The same 47 tests run against two environments, declared in `build.gradle.kts`. + +## `local` β€” plugwright owns the server + +```bash +./gradlew plugwrightTest +``` + +Downloads Paper into `run/`, installs PlaceholderAPI and AuthMe next to the plugin under test, writes an AuthMe config a bot can get through, starts the server, runs everything, and shuts it down. Every test gets a fresh username, which AuthMe treats as a fresh registration, which `@plugwright/auth-authme` answers. + +## `stand` β€” someone else owns the server + +This one connects to a server that is already running and leaves it running. Provision it once, start it by hand, then point the tests at it. + +```bash +# 1. Prepare run/ (Paper, plugins, server.properties with RCON enabled) +./gradlew plugwrightProvisionLocal + +# 2. Start the server yourself, from the run directory +cd run && ./start.sh +``` + +`run/` is not in version control, so `start.sh` is yours to write. Anything that starts the jar with Java 21 will do: + +```sh +#!/usr/bin/env sh +set -e +cd "$(dirname "$0")" +JAVA_BIN="${JAVA_BIN:-java}" +JVM_ARGS="${JVM_ARGS:--Xmx2G}" +exec "$JAVA_BIN" $JVM_ARGS -Dcom.mojang.eula.agree=true -jar server.jar --nogui +``` + +`start.sh` is listed in the `local` environment's `cleanExcludePatterns`, so provisioning again won't delete it. + +```bash +# 3. In another terminal +export PLUGWRIGHT_BOT_PASSWORD=plugwright +export PLUGWRIGHT_RCON_PASSWORD=plugwright + +./gradlew plugwrightPingStand # connects, probes RCON, logs one bot in +./gradlew plugwrightTestStand +``` + +Expect skips. The stand leases four accounts from a pool instead of inventing a name per test, so anything that assumes a clean balance, an unclaimed kit or an empty arena is excluded, and anything that reads the whole server log is skipped β€” RCON answers commands, it doesn't stream the log. + +`plugins/stand-reset.ts` handles what can be reset: it deops the leased account and clears its inventory before each test. It is loaded for the `stand` environment only, through `plugins { local(...) }`. + +## Layout + +``` +src/main/java/…/ExamplePlugin.java the plugin under test +src/test/e2e/*.spec.ts the suite, run against both environments +src/test/e2e/plugins/stand-reset.ts a local runner plugin, stand only +build.gradle.kts both environment declarations +``` diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index a302baa..22e402d 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -1,3 +1,7 @@ +import me.drownek.plugwright.api.secret +import me.drownek.plugwright.external.ExternalMode +import me.drownek.plugwright.local.LocalMode + plugins { `java-library` id("de.eldoria.plugin-yml.bukkit") version "0.8.0" @@ -5,14 +9,131 @@ plugins { id("io.github.drownek.plugwright") version "3.0.0-dev.0" } +// Password every bot on the local server registers with. It guards a server that lives for +// the length of one test run, so it is a literal here; on a real stand the password belongs +// in an account pool, where it stays a secret reference until the runner reads it. +val localBotPassword = "plugwright" + +// RCON password shared by the server the "stand" environment connects to and by the console +// channel that connects back to it. The literal is the fallback for a server started without +// the variable set; the console channel reads the variable itself, at run time. +val standRconPassword: String = providers.environmentVariable("PLUGWRIGHT_RCON_PASSWORD").getOrElse("plugwright") + plugwright { - minecraftVersion.set("1.21.11") - acceptEula.set(true) testsDir.set(file("src/test/e2e")) - downloadPlugins { - url("https://hangarcdn.papermc.io/plugins/HelpChat/PlaceholderAPI/versions/2.11.6/PAPER/PlaceholderAPI-2.11.6.jar") - } downloadNode.set(System.getenv("CI") != "true") + primaryEnvironment.set("local") + + environments { + // Paper downloaded, patched, started and killed by plugwright itself. + create("local", LocalMode) { + minecraftVersion.set("1.21.11") + acceptEula.set(true) + runDir.set(file("run")) + + // start.sh is the hand-written launcher the "stand" environment connects to; it + // lives in the run directory and has to survive the clean that precedes each run. + cleanExcludePatterns.set(listOf("server.jar", "cache", "libraries", "start.sh")) + + downloadPlugins { + url("https://hangarcdn.papermc.io/plugins/HelpChat/PlaceholderAPI/versions/2.11.6/PAPER/PlaceholderAPI-2.11.6.jar") + url("https://github.com/AuthMe/AuthMeReloaded/releases/download/6.0.0/AuthMe-6.0.0-Paper.jar") + } + + // Two things the stock AuthMe config does that no bot can answer: it asks for the + // password through Paper's dialog UI, and it allows one registration per IP, while + // a fresh bot name per test means a fresh registration per test from 127.0.0.1. + writeFiles { + // RCON is off in a stock server.properties. The local environment talks to the + // server through its own stdout and never needs it; the "stand" environment, + // which owns no process, has no other way to reach the console. + file("server.properties", """ + enable-rcon=true + rcon.port=25575 + rcon.password=$standRconPassword + """.trimIndent()) + + file("plugins/AuthMe/config.yml", """ + settings: + sessions: + enabled: false + registration: + dialog: + preJoin: + enable: false + postJoin: + enable: false + restrictions: + maxRegPerIp: 0 + maxJoinPerIp: 0 + maxLoginPerIp: 0 + timeout: 60 + allowedNicknameCharacters: '[a-zA-Z0-9_]*' + security: + minPasswordLength: 5 + Protection: + # A test suite is a stream of short-lived logins from one address, + # which is exactly what AuthMe's antibot heuristic exists to stop. + enableAntiBot: false + quickCommands: + # A test sends its first command the moment it is logged in, which + # the stock one-second grace period treats as bot behavior. + denyCommandsBeforeMilliseconds: 0 + """.trimIndent()) + } + + plugins { + npm("@plugwright/auth-authme") { + options["password"] = localBotPassword + } + } + } + + // The same tests against a server plugwright does not own: started by hand from + // ./run, still up when the tests connect, still up after they finish. Out of the + // default matrix because it needs that server to be running. + create("stand", ExternalMode) { + host.set("localhost") + port.set(25565) + minecraftVersion.set("1.21.11") + includeInMatrix.set(false) + joinThrottleMs.set(500) + + // The stand's own console, over the port the local environment enabled in + // server.properties. Without it there is no way to op a bot or read server output. + console { + rcon { + port.set(25575) + password.set(secret.env("PLUGWRIGHT_RCON_PASSWORD")) + } + } + + // Four accounts, leased per test and returned afterwards. They outlive the run, + // so from the second run on they log in instead of registering. + accounts { + autoRegister { + usernamePattern.set("pw_%04d") + password.set(secret.env("PLUGWRIGHT_BOT_PASSWORD")) + max.set(4) + } + } + + plugins { + npm("@plugwright/auth-authme") + // Compiled output of src/test/e2e/plugins/stand-reset.ts. + local(file("src/test/e2e/dist/plugins/stand-reset.js")) + } + + // Matched against test names. What is left out here is what the stand cannot give + // back: a balance, a kit or an arena slot that is spent once and stays spent. Op + // and inventory are reset per test by the stand-reset plugin instead. multi-bot is + // out for a different reason β€” it names its second bot, and a named bot is not a + // pool account, so nothing knows its password. + excludeTests.set(listOf( + "balance", "send money", "kit", "arena", "shop", "buy", "first join", "multi-bot" + )) + } + } } group = "me.drownek" diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index 5559adb..a039403 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -5,7 +5,9 @@ "packages": { "": { "dependencies": { - "@drownek/plugwright": "file:../../../../runner-package" + "@drownek/plugwright": "file:../../../../runner-package", + "@plugwright/auth-authme": "file:../../../../auth-authme-package", + "@plugwright/console-rcon": "file:../../../../console-rcon-package" }, "devDependencies": { "@types/node": "^22.10.5", @@ -13,6 +15,40 @@ "typescript": "^5.7.3" } }, + "../../../../auth-authme-package": { + "name": "@plugwright/auth-authme", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@drownek/plugwright": ">=2.0.0" + } + }, + "../../../../console-rcon-package": { + "name": "@plugwright/console-rcon", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@drownek/plugwright": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@drownek/plugwright": ">=2.0.0" + } + }, "../../../../runner-package": { "name": "@drownek/plugwright", "version": "3.0.0-dev.0", @@ -23,6 +59,9 @@ "picocolors": "^1.1.1", "source-map-support": "^0.5.21" }, + "bin": { + "plugwright": "dist/cli.js" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.5", @@ -38,6 +77,14 @@ "resolved": "../../../../runner-package", "link": true }, + "node_modules/@plugwright/auth-authme": { + "resolved": "../../../../auth-authme-package", + "link": true + }, + "node_modules/@plugwright/console-rcon": { + "resolved": "../../../../console-rcon-package", + "link": true + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", diff --git a/example_plugin/src/test/e2e/package.json b/example_plugin/src/test/e2e/package.json index fd63e49..05dd568 100644 --- a/example_plugin/src/test/e2e/package.json +++ b/example_plugin/src/test/e2e/package.json @@ -4,7 +4,9 @@ "build": "rimraf dist && tsc" }, "dependencies": { - "@drownek/plugwright": "file:../../../../runner-package" + "@drownek/plugwright": "file:../../../../runner-package", + "@plugwright/auth-authme": "file:../../../../auth-authme-package", + "@plugwright/console-rcon": "file:../../../../console-rcon-package" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/example_plugin/src/test/e2e/plugins/stand-reset.ts b/example_plugin/src/test/e2e/plugins/stand-reset.ts new file mode 100644 index 0000000..cc0877c --- /dev/null +++ b/example_plugin/src/test/e2e/plugins/stand-reset.ts @@ -0,0 +1,24 @@ +import { definePlugin } from '@drownek/plugwright'; + +/** + * Undoes what one test leaves on a leased account before the next test gets it. + * + * The local environment never needs this: it hands every test a brand new username on a + * server it just created. An external stand has neither β€” the same four accounts come back + * around all run, still opped and still holding whatever the last test gave them. + * + * Loaded through `plugins { local(...) }` in build.gradle.kts, for the "stand" environment + * only. + */ +export default definePlugin({ + name: 'stand-reset', + + async beforeEach({ player, server }) { + // Nothing to reset with: an environment without a console cannot run commands at all, + // and the tests that depend on this reset are excluded there anyway. + if (!server.session.env.capabilities.console) return; + + await server.executeAndWait(`minecraft:deop ${player.username}`); + await server.executeAndWait(`minecraft:clear ${player.username}`); + }, +}); diff --git a/example_plugin/src/test/e2e/simple-ts.spec.ts b/example_plugin/src/test/e2e/simple-ts.spec.ts index aa59fbd..5c02d35 100644 --- a/example_plugin/src/test/e2e/simple-ts.spec.ts +++ b/example_plugin/src/test/e2e/simple-ts.spec.ts @@ -25,7 +25,9 @@ test('help displays message', async ({ player }) => { await expect(player).toHaveReceivedMessage('Help'); }); -test('server logs command execution', async ({ server }) => { +// Reading the server log needs a console that streams all of it. An environment whose +// console only answers its own commands skips this test instead of failing it. +test('server logs command execution', { requires: ['consoleOutput:full'] }, async ({ server }) => { server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); \ No newline at end of file diff --git a/example_plugin/src/test/e2e/tsconfig.json b/example_plugin/src/test/e2e/tsconfig.json index 7c07169..ca67432 100644 --- a/example_plugin/src/test/e2e/tsconfig.json +++ b/example_plugin/src/test/e2e/tsconfig.json @@ -10,5 +10,5 @@ "sourceMap": true, "inlineSources": true }, - "include": ["*.spec.ts"] + "include": ["*.spec.ts", "plugins/*.ts"] } \ No newline at end of file diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PluginsSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt similarity index 71% rename from gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PluginsSpec.kt rename to gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt index 4e9e1f9..f4274dd 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PluginsSpec.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt @@ -1,6 +1,5 @@ -package me.drownek.plugwright.external +package me.drownek.plugwright.api -import me.drownek.plugwright.api.PluginRef import java.io.File /** Per-plugin options and inheritance flag, configured in the trailing lambda of [PluginsSpec.npm] @@ -16,13 +15,17 @@ class PluginRefSpec { /** * `plugins { npm("@plugwright/auth-authme") { ... }; local(file("...")) { ... } }`. * - * Declares runner plugins to load for this environment: fixtures, matchers, authentication - * hooks, inherited tests. See the runner's own plugin contract for what a plugin can do once - * loaded. + * Declares runner plugins to load for an environment: fixtures, matchers, authentication + * hooks, inherited tests. Lives in the API module rather than in one mode, because nothing + * about a plugin is mode-specific β€” a mode only has to pass [entries] to + * [TaskRegistrationContext.pluginConfigs] to support the block. */ class PluginsSpec { internal val entries = mutableListOf() + /** Entries declared so far, for a mode wiring them into its config. */ + fun refs(): List = entries.toList() + /** An npm-published plugin, e.g. `@plugwright/auth-authme`. */ fun npm(specifier: String, action: PluginRefSpec.() -> Unit = {}) { val spec = PluginRefSpec().apply(action) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt index c0de6af..253bd84 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt @@ -1,6 +1,8 @@ package me.drownek.plugwright import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction @@ -18,6 +20,15 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { @get:Optional abstract val testsDir: DirectoryProperty + /** + * Packages the configured environments need at runtime, as npm install arguments + * (`name` or `name@range`), merged across every environment so one install covers the + * whole matrix. Only the missing ones are installed β€” a package the test project already + * depends on (including a local `file:` link during development) is left alone. + */ + @get:Input + abstract val runnerPackages: ListProperty + init { group = "verification" description = "Install npm dependencies and compile the E2E tests" @@ -49,6 +60,8 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { runCommand(userTestsDirectory, nodePaths.npm, "install", env = npmEnv) } + installMissingRunnerPackages(userTestsDirectory, nodePaths, npmEnv) + // Build TypeScript tests if tsconfig.json exists val tsconfigFile = File(userTestsDirectory, "tsconfig.json") if (tsconfigFile.exists()) { @@ -58,4 +71,35 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { logger.lifecycle("No TypeScript config found, running JavaScript tests directly") } } + + private fun installMissingRunnerPackages( + testsDirectory: File, + nodePaths: NodeManager.NodePaths, + npmEnv: Map + ) { + val nodeModules = File(testsDirectory, "node_modules") + val missing = runnerPackages.get().filterNot { spec -> + File(nodeModules, packageNameOf(spec)).exists() + } + if (missing.isEmpty()) return + + logger.lifecycle("Installing runner packages: ${missing.joinToString(", ")}") + try { + // --no-save: these come from the build script's environments, so the test project's + // package.json shouldn't grow a second, drifting copy of the same decision. + runCommand(testsDirectory, nodePaths.npm, "install", "--no-save", *missing.toTypedArray(), env = npmEnv) + } catch (e: Exception) { + // A package that can't be installed is not a reason to stop compiling the tests: + // only the environment that asked for it is affected, and the runner reports the + // missing package with the context to fix it when that environment actually runs. + logger.warn("Could not install runner packages ${missing.joinToString(", ")}: ${e.message}") + } + } + + /** `@scope/name@^1.0.0` β†’ `@scope/name`; the version separator is the last `@`, which for + * a scoped package is never the leading one. */ + private fun packageNameOf(spec: String): String { + val separator = spec.lastIndexOf('@') + return if (separator > 0) spec.substring(0, separator) else spec + } } diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt index 5fd75e3..22c99b9 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -53,6 +53,8 @@ class PlugwrightCorePlugin : Plugin { } testsDir.set(extension.testsDir) + // Filled in once every environment has been wired; empty until then. + runnerPackages.convention(emptyList()) nodeVersion.set(extension.nodeVersion) downloadNode.set(extension.downloadNode) nodeInstallDir.set(defaultNodeInstallDir) @@ -150,6 +152,16 @@ class PlugwrightCorePlugin : Plugin { extension.testsDir.map { it.asFile }, extension, defaultNodeInstallDir ) val journalFilePath = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl").get().asFile + val modePackages = mode.runnerPackages(entry.spec) + + // The package a mode names an export in is the one holding its environment factory. + // Only a third-party mode needs it written into the config; `local` and `external` + // are compiled into the runner, which resolves them by mode id. + val runtimeRef = if (mode.id == "local" || mode.id == "external") { + null + } else { + modePackages.firstOrNull { it.export != null } + } val testTask = ctx.registerWithoutAlias("Test", PlugwrightTestTask::class.java) { doFirst { @@ -167,11 +179,20 @@ class PlugwrightCorePlugin : Plugin { nodeVersion.set(extension.nodeVersion) downloadNode.set(extension.downloadNode) nodeInstallDir.set(defaultNodeInstallDir) + runtimeRef?.let { ref -> + runtimePackage.set(ref.name) + ref.export?.let { runtimeExport.set(it) } + } if (project.hasProperty("testFiles")) testFiles.set(project.property("testFiles") as String) if (project.hasProperty("testNames")) testNames.set(project.property("testNames") as String) } + // Merged across environments so the whole matrix is covered by one install. + modePackages.forEach { ref -> + runnerPackageSpecs += if (ref.version != null) "${ref.name}@${ref.version}" else ref.name + } + val validation = ValidationContextImpl(envName, project.logger) mode.validate(entry.spec, validation) validationProblems += validation.errors.map { "[$envName] $it" } @@ -183,6 +204,13 @@ class PlugwrightCorePlugin : Plugin { val pluginConfigsProvider = ctx.pluginConfigsProvider ?: project.provider { emptyList() } + // A plugin declared by npm name is installed alongside the environment's own + // runner packages; a plugin given as a path is already in the project. + pluginConfigsProvider.get() + .map { it.specifier } + .filter { isNpmPackageName(it) } + .forEach { runnerPackageSpecs += it } + testTask.configure { ctx.prepareTaskRef?.let { dependsOn(it) } environmentConfig.set(environmentConfigProvider) @@ -204,11 +232,15 @@ class PlugwrightCorePlugin : Plugin { environmentConfig = environmentConfigProvider, pluginConfigs = pluginConfigsProvider, journalFile = journalFilePath, + runtimePackage = runtimeRef?.name, + runtimeExport = runtimeRef?.export, ) ctx.prepareTaskRef?.let { matrixPrepareTasks += it } } } + plugwrightCompileTests.configure { runnerPackages.set(runnerPackageSpecs.toList()) } + if (validationProblems.isNotEmpty()) { throw GradleException("plugwright configuration problems:\n" + validationProblems.joinToString("\n") { " $it" }) } @@ -230,6 +262,14 @@ class PlugwrightCorePlugin : Plugin { } } + /** Whether a plugin specifier names an npm package rather than a file in the project. + * Paths are what `plugins { local(file(...)) }` produces; everything else is installable. */ + private fun isNpmPackageName(specifier: String): Boolean { + if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("\\")) return false + if (specifier.length > 1 && specifier[1] == ':') return false + return true + } + /** The jar of the plugin under test, from `shadowJar` / `reobfJar` / `jar`. Absent when * the build asked for external plugins only, or when no jar-producing task exists. */ private fun resolveProjectPluginJar(project: Project, extension: PlugwrightExtension): Provider { diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt index 559c204..895a219 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt @@ -28,6 +28,8 @@ internal data class MatrixEnvironmentInput( val environmentConfig: Provider, val pluginConfigs: Provider>, val journalFile: File?, + val runtimePackage: String? = null, + val runtimeExport: String? = null, ) private data class EnvironmentSummary(val total: Int, val passed: Int, val failed: Int, val skipped: Int, val durationMs: Long) @@ -124,6 +126,8 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { junitReportFile = env.junitReportFile, pluginConfigs = env.pluginConfigs.get(), journalFile = env.journalFile, + runtimePackage = env.runtimePackage, + runtimeExport = env.runtimeExport, ) RunnerLauncher.writeConfig(entry) val cliJs = RunnerLauncher.resolveCliJs(env.testsDir) @@ -159,7 +163,7 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { private fun printSummaryTable(outcomes: List) { val nameWidth = outcomes.maxOf { it.env.name.length } logger.lifecycle("") - logger.lifecycle("Environment sumarries:") + logger.lifecycle("Environment summaries:") for ((env, summary, error) in outcomes) { val label = env.name.padEnd(nameWidth) val flag = if (env.allowFailure) " [allowFailure]" else "" diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt index 20e16cf..1cfb18e 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt @@ -59,6 +59,17 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { @get:Internal abstract val journalFile: RegularFileProperty + /** npm package exporting this environment's factory. Unset for a built-in mode, which the + * runner already carries. */ + @get:Input + @get:Optional + abstract val runtimePackage: Property + + /** Named export holding the factory; unset means the package's default export. */ + @get:Input + @get:Optional + abstract val runtimeExport: Property + /** Where the generated runner config is written before the CLI is invoked. */ @get:OutputFile abstract val configFile: RegularFileProperty @@ -111,6 +122,8 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { junitReportFile = junitReportFile.get().asFile, pluginConfigs = pluginConfigs.get(), journalFile = journalFile.orNull?.asFile, + runtimePackage = runtimePackage.orNull, + runtimeExport = runtimeExport.orNull, ) RunnerLauncher.writeConfig(entry) logger.lifecycle("Runner config: ${configDestination.absolutePath}") diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt index 01ddb59..36349ec 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt @@ -29,6 +29,10 @@ object RunnerLauncher { val jsonReportFile: File? = null, val junitReportFile: File? = null, val pluginConfigs: List = emptyList(), + /** npm package exporting this environment's factory; null for a built-in mode. */ + val runtimePackage: String? = null, + /** Named export holding the factory; null means the package's default export. */ + val runtimeExport: String? = null, /** Crash-recovery journal path for `Session.journal`; null disables on-disk persistence. */ val journalFile: File? = null, ) @@ -39,6 +43,17 @@ object RunnerLauncher { obj("environment") { put("name", entry.environmentName) put("mode", entry.modeId) + // Where the runner loads the environment implementation from. The built-in + // modes are compiled into the runner and ignore it; a third-party mode is + // only reachable through this reference. + if (entry.runtimePackage != null) { + obj("runtime") { + put("package", entry.runtimePackage) + putIfPresent("export", entry.runtimeExport) + } + } else { + putNull("runtime") + } put("config", entry.environmentConfig) } obj("tests") { diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt index 7e12975..8c1b451 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt @@ -1,6 +1,7 @@ package me.drownek.plugwright.external import me.drownek.plugwright.api.EnvironmentSpec +import me.drownek.plugwright.api.PluginsSpec import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt index 17582d5..cbf5573 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt @@ -23,7 +23,7 @@ object ExternalMode : PlugwrightMode { add(RunnerPackageRef("@drownek/plugwright", export = "externalEnvironment")) val needsRcon = spec.consoleSpec?.channels?.any { it is ConsoleChannelSpec.Rcon } == true if (needsRcon) { - add(RunnerPackageRef("@plugwright/console-rcon", "^1.0.0", export = "rconConsole")) + add(RunnerPackageRef("@plugwright/console-rcon", export = "rconConsole")) } } @@ -117,7 +117,7 @@ object ExternalMode : PlugwrightMode { val project = ctx.project val envName = spec.name - ctx.pluginConfigs(project.provider { spec.pluginsSpec.entries.toList() }) + ctx.pluginConfigs(project.provider { spec.pluginsSpec.refs() }) val configProvider = project.provider { ConfigNodeBuilder().also { serialize(spec, it) }.build() } val journalFile = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl") diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt index c011243..e9fa421 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt @@ -1,6 +1,7 @@ package me.drownek.plugwright.local import me.drownek.plugwright.api.EnvironmentSpec +import me.drownek.plugwright.api.PluginsSpec import me.drownek.plugwright.api.RunDirFile import org.gradle.api.file.DirectoryProperty import org.gradle.api.model.ObjectFactory @@ -47,6 +48,17 @@ class LocalEnvironmentSpec(private val environmentName: String, objects: ObjectF /** When true, the plugin under test is not built or installed automatically. */ val useExternalPluginsOnly: Property = objects.property(Boolean::class.java).convention(false) + internal val pluginsSpec: PluginsSpec = PluginsSpec() + + /** + * `plugins { npm("@plugwright/auth-authme"); local(file("...")) }` β€” runner plugins loaded + * for this environment. A locally spawned server still needs them whenever it runs a + * plugin that changes what a connecting bot has to do, authentication being the usual case. + */ + fun plugins(action: PluginsSpec.() -> Unit) { + pluginsSpec.action() + } + /** * DSL method for configuring plugin downloads. * ``` diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt index 4d9f401..861436b 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt @@ -91,6 +91,8 @@ object LocalMode : PlugwrightMode { ctx.prepareTask(provision) + ctx.pluginConfigs(project.provider { spec.pluginsSpec.refs() }) + ctx.environmentConfig(project.provider { buildConfigNode(spec, resolveJavaPath(javaLauncherProvider)) }) diff --git a/runner-package/README.md b/runner-package/README.md index a819a63..540433b 100644 --- a/runner-package/README.md +++ b/runner-package/README.md @@ -33,9 +33,19 @@ test('player can interact with GUI', async ({ player }) => { }); ``` +## Running against something other than a local server + +The runner takes a config file describing one environment: + +```bash +npx plugwright --config build/tmp/plugwright/local.json +``` + +The Gradle plugin writes that file, but nothing stops you from writing it yourself. `local` starts and stops its own Paper server; `external` connects to one that is already running, with an account pool, a console channel and authentication handled by a plugin. Two service modes exist for the second case: `--ping` checks that the server answers without running tests, and `--cleanup` replays outstanding cleanup work. + ## Documentation -Full documentation is available in the [GitHub repository Wiki](https://github.com/Drownek/plugwright/wiki). +Full documentation is at [plugwright.dev](https://plugwright.dev). Start with [Environments](https://plugwright.dev/environments) for multi-server setups, and [Runner Plugins](https://plugwright.dev/plugins) for hooks, fixtures and custom matchers. ## License diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts index e1705af..8a24fbc 100644 --- a/runner-package/lib/environments/external.ts +++ b/runner-package/lib/environments/external.ts @@ -7,7 +7,7 @@ import { resolveSecret } from '../config.js'; import { AccountPool } from '../account.js'; import type { AccountsConfig } from '../account.js'; import { AdminBotConsole } from '../admin-bot-console.js'; -import { sleep } from '../utils.js'; +import { sleep, importOptionalPackage } from '../utils.js'; export interface ExternalConsoleChannelConfig { kind: 'rcon' | 'adminBot'; @@ -86,6 +86,9 @@ class ExternalEnvironment implements Environment { ...BASE_CAPABILITIES, console: this._console !== null, consoleOutput: this._console?.output ?? 'none', + // A reachable console is the ability to run `op`, which is what this capability + // claims. Without one there is no way to grant it, hence the false in the base. + op: this._console !== null, }; console.log(this._console @@ -106,12 +109,13 @@ class ExternalEnvironment implements Environment { const rconPackage = '@plugwright/console-rcon'; let mod: any; try { - mod = await import(rconPackage); - } catch { + mod = await importOptionalPackage(rconPackage); + } catch (error) { console.error(pc.red( 'Mode "external": console { rcon { } } needs the "@plugwright/console-rcon" package.\n' + 'It installs automatically as part of plugwrightCompileTests β€” check that npm install\n' + - 'completed in your tests directory and that the package appears under node_modules.' + 'completed in your tests directory and that the package appears under node_modules.\n' + + `(${(error as Error).message})` )); return null; } diff --git a/runner-package/lib/matchers.ts b/runner-package/lib/matchers.ts index 8be4276..6bab347 100644 --- a/runner-package/lib/matchers.ts +++ b/runner-package/lib/matchers.ts @@ -72,12 +72,25 @@ export class RunnerMatchers extends Matchers { return strict ? msg === expectedMessage : msg.includes(expectedMessage); }; + const session = (this.actual as PlayerWrapper | ServerWrapper).session; + + // Reading the server log needs a console that streams everything. A console that only + // answers the commands it is given (RCON) leaves the buffer empty, and the assertion + // would fail after a full timeout with nothing explaining why. + if (!(this.actual instanceof PlayerWrapper) && session.env.capabilities.consoleOutput !== 'full') { + throw new Error( + `Cannot read the server log on environment "${session.env.id}": its console output level is ` + + `"${session.env.capabilities.consoleOutput}". Mark the test with requires: ['consoleOutput:full'] ` + + 'to have it skipped there instead.' + ); + } + // A player's messages are its own (see `PlayerWrapper.messageBuffer`) so one bot's chat // never satisfies an assertion made against another; the server log has no such split, // it's one console shared by the whole session. const buffer = this.actual instanceof PlayerWrapper ? this.actual.messageBuffer - : (this.actual as ServerWrapper).session.consoleLog; + : session.consoleLog; const view = (): string[] => buffer.slice(since); await this.pollAssertion( diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 859bca3..2b9bdfb 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -114,14 +114,42 @@ export class PlayerWrapper { this._captureSpawnPromise(timeout); } - await this._spawnPromise; - this._spawnPromise = null; - + // Listeners go up before the first await: a login wall greets the bot as soon as it + // enters the play state, and a prompt that arrives before the message buffer exists + // is a prompt no authentication plugin can answer. this._registerPersistentListeners(); if (this.account) { + // Authentication has to happen while the server still holds the player: AuthMe and + // friends keep an unauthenticated bot out of the world entirely, so waiting for the + // spawn first would wait for something login is the precondition of. + await Promise.race([this._spawnPromise, this._waitForLogin(timeout)]); await this.session.onPlayerCreate?.(this, { account: this.account, env: this.session.env }); } + + await this._spawnPromise; + this._spawnPromise = null; + } + + /** Resolves once the client is in the play state, where chat works and the server's login + * prompt has been delivered. Never rejects on its own β€” it is raced against the spawn + * promise, which already fails on a kick, an error or a timeout. */ + private _waitForLogin(timeout: number): Promise { + if (this.bot.entity) return Promise.resolve(); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.bot.removeListener('login', onLogin); + resolve(); + }, timeout); + + const onLogin = (): void => { + clearTimeout(timer); + resolve(); + }; + + this.bot.once('login', onLogin); + }); } /** @internal */ @@ -169,8 +197,26 @@ export class PlayerWrapper { return currentWindow ? new GuiWrapper(this.bot, currentWindow as Window) : null; } - chat(message: string): void { - console.log(`${pc.cyan('[Bot]')} ${pc.dim(`Chatting: ${message}`)}`); + /** + * Sends a chat message as this bot. + * + * `options.secrets` lists values that must not appear in the line this call logs β€” a + * password, a token, anything the caller already holds and knows is sensitive. Each + * occurrence of a listed value is replaced in the *logged* copy of `message`; what goes + * to the server is untouched. + * + * The list is the caller's to supply, and an empty one redacts nothing. Guessing which + * argument of an arbitrary command is a password would mean this method knowing every + * plugin's command shapes, and a guess that misses fails open β€” it prints the secret. The + * caller is the only one who knows, so the caller says so. + */ + chat(message: string, options: { secrets?: string[] } = {}): void { + const { secrets = [] } = options; + const logged = secrets.reduce( + (text, secret) => (secret ? text.split(secret).join('[REDACTED]') : text), + message, + ); + console.log(`${pc.cyan('[Bot]')} ${pc.dim(`Chatting: ${logged}`)}`); this.bot.chat(message); } @@ -205,7 +251,19 @@ export class PlayerWrapper { async makeOp(): Promise { this.requireServer(); - this.serverWrapper!.execute(`minecraft:op ${this.username}`); + const command = `minecraft:op ${this.username}`; + + // A console that answers (RCON) says whether the command worked; the confirmation is + // never broadcast to the player, so there is nothing to wait for in the chat buffer. + if (this.session.console?.output === 'responses') { + const response = await this.serverWrapper!.executeAndWait(command); + // "Made X a server operator" on success, "Nothing changed. The player already is + // an operator" when it was already granted β€” both mean the player is op now. + if (/operator/i.test(response)) return; + throw new Error(`Player ${this.username} was not opped: ${response.trim() || 'no response from the console'}`); + } + + this.serverWrapper!.execute(command); await poll( () => this.messageBuffer.find(m => m.includes(`Made ${this.username} a server operator`)), @@ -308,6 +366,15 @@ export class PlayerWrapper { private async executeAndSync(cmd: string): Promise { this.requireServer(); + + // A console that answers has already finished the command by the time it replies. The + // marker below exists for the stdio console, where output and command completion are + // two unrelated streams. + if (this.session.console?.output === 'responses') { + await this.serverWrapper!.executeAndWait(cmd); + return; + } + const syncId = `sync_${randomUUID().split('-')[0]}`; this.serverWrapper!.execute(cmd); this.serverWrapper!.execute(`minecraft:say ${syncId}`); diff --git a/runner-package/lib/plugin-host.ts b/runner-package/lib/plugin-host.ts index 7499131..177a18f 100644 --- a/runner-package/lib/plugin-host.ts +++ b/runner-package/lib/plugin-host.ts @@ -1,6 +1,7 @@ import pc from 'picocolors'; import { RunnerMatchers } from './matchers.js'; import { PLUGIN_API_VERSION } from './plugin.js'; +import { importOptionalPackage } from './utils.js'; import type { PlugwrightPlugin, PluginTestRef } from './plugin.js'; import type { Session } from './session.js'; import type { PlayerWrapper } from './player.js'; @@ -27,7 +28,7 @@ export class PluginHost { for (const cfg of configs) { let mod: any; try { - mod = await import(cfg.specifier); + mod = await importOptionalPackage(cfg.specifier); } catch (error) { throw new Error(`Failed to load plugin "${cfg.specifier}": ${(error as Error).message}`); } diff --git a/runner-package/lib/server.ts b/runner-package/lib/server.ts index 1954f4a..876423a 100644 --- a/runner-package/lib/server.ts +++ b/runner-package/lib/server.ts @@ -13,4 +13,13 @@ export class ServerWrapper { } this.session.console.execute(cmd); } + + /** Runs a command and resolves with whatever the console gives back. A console with + * `output: 'none'` has nothing to give back and resolves empty. */ + executeAndWait(cmd: string, timeoutMs?: number): Promise { + if (!this.session.console) { + throw new Error('No server console available for this environment'); + } + return this.session.console.executeAndWait(cmd, timeoutMs); + } } diff --git a/runner-package/lib/utils.ts b/runner-package/lib/utils.ts index 8aa3bac..1d0a4c5 100644 --- a/runner-package/lib/utils.ts +++ b/runner-package/lib/utils.ts @@ -1,3 +1,8 @@ + +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + export const sleep = (ms: number, signal?: AbortSignal) => { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(new Error('Aborted')); @@ -144,4 +149,24 @@ export async function waitForStable( if (Date.now() >= stableDeadline) break; await sleep(Math.min(interval, Math.max(0, stableDeadline - Date.now())), signal); } -} \ No newline at end of file +} + +/** + * Imports a package that isn't a dependency of this one β€” an optional console package, a + * third-party mode, a plugin. A plain `import()` resolves from this file, which finds + * nothing when the runner itself is a linked checkout rather than an entry under the test + * project's `node_modules`; the fallback resolves from the test project instead, which is + * where the Gradle plugin installs these packages and is the runner's working directory. + */ +export async function importOptionalPackage(name: string): Promise { + try { + return await import(name); + } catch (error) { + const fromTestProject = createRequire(pathToFileURL(join(process.cwd(), 'package.json'))); + try { + return await import(pathToFileURL(fromTestProject.resolve(name)).href); + } catch { + throw error; + } + } +} diff --git a/runner-package/package.json b/runner-package/package.json index d5a1305..7032c9a 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -5,6 +5,9 @@ "type": "module", "main": "dist/runner.js", "types": "dist/runner.d.ts", + "bin": { + "plugwright": "dist/cli.js" + }, "scripts": { "build": "rimraf dist && tsc", "prepare": "npm run build", diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 887e0dd..458b4d0 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -13,6 +13,7 @@ import { externalEnvironment } from './lib/environments/external.js'; import { PlayerWrapper } from './lib/player.js'; import { printTestSummary, writeJsonReport, writeJUnitReport } from './lib/reporter.js'; import { loadRunnerConfig } from './lib/config.js'; +import { importOptionalPackage } from './lib/utils.js'; import type { Environment } from './lib/environment.js'; import type { EnvironmentConfig, LocalEnvironmentConfig, RunnerConfig } from './lib/config.js'; import type { ExternalEnvironmentConfig } from './lib/environments/external.js'; @@ -61,7 +62,7 @@ async function resolveEnvironment(cfg: EnvironmentConfig): Promise if (cfg.runtime) { let mod: any; try { - mod = await import(cfg.runtime.package); + mod = await importOptionalPackage(cfg.runtime.package); } catch (error) { throw new Error( `Environment "${cfg.name}" needs package "${cfg.runtime.package}", which failed to load: ` + @@ -79,10 +80,18 @@ async function resolveEnvironment(cfg: EnvironmentConfig): Promise } /** Capability keys from `testCase.requires` that `env` does not actually satisfy. A - * value of `false`, `'none'`, or an absent key all count as unmet. */ + * value of `false`, `'none'`, or an absent key all count as unmet. + * + * `'key:value'` demands one specific value instead β€” `'consoleOutput:full'` for a test that + * reads the server log, which a console answering only its own commands cannot provide even + * though it satisfies plain `'console'`. */ function missingCapabilities(env: Environment, required: string[]): string[] { const capabilities = env.capabilities as unknown as Record; return required.filter(key => { + const separator = key.indexOf(':'); + if (separator !== -1) { + return String(capabilities[key.slice(0, separator)]) !== key.slice(separator + 1); + } const value = capabilities[key]; return value === false || value === 'none' || value === undefined; });