diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c684647 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Settings for publishing to a registry other than the public ones. Copy to `.env` and fill +# in what applies; `.env` is ignored by git so the passwords stay out of the repository. +# +# None of this is needed for a public release. `npm run publish:packages` with nothing set +# publishes to npmjs.com, and `./gradlew publishToPublicRepository` publishes the plugin to +# the Gradle Plugin Portal using the credentials that plugin already looks for +# (GRADLE_PUBLISH_KEY and GRADLE_PUBLISH_SECRET). + +# --- npm packages ------------------------------------------------------------------------- +# Where `npm run publish:packages` sends @plugwright/runner and the plugin packages. +# Leave the registry unset to publish to npmjs.com instead. +PLUGWRIGHT_NPM_REGISTRY=https://registry.example.com/repository/npm-hosted/ +PLUGWRIGHT_NPM_USER= +PLUGWRIGHT_NPM_PASSWORD= + +# Optional. The dist-tag to publish under, and the npm access level. +# PLUGWRIGHT_NPM_TAG=latest +# PLUGWRIGHT_NPM_ACCESS=public + +# --- gradle plugin ------------------------------------------------------------------------ +# Where `./gradlew publishToPrivateRepository` sends the plugin. The same three values can be +# given as gradle properties instead: plugwright.publish.url, .user and .password. +# Leave the user and password empty for a repository that accepts anonymous deploys. +# +# Naming a URL here is what turns private publishing on. Without one the task succeeds and +# publishes nothing, so leaving this whole section blank is a valid setup. +PLUGWRIGHT_PUBLISH_URL=https://repo.example.com/repository/maven-releases/ +PLUGWRIGHT_PUBLISH_USER= +PLUGWRIGHT_PUBLISH_PASSWORD= + +# Optional. Force either destination off, whatever the rest of this file says. +# PLUGWRIGHT_PUBLISH_PUBLIC_ENABLED=false +# PLUGWRIGHT_PUBLISH_PRIVATE_ENABLED=false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6661c3b..e2e2ff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,21 +13,116 @@ on: - 'docs/**' - 'docs.json' - '**/*.md' + workflow_dispatch: + inputs: + mc_version: + description: 'Minecraft version to test' + required: true + default: '1.21.11' + type: choice + options: + - '1.21.11' + - '26.1.2' + java_version: + description: 'Java version' + required: true + default: '21' + type: choice + options: + - '21' + - '26' + run_stand: + description: 'Run external stand server tests' + required: true + default: true + type: boolean jobs: test-example-plugin: if: "!contains(github.event.head_commit.message, 'skip-ci')" runs-on: ubuntu-latest + env: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" + MC_VERSION: ${{ inputs.mc_version || '1.21.11' }} + JAVA_VERSION: ${{ inputs.java_version || '21' }} steps: - uses: actions/checkout@v4 - - name: Build runner-package + # The example links all three by path, and npm cannot build a linked package: it packs + # the directory into a staging copy that never gets its dev dependencies. + - name: Build the local npm packages run: | - cd runner-package - npm install - npm run build + npm run install:packages + npm run build:packages - uses: drownek/plugwright-action@v1 with: - java-version: "21" + java-version: ${{ inputs.java_version || '21' }} node-version: "24" working-directory: "./example_plugin" + + test-example-plugin-stand: + if: "!contains(github.event.head_commit.message, 'skip-ci') && (inputs.run_stand == null || inputs.run_stand == true)" + runs-on: ubuntu-latest + env: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" + MC_VERSION: ${{ inputs.mc_version || '1.21.11' }} + JAVA_VERSION: ${{ inputs.java_version || '21' }} + # Test-only credentials for the Paper server this job starts and tears down itself. + PLUGWRIGHT_RCON_PASSWORD: plugwright + PLUGWRIGHT_BOT_PASSWORD: plugwright + + steps: + - uses: actions/checkout@v4 + # Same duplicated build as test-example-plugin: this job runs on its own runner, in + # parallel with it, so it can't share that job's npm install/build output. + - name: Build the local npm packages + run: | + npm run install:packages + npm run build:packages + # Running in parallel with test-example-plugin means this job can't reuse its + # generated/local/run/ either - provision the same Paper server here instead of just + # running plugwrightTest, which would also run (and duplicate) the local suite. + # plugwrightCompileTests is the other half: plugwrightPingStand/plugwrightTestStand + # don't depend on it themselves (ExternalMode assumes the stand is already up, nothing + # to provision - see registerTasks in ExternalMode.kt), they just expect node_modules + # to already have what npm(...) plugin refs and console { rcon {} } need. + - uses: drownek/plugwright-action@v1 + with: + java-version: ${{ inputs.java_version || '21' }} + node-version: "24" + working-directory: "./example_plugin" + gradle-args: plugwrightProvisionLocal plugwrightCompileTests + # generated/ is gitignored, so start.sh - the launcher example_plugin/README.md tells + # developers to hand-write - never made it into the provisioned run dir. Copy in the + # tracked copy. + - name: Start the stand Paper server + working-directory: ./example_plugin/src/test/e2e/generated/local/run + run: | + cp "$GITHUB_WORKSPACE/example_plugin/src/test/e2e/stand-run/start.sh" . + chmod +x start.sh + nohup ./start.sh > stand-server.log 2>&1 & + echo $! > stand-server.pid + - name: Wait for the stand server + working-directory: ./example_plugin/src/test/e2e/generated/local/run + timeout-minutes: 2 + run: tail -n +1 -f stand-server.log | grep -q -m 1 'Done' + - name: Verify authentication (Ping) + working-directory: ./example_plugin + run: ./gradlew plugwrightPingStand + - name: Run the stand suite + working-directory: ./example_plugin + run: ./gradlew plugwrightTestStand + - name: Stop the stand Paper server + if: always() + working-directory: ./example_plugin/src/test/e2e/generated/local/run + run: | + [ -f stand-server.pid ] && kill "$(cat stand-server.pid)" 2>/dev/null || true + - name: Upload stand server log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: stand-server-log + path: | + example_plugin/src/test/e2e/generated/local/run/stand-server.log + example_plugin/build/reports/plugwright/stand.* + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce50921..49d937e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # for creating GitHub releases - id-token: write # required for OIDC trusted publishing + id-token: write # signs the provenance attestation steps: - name: Checkout repository uses: actions/checkout@v4 @@ -31,11 +31,19 @@ jobs: - name: Upgrade npm run: npm install -g npm@11.5.1 - - name: Publish NPM package - working-directory: runner-package + # The plugin packages link the runner through `file:../runner-package`, so they are + # installed together rather than one directory at a time. Each package builds itself + # from `prepublishOnly`, and the script publishes all three. + - name: Publish NPM packages + env: + # A trusted publisher is configured per package, and no name under @plugwright + # exists yet, so there is nothing for OIDC to authenticate against on the first + # release. A granular token scoped to the org carries it; once all three names + # exist, `npm trust` takes over and this block comes out. + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - npm ci - npm publish --access public --provenance + npm run install:packages + npm run publish:packages -- --provenance - name: Publish Gradle plugin working-directory: gradle-plugin @@ -44,5 +52,5 @@ jobs: GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} run: | chmod +x gradlew - ./gradlew publishPlugins + ./gradlew publish \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9df230c..adc4348 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,7 @@ gradle-app.setting !gradle-wrapper.jar *.class gradle.properties -/gradle-plugin/bin/ -/example_plugin/bin/ +**/bin/ # IDE .idea/ @@ -32,6 +31,9 @@ gradle.properties Thumbs.db desktop.ini +# Code Graph index +.codegraph/ + # Logs *.log logs/ @@ -40,7 +42,8 @@ logs/ *.tsbuildinfo dist/ -# Test server runtime +# Test server runtime — plugwright writes it under /generated/ +generated/ run/ test-server/ **/server.properties @@ -62,9 +65,6 @@ test-server/ **/spigot.jar server.jar -# Compiled test files -**/e2e/dist/ - # Temporary files *.tmp *.temp @@ -81,3 +81,9 @@ release.properties dependency-reduced-pom.xml .serena/ + +# Publishing credentials for a private registry. See .env.example. +.env + +# Local, per-developer notes kept beside the checked-in docs. +*.local.md diff --git a/README.md b/README.md index 3f326fd..817e9e2 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,10 @@ End-to-end testing framework for Paper/Spigot Minecraft plugins. Supports JavaSc ![Video showcase demonstrating Plugwright bots joining a server, moving, and interacting with GUIs](https://github.com/user-attachments/assets/0272a6d9-f9ab-4486-8bf3-ee5909a10ee9)
-⚠️ Upgrading from Paperwright (v1.x)? Click here for migration steps. +⚠️ Upgrading from Plugwright 2.x? The npm package moved.
-This framework has been renamed from Paperwright to Plugwright. If you are upgrading from an older version, update the following: - -1. Change `id("io.github.drownek.paperwright")` to `id("io.github.drownek.plugwright")`. -2. Rename your `paperwright { ... }` configuration block to `plugwright { ... }` and Gradle tasks (e.g. `./gradlew paperwrightTest` to `./gradlew plugwrightTest`). -3. In your `package.json`, change `@drownek/paperwright` to `@drownek/plugwright` and run `npm install`. -4. Update your test files: `import { test } from '@drownek/paperwright'` to `import { test } from '@drownek/plugwright'`. -5. Change your CI to use `drownek/plugwright-action@v1`. +The runner is published as @plugwright/runner from 3.0 onwards; @drownek/plugwright stops receiving releases at 2.x. Change the dependency in your package.json, run npm install, and update the import in your test files. Nothing else moves: the Gradle plugin id stays io.github.drownek.plugwright. +See the full v2 to v3 Migration Guide for layout changes, configuration updates, and new features.
## Features @@ -50,22 +45,30 @@ Before you begin, you need: **1. Add the plugin to your `build.gradle.kts`:** ```kotlin +import me.drownek.plugwright.local.LocalMode + plugins { - id("io.github.drownek.plugwright") version "2.0.4" + id("io.github.drownek.plugwright") version "3.0.0" } plugwright { - minecraftVersion.set("1.19.4") - testsDir.set(file("src/test/e2e")) - acceptEula.set(true) - - // Download some dependencies your plugin might need - downloadPlugins { - url("https://url.to/plugin1.jar") - url("https://url.to/plugin2.jar") - // ... etc + environments { + // Paper downloaded, patched, started and killed by plugwright itself. + create("local", LocalMode) { + minecraftVersion.set("1.19.4") + acceptEula.set(true) + + // Download some dependencies your plugin might need + downloadPlugins { + url("https://url.to/plugin1.jar") + url("https://url.to/plugin2.jar") + // ... etc + } + } } + testsDir.set(file("src/test/e2e")) + // If true, always downloads and uses an isolated Node.js version, ignoring the system Node. downloadNode.set(true) } @@ -75,14 +78,22 @@ plugwright { **2. Initialize the test folder:** -Run the init command to set up your test folder. -This will automatically generate your package.json, TypeScript configuration, and an example test in a chosen directory. +Run the init command to set up your test folder. It asks where to put it, then writes an npm project with a `package.json`, a TypeScript config, a `.gitignore`, an example spec and an example runner plugin: -This command is interactive, so simply follow the prompts on your screen: ```bash ./gradlew plugwrightInit ``` +``` +src/test/e2e/ + tests/example.spec.ts your specs go here + plugins/example-plugin.ts hooks, fixtures and matchers + package.json, tsconfig.json + .gitignore node_modules, dist, generated +``` + +Compiled specs land in `dist`, and everything an environment writes — the Paper server the local one starts, for instance — in `generated`. Neither belongs in version control. See [Project Layout](https://plugwright.dev/project-layout). + **3. Run your tests:** ```bash @@ -93,6 +104,50 @@ 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. + +- [Project layout](https://plugwright.dev/project-layout) — where specs, plugins and generated files live +- [Environments](https://plugwright.dev/environments) — modes, tasks, the matrix +- [External servers](https://plugwright.dev/external-servers) — console channels, account pools, cleanup +- [Runner plugins](https://plugwright.dev/plugins) — hooks, fixtures, matchers, inherited tests +- [Writing a mode](https://plugwright.dev/custom-modes) + ## Why Plugwright vs MockBukkit? | | **Plugwright** | **MockBukkit** | @@ -135,6 +190,18 @@ jobs: working-directory: "." ``` +## Used in Production + + + HolyWorld Logo + + +HolyWorld
+~10,000 peak online players. Plugwright powers their CI/CD pipeline for end-to-end plugin testing.
+Integrated by @monikon22 + +
+ ## Documentation & Examples For full examples on how to test **GUIs**, **multi-bot interactions**, **NMS**, and the complete **API Reference**, visit our official documentation site: diff --git a/auth-authme-package/README.md b/auth-authme-package/README.md new file mode 100644 index 0000000..90bde85 --- /dev/null +++ b/auth-authme-package/README.md @@ -0,0 +1,83 @@ +# @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()`, and every `player.rejoin()` — it waits for the server's prompt and answers it. Registration is followed through to the login it triggers, because a command sent between the two is still rejected as unauthenticated. + +Microsoft (online-mode) accounts go through the same handshake by default — whether AuthMe still puts up a login wall for a premium account is a server-side setting, not something this plugin assumes. Set `skipOnMicrosoftAccount` if you've confirmed yours doesn't. + +## 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 whichever of a register prompt, a login prompt, or a session-resume message arrives, and acts on that. The register pattern is tested first, since AuthMe's register prompt mentions the password too and would otherwise look like a login prompt. + +A reconnect within AuthMe's own session timeout gets no prompt at all — AuthMe already considers the account logged in and says so via `sessionResumedPattern` instead. The plugin stops there without sending a command. Nothing matching within `timeoutMs` is treated as a genuine failure, not a stale session. + +## Options + +| 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 | +| `sessionResumedPattern` | `Session Reconnection` | Regex confirming AuthMe resumed the session on its own, no prompt needed | +| `timeoutMs` | `15000` | How long to wait for each prompt or confirmation | +| `password` | — | Fallback password for accounts that carry none | +| `skipOnMicrosoftAccount` | `false` | Skip the handshake entirely for `microsoft` accounts | + +All patterns are matched case-insensitively, and only against messages that arrived after the step they belong to. A greeting containing the word "welcome" would otherwise pass for a login confirmation, and the test would start before the player could run a single command. + +`password` covers accounts an environment invents rather than leases: `LocalMode` hands every test a throwaway `Test_` with no password of its own. Plugin options travel as plain strings, so use it only where the password protects nothing — a local server that is deleted after the run. Anywhere else, put the accounts in `accounts { }`, where the password stays a secret reference until the runner reads it. + +`microsoft` accounts (`accounts { microsoft { ... } }`) carry no password of their own either — mineflayer authenticates them itself — so if your server still prompts them, they need `password` too. Set `skipOnMicrosoftAccount = true` only once you've confirmed your server lets premium accounts straight through without one. + +## Preflight test + +A `preflight` test ships with the plugin and runs before any user spec. The handshake above already throws on the first connection if it fails, so the test mostly exists to put a named failure at the top of the report instead of a stack trace buried in someone else's test. + +## 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..9745250 --- /dev/null +++ b/auth-authme-package/auth.spec.ts @@ -0,0 +1,10 @@ +import { test } from '@plugwright/runner'; + +// If the login/register handshake in `onPlayerCreate` failed or timed out, `createPlayer()` +// would already have thrown before this test body ever runs — so reaching here at all is +// 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..cdde38f --- /dev/null +++ b/auth-authme-package/index.ts @@ -0,0 +1,186 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { definePlugin, poll } from '@plugwright/runner'; + +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; + /** Regex matched against server messages confirming AuthMe resumed an existing session on + * its own — no login/register needed. AuthMe sends this instead of a prompt when it + * considers the connecting player already authenticated (a reconnect within its session + * timeout). */ + sessionResumedPattern?: string; + /** Password used for accounts that carry none of their own — the throwaway identities an + * environment without an account pool generates per bot. Plugin options travel as plain + * values, so only use this where the password is worth nothing: a local, disposable + * server. Anywhere else, put the accounts in the pool and let the password be a secret. */ + password?: string; + /** Skip the login/register handshake entirely for `microsoft` (online-mode) accounts. + * Off by default: whether AuthMe still puts up its login wall for a premium account is a + * server-side setting (e.g. AuthMe's premium auto-login), not something this plugin can + * assume — see issue #65, where a `microsoft` account was prompted to `/register` like + * any other. Only set this once you've confirmed your server really does let Microsoft + * accounts straight through. Plugin options travel as strings from the Kotlin DSL, so set + * it as `options["skipOnMicrosoftAccount"] = "true"`. */ + skipOnMicrosoftAccount?: boolean; +} + +const DEFAULTS: Required> = { + 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, + sessionResumedPattern: 'Session Reconnection', + skipOnMicrosoftAccount: false, +}; + +// `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; + +// Kotlin's `options[k] = v` map is string-only (see PluginRefSpec), so a boolean option set +// through the Gradle DSL arrives here as the literal string "true"/"false", not a real +// boolean — a plain truthy check would treat "false" as on. Anything already boolean (options +// set from a JS/TS environment config directly) passes through unchanged. +const isEnabled = (value: boolean | string): boolean => value === true || value === 'true'; + +/** + * Reference authentication plugin for a server running AuthMe (or anything with the same + * login/register-by-chat flow). The credentials go straight into the runner's plugin configuration, and the runner manages + * the authentication flow. It happens exactly once per bot — on the very first + * join and every `player.rejoin()` — before test code ever gets a chance to see + * the player. + */ +export default definePlugin({ + name: 'authme', + apiVersion: 1, + tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight' }], + + setup({ options }) { + resolved = { ...DEFAULTS, ...options }; + }, + + async onPlayerCreate(player, { account }) { + // Opt-in only: whether AuthMe skips its login wall for a premium account depends on + // server config, not on the account being `microsoft` (issue #65). + if (account.auth === 'microsoft' && isEnabled(resolved.skipOnMicrosoftAccount)) return; + + const password = account.password ?? resolved.password; + if (!password) { + throw new Error( + account.auth === 'microsoft' + ? `authme: microsoft account "${account.username}" has no password to log in with. ` + + 'Microsoft accounts never carry one (mineflayer authenticates them itself), so set ' + + 'the plugin\'s "password" option, or set "skipOnMicrosoftAccount" if your server ' + + 'really doesn\'t put up a login wall for premium accounts.' + : `authme: account "${account.username}" has no password to log in with. ` + + 'Give the environment an accounts pool, or set the plugin\'s "password" option ' + + 'for a throwaway local server.' + ); + } + + const registerPrompt = new RegExp(resolved.registerPromptPattern, 'i'); + const loginPrompt = new RegExp(resolved.loginPromptPattern, 'i'); + const sessionResumed = new RegExp(resolved.sessionResumedPattern, 'i'); + const successPattern = new RegExp(resolved.successPattern, 'i'); + + // Which of the two the server asks for is the server's decision, not ours: + // `account.justCreated` is a hint from the account pool, and it is wrong whenever a + // pool account outlives the run that created it. So wait for whichever of the three + // arrives and act on that. Register is tested first because AuthMe's register prompt + // names the password too, and would otherwise match the login pattern. + // + // Hardcoded to 0 rather than `player.getMessageBufferIndex()`: the login prompt can + // arrive during the handshake, before this handler even runs, so reading the buffer + // index here can already be past it. Scanning from 0 risks matching a stale prompt from + // a previous connection, but this buffer is fresh per player and the loss of precision + // is worth never missing the real prompt. + const joinIndex = 0; + const since = (index: number, pattern: RegExp): string | undefined => + player.messageBuffer.slice(index).find((m: string) => pattern.test(m)); + + // The server occasionally reconnects a player without prompting at all — AuthMe still + // considers the account logged in from a connection that never fully closed, and says + // so with `sessionResumedPattern` instead of a prompt. Trust that message and stop here: + // no command to send, nothing left to confirm. Anything else within `timeoutMs` is a + // genuine miss, not a stale session, and throws. + const promptResult = await poll<'register' | 'login' | 'resumed'>( + () => { + if (since(joinIndex, registerPrompt)) return 'register'; + if (since(joinIndex, loginPrompt)) return 'login'; + if (since(joinIndex, sessionResumed)) return 'resumed'; + return undefined; + }, + { + timeout: resolved.timeoutMs, + message: `authme: never saw a login/register prompt or session-resume message for "${account.username}"`, + }, + ); + if (promptResult === 'resumed') { + const authenticated = new RegExp(resolved.authenticatedPattern, 'i'); + await poll(() => since(joinIndex, authenticated), { + timeout: resolved.timeoutMs, + message: `authme: "${account.username}" session resumed, but never confirmed as authenticated`, + }); + return; + } + const isRegistration = promptResult === 'register'; + + // Everything below only looks at messages newer than the command. A server's greeting + // 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..ba5f25f --- /dev/null +++ b/auth-authme-package/package-lock.json @@ -0,0 +1,207 @@ +{ + "name": "@plugwright/auth-authme", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@plugwright/auth-authme", + "version": "3.0.0", + "license": "MIT", + "devDependencies": { + "@plugwright/runner": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@plugwright/runner": ">=3.0.0-dev.0" + } + }, + "../runner-package": { + "name": "@plugwright/runner", + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "mineflayer": "^4.39.0", + "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", + "source-map-support": "^0.5.21" + }, + "bin": { + "plugwright": "dist/cli.js" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.5", + "@types/source-map-support": "^0.5.10", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@plugwright/runner": { + "resolved": "../runner-package", + "link": true + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/auth-authme-package/package.json b/auth-authme-package/package.json new file mode 100644 index 0000000..71b3e85 --- /dev/null +++ b/auth-authme-package/package.json @@ -0,0 +1,49 @@ +{ + "name": "@plugwright/auth-authme", + "version": "3.0.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", + "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": { + "@plugwright/runner": ">=3.0.0" + }, + "devDependencies": { + "@plugwright/runner": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/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/docs/api-reference.mdx b/docs/api-reference.mdx index b5ed7fd..59f6961 100644 --- a/docs/api-reference.mdx +++ b/docs/api-reference.mdx @@ -3,6 +3,68 @@ title: "API Reference" description: "Detailed API documentation for advanced users." --- +## Test Runner API + +### `test(name, options?, fn)` + +Defines an individual test case. + + + The title/description of the test. + + + + Runs N independent instances of the test simultaneously with distinct leased accounts to detect race conditions. + + + + Environment capabilities required for this test to execute. + + + + List of environment names where this test is allowed to run. + + +```typescript +import { test, expect } from '@plugwright/runner'; + +test('race condition check', { concurrency: 3 }, async ({ player }) => { + player.chat('/claim'); + await expect(player).toHaveReceivedMessage(/Claimed|already claimed/); +}); +``` + +### `describe.serial(name, options?, fn)` + +Groups tests that execute sequentially while retaining the same player/bot session and leased account across all tests in the block. + + + The title/description of the serial block. + + + + Specific account name to lease from the environment's account pool. + + + + Runs N independent copies of the entire ordered serial chain at the same time. + + +```typescript +import { describe, test, expect } from '@plugwright/runner'; + +describe.serial('kit lifecycle', () => { + test('claim kit', async ({ player }) => { + player.chat('/kit starter'); + }); + + test('is on cooldown', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('cooldown'); + }); +}); +``` + ## `player` / `bot` Object The `player` or `bot` object represents a real Minecraft client connected to the test server. @@ -31,7 +93,7 @@ Waits for a GUI to open matching the title and returns a live handle. ```javascript const gui = await player.gui({ title: 'Shop' }); -const button = gui.locator(i => i.getDisplayName().includes('Confirm')); +const button = gui.locator(i => i.displayName.includes('Confirm')); ``` ### `player.makeOp()` @@ -79,6 +141,16 @@ Gives items to the player. await player.giveItem('diamond', 64); ``` +### `player.clearInventory(item?, options?)` + +Clears the player's inventory and waits until the client-side inventory state reflects it. If an item name is specified, only that item is cleared. + +```javascript +await player.clearInventory(); +// or clear specific item +await player.clearInventory('diamond'); +``` + ### Properties @@ -104,7 +176,7 @@ Executes a command from the server console. ```javascript -server.execute(`give ${player.username} diamond 64`); +await server.execute(`give ${player.username} diamond 64`); ``` ## Exported Utilities @@ -120,6 +192,33 @@ const player2 = await createPlayer(); player2.chat('/tpa ' + player.username); ``` + + Connect as this exact name instead of taking whatever the environment's account pool has free. + + + + Names the bot inside a `describe.serial` block, so a later test in that block gets the same bot + back instead of connecting another one. + + + + The password an authentication plugin logs `username` in with. Only for a named bot: a pool + account already carries its own, and passing both is an error. + + +Ask for a name only when the test needs that specific identity — an account somebody provisioned +by hand, or a name that came out of an external API. A second bot that is only there to be a +second player should stay unnamed, so a stand can lease it from the pool. + +```javascript +const friend = await createPlayer({ + username: 'FriendBot', + password: process.env.FRIEND_BOT_PASSWORD, +}); +``` + +Read the password from the environment. Spec files go to git. + ### `sleep(ms)` Pauses execution for a specified number of milliseconds. @@ -147,7 +246,7 @@ Repeatedly executes a function until it returns a value that is not `undefined`, ```javascript -await poll(() => player.inventory.hasItem('diamond')); +const diamond = await poll(() => player.bot.inventory.items().find(i => i.name === 'diamond')); ``` ### `waitForAssertion(fn, options?)` diff --git a/docs/ci-cd.mdx b/docs/ci-cd.mdx index b9f3101..c126eef 100644 --- a/docs/ci-cd.mdx +++ b/docs/ci-cd.mdx @@ -26,3 +26,30 @@ jobs: # Path to your plugin gradle project if it's not at the project's root working-directory: "." ``` + +## Private npm registries + +If the test workspace installs from a private registry, declare it once in `build.gradle.kts` and pass the token through the environment. Nothing about the registry has to be configured on the runner, and the workflow file holds a secret name rather than a secret: + +```kotlin +plugwright { + npm { + registry("https://nexus.corp/repository/npm-group/") { + authToken(secret.env("NPM_TOKEN")) + } + } +} +``` + +```yaml + - uses: drownek/plugwright-action@v1 + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + with: + java-version: "17" + node-version: "24" +``` + +Plugwright generates the `.npmrc` from that block before each `npm install`. If `NPM_TOKEN` is missing from the job, the build stops and names it, instead of failing later with a 404 that looks like a typo in a package name. See [Configuration](/configuration#npm-registries). + +The generated file is gitignored, but it does exist on disk for the length of the job. On a self-hosted runner with a shared workspace, clean it up the way you would any other credential the job writes. diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 2ae3768..f1b92c9 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -3,6 +3,33 @@ 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") + 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`: @@ -12,10 +39,11 @@ plugwright { // ---- Server Configuration ---- // Version of Paper server to download and run minecraftVersion.set("1.19.4") - - // Directory where the test server will be located - runDir.set(file("run")) - + + // Where the test server lives. Leave it out and it goes to + // /generated//run + // runDir.set(file("/mnt/fast-disk/paper")) + // Automatically accept the Minecraft EULA acceptEula.set(true) @@ -58,21 +86,20 @@ minecraftVersion.set("1.20.1") ``` - Directory where the test server will be located. Default is `project.layout.projectDirectory.dir("run")`. + Directory where the test server will be located. Unset by default, which puts it in `/generated//run` — set it only to keep the server somewhere else. See [Project Layout](/project-layout). ```kotlin -runDir.set(file("run")) -runDir.set(file("test-server")) +runDir.set(file("/mnt/fast-disk/paper")) ``` - Directory containing test files. Default is `file("src/test/e2e")`. + Root of the test workspace: the npm project, the `tests` and `plugins` sources, and the `dist` and `generated` directories the build writes. Default is `file("src/test/e2e")`. ```kotlin testsDir.set(file("src/test/e2e")) -testsDir.set(file("tests/integration")) +testsDir.set(file("e2e")) ``` @@ -167,6 +194,115 @@ downloadNode.set(true) // no local Node.js required - download it automatically nodeVersion.set("22.14.0") ``` +## npm registries + +The workspace is an npm project, and by default it installs from whatever registry the machine is already pointed at. If your packages come from a private registry (a Nexus or an Artifactory, usually), declare it in the build script instead of leaving a `.npmrc` for everyone to set up by hand: + +```kotlin +import me.drownek.plugwright.api.secret + +plugwright { + npm { + registry("https://nexus.corp/repository/npm-group/") { + authToken(secret.env("NPM_TOKEN")) + } + + // Only @plugwright packages come from here; everything else uses the registry above. + scope("@plugwright", "https://nexus.corp/repository/npm-private/") { + username(secret.env("NPM_USER")) + password(secret.env("NPM_PASS")) + } + + option("strict-ssl", "false") + } +} +``` + +Plugwright writes this to a `.npmrc` next to `package.json` immediately before it runs `npm install`, which covers both the workspace's own dependencies and the runner packages your environments pull in. Without an `npm { }` block no file is written and nothing changes. + + + Registries the workspace installs from. `registry(url)` sets the default one, `scope("@org", url)` routes a single scope, and `option(key, value)` writes any other npmrc setting verbatim. All three are optional and can appear in any order. + + +### Credentials + +Credentials are [`SecretRef`](/environments#secrets) values — `secret.env("NPM_TOKEN")`, `secret.file("/run/secrets/npm")`, `secret.systemProperty("npm.token")`. There is deliberately no way to write a literal token: a build script is a file in your repository, and a literal would also end up in the configuration cache. + +`authToken(...)` becomes an `_authToken` line. `username(...)` plus `password(...)` become `username` and a base64-encoded `_password`, which is what npm 7 and later expect. A username without a password (or the other way round) is a configuration error and fails the build. + +So is a secret that resolves to nothing. An unset `NPM_TOKEN` stops the build before `npm install` runs, with the name of the variable that was empty — rather than several minutes later, with a 404 from the public registry. + +### The generated file + +The `.npmrc` carries a marker on its first line: + +``` +# Generated by plugwright - do not edit +# Edit the npm { } block in your build script instead. +registry=https://nexus.corp/repository/npm-group/ +@plugwright:registry=https://nexus.corp/repository/npm-private/ +//nexus.corp/repository/npm-private/:username=ci +//nexus.corp/repository/npm-private/:_password=Y2ktcGFzcw== +strict-ssl=false +``` + +Only a file carrying that marker is ever overwritten. If the workspace already has an `.npmrc` you wrote yourself, plugwright leaves it alone and warns that the `npm { }` block is being ignored — delete the file to hand the job over. Remove the block from the build script and the generated file is deleted with it, so a registry you stopped declaring stops applying. + +The file holds resolved credentials, so it is gitignored: `plugwrightInit` scaffolds a `.gitignore` that lists it, and a workspace created before this existed gets the entry the first time the file is generated. It is written with owner-only permissions where the filesystem supports them. + +## Multi-environment options + +These live on `plugwright { }` itself, next to `testsDir`. + + + 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("name")` for one of your own in the workspace's `plugins` directory. See [Runner Plugins](/plugins). + + +```kotlin +plugins { + npm("@plugwright/auth-authme") { + options["loginCommand"] = "/login" + } +} +``` + ## Environment Variables diff --git a/docs/core-concepts.mdx b/docs/core-concepts.mdx index 081dc37..42ce2e1 100644 --- a/docs/core-concepts.mdx +++ b/docs/core-concepts.mdx @@ -8,7 +8,7 @@ description: "Understand the fundamentals of Plugwright." In Plugwright, every test runs with a pre-configured context. You use `test()` to define a scenario and `expect()` to make assertions. ```javascript -import { expect, test } from '@drownek/plugwright'; +import { expect, test } from '@plugwright/runner'; test('Basic test', async ({ player }) => { // Your test logic here @@ -27,7 +27,7 @@ test('Click an item in GUI', async ({ player }) => { const gui = await player.gui({ title: 'Menu' }); // Find an item by its internal name or display name - const shopItem = gui.locator(i => i.getDisplayName().includes('Shop')); + const shopItem = gui.locator(i => i.displayName.includes('Shop')); await shopItem.click(); await expect(player).toHaveReceivedMessage('You opened the shop!'); diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx new file mode 100644 index 0000000..471b9d0 --- /dev/null +++ b/docs/custom-modes.mdx @@ -0,0 +1,177 @@ +--- +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. + +### Files your mode generates + +Anything written while an environment runs belongs under `ctx.layout.generatedDir(ctx.environmentName)` — `src/test/e2e/generated/proxy` for the mode above. That directory is gitignored and is yours alone; no other environment writes there. + +If the spec has a property for it, fill the default in `applyLayoutDefaults` rather than in the property's convention. It runs before validation, only for properties the build script left unset, so an explicit value in the build script still wins: + +```kotlin +override fun applyLayoutDefaults(spec: VelocityEnvironmentSpec, layout: PlugwrightLayout) { + if (!spec.workDir.isPresent) { + spec.workDir.set(File(layout.generatedDir(spec.name), "compose")) + } +} +``` + +`PlugwrightLayout` also knows where the sources and the compiled output are: `testsDir`, `pluginsDir`, `compiledTestsDir`, `compiledPluginsDir`. See [Project Layout](/project-layout). + +Preparation belongs in a task rather than a callback. A callback executed inside someone else's `@TaskAction` drags your mode object into that task's state, breaks the configuration cache, and can never be run on its own. A task with declared inputs and outputs gets up-to-date checks and a name someone can type. + +If a config value needs something only a task can reach — the Java toolchain, a Gradle service — set it from `registerTasks` with `ctx.environmentConfig(provider)` instead of from `serialize`. That is what `LocalMode` does for the Java executable path. + +### 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 '@plugwright/runner'; + +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, + }; + + 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: true } }` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log. + +`accounts()` and `beforeJoin()` are optional. Returning no pool means every bot gets a throwaway `pw_` username, which is what `local` does. A pool is also what makes `describe.serial('...', { account: 'pw_0001' })` possible: without one, a block asking for a named account fails rather than running as somebody else. + +## 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..aa118e6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -20,6 +20,8 @@ "pages": [ "introduction", "quickstart", + "migration-v3", + "project-layout", "configuration" ] }, @@ -35,6 +37,22 @@ "api-reference", "examples" ] + }, + { + "group": "Environments", + "pages": [ + "environments", + "external-servers", + "plugins", + "reports", + "custom-modes" + ] + }, + { + "group": "Maintaining", + "pages": [ + "publishing" + ] } ] } diff --git a/docs/environments.mdx b/docs/environments.mdx new file mode 100644 index 0000000..d6cc8c0 --- /dev/null +++ b/docs/environments.mdx @@ -0,0 +1,131 @@ +--- +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) + } + + 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`. It also names the directory the environment writes to — `src/test/e2e/generated/local`, where the Paper server for that environment ends up. Two local environments in one build therefore run two separate servers without either one saying where. See [Project Layout](/project-layout). + + +A build script with no `environments { }` block still works. The flat properties (`minecraftVersion`, `runDir`, `downloadPlugins`, and the rest) describe one implicit `local` environment, exactly as they did before. See [Configuration](/configuration). + + +## 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 +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 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 ad17570..68e8068 100644 --- a/docs/examples.mdx +++ b/docs/examples.mdx @@ -14,6 +14,8 @@ The **[`example_plugin`](https://github.com/Drownek/plugwright/tree/master/examp - **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..e5d7a54 --- /dev/null +++ b/docs/external-servers.mdx @@ -0,0 +1,125 @@ +--- +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")) } + } + + 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 | +| `LocalMode` | `full` | Built into LocalMode — commands run via RCON while full server logs are captured directly from stdout | + +The output level matters more than it looks. `full` means the whole server log is readable, so `expect(server).toHaveReceivedMessage(...)` works. `responses` means you get back what the command printed and nothing else. A test that reads the server log should say so: + +```ts +test('command is logged', { requires: { consoleOutput: 'full' } }, async ({ server }) => { + await server.execute('say hello'); + await expect(server).toHaveReceivedMessage('hello'); +}); +``` + +Declaring no channel at all is valid. The environment runs without a console, and every test that requires one is skipped and reported as skipped. + +## 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. The placeholder decides what happens to a name afterwards: `pw_%04d` numbers a fixed set of accounts the run keeps coming back to, while `pw_%s` puts a random suffix there and never hands the same name out twice. See below. +- **`microsoft`** — online-mode accounts. No password; mineflayer authenticates with a cached device-code token. Point `cacheDir` somewhere outside `build/`, and warm the cache before CI ever needs it, because the device-code flow is interactive. + +One account is leased per bot and returned in a `finally`, whatever the test did. When the pool is empty and `autoRegister` has hit `max`, `lease()` throws rather than hand the same identity to two connected bots. + +An explicitly named bot bypasses the pool entirely: + +```ts +const friend = await createPlayer({ username: 'FriendBot', password: process.env.FRIEND_BOT_PASSWORD }); +``` + +That is a request for a specific identity, not for whatever is free, so the pool knows nothing about it and neither does your authentication plugin. Pass the password with the name. Read it from the environment; the spec file goes to git. + +Most second bots don't need this. A test that just wants another player should call `createPlayer()` with no arguments and let the pool answer — a name is worth asking for when the identity is, because somebody provisioned that account with a permission group or a balance, or because the name came from somewhere outside the test. + + +A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, and exclude what you can't. + + +## Numbered slots or fresh names + +`autoRegister` answers a question the fixed `pool` can't: where does a name come from when the server has never seen this test before? Which form you want depends on what the stand can clean up. + +`pw_%04d` gives you `pw_0001` … `pw_000N`, leased in turn and returned when a test ends. The set is finite and the accounts are provisioned once, which is what a stand with permission groups or a whitelist needs. The cost is that every test inherits whatever the last one left on that account, so anything you can't reset with a command has to stay out of the suite (`excludeTests`) or be undone in a plugin's `beforeEach`. + +`pw_%s` generates a name per lease — `pw_a8f2` — and never reuses it. Each test starts on an account with no history, which is the closest a stand gets to what `local` hands out for free. The cost is a registration the server keeps: after a few runs the login plugin's database is full of test accounts, and pruning them is on you. `max` still caps how many bots are connected at once. + +## Naming an account from a test + +A `describe.serial` block can ask for one specific pool account: + +```ts +describe.serial('vip shop', { account: 'pw_0001' }, () => { + // ... +}); +``` + +That's for a scenario tied to state somebody provisioned on that account — a permission group, a starting balance. The account has to be in the pool and free; anything else fails the block instead of quietly running as a different player. See [Writing Tests](/writing-tests). + +## Checking the stand before you test + +```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. + + + +After `setup()`, the environment reports what it actually supports. For `ExternalMode` that is: console plus op only if a console channel answered. Tests that declare `requires` are skipped against that list, with the reason in the report. See [Test Filtering](/test-filtering). diff --git a/docs/gui-testing.mdx b/docs/gui-testing.mdx index 8b8998c..1b938d7 100644 --- a/docs/gui-testing.mdx +++ b/docs/gui-testing.mdx @@ -88,7 +88,7 @@ Creates a locator for items matching the predicate. const compass = gui.locator(i => i.name === 'compass'); // By display name -const itemByName = gui.locator(i => i.getDisplayName().includes('Session')); +const itemByName = gui.locator(i => i.displayName.includes('Session')); // By lore const itemByLore = gui.locator(i => i.hasLore('Click to view')); @@ -198,7 +198,7 @@ test('clicking GUI item triggers callback', async ({ player }) => { player.chat('/example gui-settings'); const gui = await player.gui({ title: 'guiSettings' }); - const item = gui.locator(item => item.getDisplayName().includes('guiItemInfo')); + const item = gui.locator(item => item.displayName.includes('guiItemInfo')); await item.click(); await expect(player).toHaveReceivedMessage('You clicked on item'); @@ -232,7 +232,7 @@ test('navigate through pages', async ({ player }) => { const gui = await player.gui({ title: 'Warps' }); // Check first page - const firstItem = gui.locator(i => i.getDisplayName().includes('Spawn')); + const firstItem = gui.locator(i => i.displayName.includes('Spawn')); await firstItem.click(); // Reopen and check next page @@ -240,7 +240,7 @@ test('navigate through pages', async ({ player }) => { const nextButton = gui.locator(i => i.name === 'arrow'); await nextButton.click(); - const secondItem = gui.locator(i => i.getDisplayName().includes('Arena')); + const secondItem = gui.locator(i => i.displayName.includes('Arena')); await expect.poll(() => secondItem.displayName()).toContain('Arena'); }); ``` diff --git a/docs/matchers.mdx b/docs/matchers.mdx index f22d061..2f610f6 100644 --- a/docs/matchers.mdx +++ b/docs/matchers.mdx @@ -7,10 +7,10 @@ description: "Complete reference for all available assertion matchers." ### `toHaveReceivedMessage(message, options?)` -Waits for the bot to receive a message containing (or exactly matching) the text or RegExp. +Waits for the bot or server console to receive a message containing (or exactly matching) the text or RegExp. ```javascript -// Partial match (default) +// Partial match on player chat (default) await expect(player).toHaveReceivedMessage('Welcome'); // RegEx match @@ -28,10 +28,20 @@ await expect(player).toHaveReceivedMessage('Success', { since: marker }); await expect(player).not.toHaveReceivedMessage('Error'); ``` + +**Concurrency & Isolation Best Practices:** +- **`expect(player)` vs `expect(server)`**: Each bot maintains its own private `messageBuffer`. Messages sent to one player never bleed into another bot's assertions, making `expect(player)` 100% safe in concurrent suites (`concurrency: N`). +- **Asserting on Server Logs**: The server log (`expect(server)`) is a single shared stream for the entire server. In concurrent tests, always include `${player.username}` in your pattern to avoid matching lines produced by other bots: + ```javascript + await expect(server).toHaveReceivedMessage(new RegExp(`Granted VIP to ${player.username}`)); + ``` +- **Global Server State**: If a test asserts on global server output with no player identifier (e.g., `[Plugin] Reload complete`), simply run it as a standard test without the `concurrency` option. + + **Parameters:** - `message` (string | RegExp) - Text or pattern to search for - `options.strict` (boolean) - Require exact match (default: false) -- `options.since` (number) - Buffer index to search from +- `options.since` (number) - Buffer index to search from (e.g. `server.startIndex` or from `player.getMessageBufferIndex()`) - `options.timeout` (number) - Max wait time in ms @@ -80,7 +90,7 @@ Strict equality check using `Object.is()`. Use for primitives. expect(42).toBe(42); expect('hello').toBe('hello'); expect(true).toBe(true); -expect(player.username).toBe('Test_123'); +expect(player.username).toBe('pw_a8f2'); ``` ### `toEqual(value)` @@ -164,7 +174,7 @@ expect(Math.PI).toBeCloseTo(3.14, 2); ```javascript expect('Hello World').toMatch(/World/); expect('Hello World').toMatch('World'); -expect(player.username).toMatch(/Test_\d+/); +expect(player.username).toMatch(/pw_[0-9a-f]+/); ``` ### `toContain(substring)` diff --git a/docs/migration-v3.mdx b/docs/migration-v3.mdx new file mode 100644 index 0000000..8dae508 --- /dev/null +++ b/docs/migration-v3.mdx @@ -0,0 +1,274 @@ +--- +title: "Migration Guide (v2 to v3)" +description: "Migrate your Plugwright test suites and build configuration from v2 to v3." +--- + +Plugwright v3 introduces multi-environment execution, external server and staging stands support, runner plugins, concurrent bot testing, and an updated workspace layout. + +Migrating from v2 to v3 is straightforward, and the Gradle plugin handles most workspace structure changes automatically on the first run. + +--- + +## Step-by-Step Migration Walkthrough + +Here is the exact step-by-step path to upgrade a v2 project to v3: + +### 1. Update the Gradle Plugin Version +In your `build.gradle.kts`, bump the plugin version to `3.0.0` (or check for the latest `3.x` release): + +```kotlin +plugins { + // Before (v2) + // id("io.github.drownek.plugwright") version "2.0.4" + + // After (v3) + id("io.github.drownek.plugwright") version "3.0.0" // or the latest 3.x version +} +``` + +### 2. Update `package.json` and Install +In `src/test/e2e/package.json`, replace `@drownek/plugwright` with `@plugwright/runner` using version `^3.0.0` (or matching your Gradle plugin's 3.x version), then run `npm install`: + +```json +{ + "devDependencies": { + "@plugwright/runner": "^3.0.0" + } +} +``` + +### 3. Update Spec Imports +In your TypeScript test files, rename the package import: + +```typescript +// Before (v2) +import { test, expect } from '@drownek/plugwright'; + +// After (v3) +import { test, expect } from '@plugwright/runner'; +``` + +### 4. Run `gradlew plugwrightTest` +Run your test task: + +```bash +./gradlew plugwrightTest +``` + +On first run, Plugwright detects the legacy v2 layout and performs an automatic migration: +- Moves your `*.spec.ts` files from `src/test/e2e/` into `src/test/e2e/tests/` (preserving subdirectories). +- Updates `src/test/e2e/tsconfig.json` to include `"tests/**/*.ts"` and `"plugins/**/*.ts"`. +- Compiles the tests and runs the suite. + +```text +Moved 13 spec file(s) into .../src/test/e2e/tests — plugwright looks for specs under 'tests' now. +Updated .../src/test/e2e/tsconfig.json for the new layout +``` + +--- + +## Recommended Configuration Update + +While v3 retains compatibility with the old flat `plugwright { ... }` block, it is recommended to adopt the new `environments` syntax. Notice that server-specific settings (`minecraftVersion`, `acceptEula`, `downloadPlugins`) now belong inside `environments.create("local", LocalMode)`: + +```kotlin +// Before (v2 flat configuration) +plugwright { + minecraftVersion.set("26.1.2") + acceptEula.set(true) + testsDir.set(file("src/test/e2e")) + downloadPlugins { + url("https://hangarcdn.papermc.io/plugins/HelpChat/PlaceholderAPI/versions/2.11.6/PAPER/PlaceholderAPI-2.11.6.jar") + } + downloadNode.set(System.getenv("CI") != "true") +} +``` + +```kotlin +// After (v3 environments DSL) +import me.drownek.plugwright.local.LocalMode + +plugwright { + environments.create("local", LocalMode) { + minecraftVersion.set("26.1.2") + acceptEula.set(true) + downloadPlugins { + url("https://hangarcdn.papermc.io/plugins/HelpChat/PlaceholderAPI/versions/2.11.6/PAPER/PlaceholderAPI-2.11.6.jar") + } + } + testsDir.set(file("src/test/e2e")) + downloadNode.set(System.getenv("CI") != "true") +} +``` + + +Without `environments`, the flat properties define an implicit `local` environment. They are deprecated and slated for removal. + + +--- + +## API Adjustments & Modernizations + +### Awaiting `server.execute(...)` +In v3, `server.execute(...)` communicates with the console channel asynchronously and returns a `Promise`. +While unawaited calls will often still fire in the background (similar to v2 behavior), awaiting it is strongly recommended so you can catch errors or read command output reliably: + +```typescript +// Recommended in v3: +await server.execute(`give ${player.username} diamond 64`); +``` + +### GUI Item Display Name Property +Instead of invoking `item.getDisplayName()`, you can now use the clean property accessor `item.displayName`: + +```typescript +// Before (v2) +const spawn = gui.locator(item => + item.getDisplayName().includes('Spawn') +); + +// After (v3) +const spawn = gui.locator(item => + item.displayName.includes('Spawn') +); +``` + +### Built-in `player.clearInventory(...)` +Avoid manual command workarounds to reset a player's inventory. `player.clearInventory` clears the inventory and waits until client-side inventory state reflects it: + +```typescript +// Clear entire inventory +await player.clearInventory(); + +// Or clear specific item +await player.clearInventory('diamond'); +``` + +--- + +## Directory & Git Ignore Updates + +The runtime directories are now isolated per environment: +- **Server files**: Now live in `/generated//run/` (e.g. `src/test/e2e/generated/local/run/`). +- **Compiled specs**: Output to `src/test/e2e/dist/`. + +Make sure `src/test/e2e/.gitignore` contains: +```gitignore +node_modules +dist +generated +.npmrc +``` +You can safely remove root `run/` from your repository's top-level `.gitignore` if it's no longer used. + +--- + +## New Features Available in v3 + +Plugwright v3 brings major capabilities designed for real-world server environments, race condition detection, and complex gameplay flows: + +### 1. Stateful Multi-Step Tests: `describe.serial` +By default, every test gets a fresh player and an isolated connection. With `describe.serial`, a single player connection is maintained across all tests in the block. This makes it effortless to test lifecycles such as kit cooldowns, auction cycles, multi-step quests, and economy balances without cumbersome workarounds. + +```typescript +import { describe, test, expect, sleep } from '@plugwright/runner'; + +describe.serial('kit cooldown lifecycle', () => { + test('claims the starter kit', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Received starter kit'); + }); + + test('kit is immediately on cooldown', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Kit is on cooldown'); + }); + + test('can claim again after waiting', async ({ player }) => { + await sleep(5000); + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Received starter kit'); + }); +}); +``` + +You can also name retained secondary bots across steps using `createPlayer({ as: 'buyer' })`. [Read more in Writing Tests › describe.serial](/writing-tests#tests-that-share-a-player-describeserial). + +--- + +### 2. Race Condition Testing: `concurrency: N` +Catching bugs like item duping, chest snipe, or auction desync requires multiple players hitting the same logic simultaneously. Plugwright v3 introduces first-class concurrency at the test and serial block level: + +```typescript +test('only one player can loot the treasure chest', { concurrency: 5 }, async ({ player }) => { + player.chat('/lootchest claim'); + // Passes only if all 5 concurrent instances observe expected outcomes without server errors + await expect(player).toHaveReceivedMessage(/Claimed reward|Chest already looted/); +}); +``` + +Plugwright spins up N isolated runner instances and leases distinct accounts from the pool simultaneously. [Read more in Writing Tests › concurrency](/writing-tests#racing-bots-against-each-other-concurrency). + +--- + +### 3. Remote Stands & External Servers (`ExternalMode`) +In addition to spinning up ephemeral local Paper servers via `LocalMode`, v3 natively supports testing against remote staging servers, production mirrors, or persistent local stands using `ExternalMode`. + +- **Account Pools**: Safely leases and releases pre-configured test bot accounts. +- **RCON Console Channel**: Execute server commands and parse console responses via secure RCON. +- **Stand Reset & Ping Tasks**: Auto-generated `./gradlew Ping` and `./gradlew Clean` tasks. + +```kotlin +import me.drownek.plugwright.external.ExternalMode + +plugwright { + environments.create("stand", ExternalMode) { + server { + host.set("staging.myserver.net") + port.set(25565) + } + rcon { + port.set(25575) + password.set(System.getenv("STAND_RCON_PASSWORD")) + } + accounts { + account("bot_1", System.getenv("BOT1_PASSWORD")) + account("bot_2", System.getenv("BOT2_PASSWORD")) + } + } +} +``` +[Read more in External Servers](/external-servers). + +--- + +### 4. Runner Plugins & Authentication (e.g. AuthMe) +Runner plugins extend test execution with custom hooks, fixtures, matchers, and auth adapters. Plugwright v3 provides first-party packages like `@plugwright/auth-authme` (handling login/register dialogs, session resumption, and password secrecy). + +Plugins can be declared directly in your Gradle environment configuration: +```kotlin +environments.create("stand", ExternalMode) { + plugins { + plugin("@plugwright/auth-authme") { + config.set(mapOf("registerCommand" to "/register", "loginCommand" to "/login")) + } + } +} +``` +[Read more in Runner Plugins](/plugins). + +--- + +### 5. Private npm Registries +If your organization distributes internal matchers, runner plugins, or fixtures via private npm registries, declare them right in your `build.gradle.kts`: + +```kotlin +plugwright { + npm { + registry("@myorg", "https://npm.pkg.github.com") { + authToken.set(System.getenv("GITHUB_TOKEN")) + } + } +} +``` +Plugwright generates the appropriate `.npmrc` scoped configuration automatically before installing test dependencies. [Read more in Configuration](/configuration#npm-registries). diff --git a/docs/plugins.mdx b/docs/plugins.mdx new file mode 100644 index 0000000..7aa9a77 --- /dev/null +++ b/docs/plugins.mdx @@ -0,0 +1,160 @@ +--- +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("staging") { + inheritTests = false + } + } +} +``` + +`npm(...)` names a published package, installed by `plugwrightCompileTests` along with the rest of the environment's packages. `local(...)` names a plugin of your own: `local("staging")` is `plugins/staging.ts` in the test workspace, compiled to `dist/plugins/staging.js` by the same `tsc` run as your specs. For a plugin that lives outside the workspace there is still `local(file("..."))`. Options are plain strings — anything secret belongs in `accounts { }`, where it stays a secret reference. + +`LocalMode` takes the same block. A local server running an authentication plugin needs the login hook exactly as much as a remote one does. + +## 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 '@plugwright/runner'; + +export default definePlugin({ + name: 'authme', + async onPlayerCreate(player, { account }) { + // Whether AuthMe puts up a login wall for a premium account is a server-side + // setting, not something derivable from `account.auth` — don't assume it away. + // wait for the prompt, answer it, wait for the confirmation + }, +}); +``` + +`onPlayerCreate` fires on every connection: the bot a test starts with, a second bot from `createPlayer()`, and every `player.rejoin()`. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook. + +## Hooks and `describe.serial` + +`beforeEach` and `afterEach` normally wrap every test. Around a [`describe.serial`](/writing-tests) block they run once instead: before its first test and after its last. + +That is deliberate, and it matters most for a plugin that resets an account between tests. A block exists because its second test depends on what its first one did; a reset firing in between would throw that away, and the plugin has no way to tell which state the block was counting on. Anything a plugin needs to do per test inside a block belongs in the spec's own `beforeEach`, where the test author can see it. + +## Inherited tests + +```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 only looks at your own compiled `tests` directory, so this is the only way a packaged test ever runs. Per-plugin, `inheritTests = false` loads the hooks and matchers without the tests. + +## Fixtures + +`extendContext` adds fields to the object every test destructures: + +```ts +extendContext(ctx) { + return { auth: new AuthApi(ctx.player) }; +} +``` + +```ts +declare module '@plugwright/runner' { + 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 '@plugwright/runner'; + +export default definePlugin({ + name: 'staging-reset', + + async beforeEach({ player, server }) { + if (!server.session.env.capabilities.console) return; + + // Reset permissions and clear inventory before letting the test start + await player.deOp(); + await player.clearInventory(); + }, +}); +``` + +`definePlugin` is an identity function; it exists so TypeScript infers your options type at the definition site. Depend on `@plugwright/runner` as a peer dependency, ship compiled JavaScript, and point `main` at it. + +`@plugwright/auth-authme` in this repository is a complete, working example: a hook, an options interface, a preflight test, and a README. diff --git a/docs/project-layout.mdx b/docs/project-layout.mdx new file mode 100644 index 0000000..f1a08ba --- /dev/null +++ b/docs/project-layout.mdx @@ -0,0 +1,85 @@ +--- +title: "Project Layout" +description: "Where the specs, the plugins and the generated files live." +--- + +Everything plugwright needs sits under one directory — `src/test/e2e` unless you point `testsDir` somewhere else. It is an npm project, so `package.json` and `node_modules` are there too: + +``` +src/test/e2e/ + package.json the npm project the runner is installed into + tsconfig.json + .npmrc generated from npm { }, when the build script has one + .gitignore node_modules, dist, generated, .npmrc + tests/ your specs + shop.spec.ts + plugins/ runner plugins you wrote yourself + stand-reset.ts + dist/ compiled output, mirroring tests/ and plugins/ + generated/ what the environments write while they run + local/run/ the Paper server the local environment starts + node_modules/ +``` + +Three of those directories are disposable: `node_modules`, `dist` and `generated`. Delete any of them and the next `plugwrightTest` recreates it. `plugwrightInit` writes a `.gitignore` covering all three; if you already have one, it appends the lines it needs and leaves the rest alone. + +So is the `.npmrc`, when there is one — it is generated from the `npm { }` block before every install and may hold a registry token, which is why it is gitignored too. See [Configuration](/configuration#npm-registries). + +## tests + +`plugwrightCompileTests` compiles `tests/**/*.ts` into `dist/tests`, keeping subdirectories, and the runner scans the result for `.spec.js`. Group specs into folders however you like — `tests/economy/shop.spec.ts` is fine. + +A workspace of plain JavaScript needs no compile step. Without a `tsconfig.json` the runner reads `tests/` directly. + +## plugins + +Runner plugins — hooks, fixtures, matchers, inherited tests — go in `plugins/`, one file each, and compile into `dist/plugins`. A plugin is loaded by name: + +```kotlin +plugins { + local("stand-reset") // plugins/stand-reset.ts +} +``` + +`local(file(...))` still takes a path, for a plugin that lives somewhere else entirely. See [Runner Plugins](/plugins). + +## generated + +Each environment gets its own directory under `generated/`, named after it. The local environment puts its Paper server in `generated//run`: the jar, the worlds, the logs, the plugins it downloaded. Two local environments in one matrix therefore never share a server directory. + +You can still choose the directory yourself, and an explicit value always wins: + +```kotlin +environments { + create("local", LocalMode) { + runDir.set(file("/mnt/fast-disk/paper")) + } +} +``` + +The `stand` in the [example project](https://github.com/Drownek/plugwright/tree/master/example_plugin) shows why the default is convenient: an external environment can point at the very server the local one left behind, because there is only one place it could be. + +## Moving the whole thing + +`testsDir` is the root of all of this: + +```kotlin +plugwright { + testsDir.set(file("e2e")) +} +``` + +Then the specs are in `e2e/tests`, the server in `e2e/generated/local/run`, and so on. + +## Migrating from the old layout + +Before this layout, specs sat directly in `testsDir` and the local server went to a `run/` directory next to `build.gradle.kts`. The move is mostly automatic — the first `plugwrightCompileTests` after upgrading moves every spec it finds into `tests/`, subdirectories intact, and rewrites `tsconfig.json` so `include` points at the new place. It logs both. + +Four things are worth checking by hand afterwards: + +1. **Your `.gitignore`.** `run/` no longer needs an entry. `generated/` inside the workspace does — run `plugwrightInit` again to have the lines appended, or add them yourself. +2. **`runDir`.** A build script that sets it keeps that exact directory. Drop the line to get `generated//run` instead, and move the server there if you want to keep the downloaded jar and the worlds. +3. **Local plugins.** `local(file("src/test/e2e/dist/plugins/x.js"))` becomes `local("x")` once the source is in `plugins/`. +4. **A `tsconfig.json` with comments.** JSON with comments is legal in a `tsconfig` and unparseable as JSON, so plugwright leaves such a file untouched and says so. Point `include` at `tests/**/*.ts` and `plugins/**/*.ts` yourself. + +If you would rather do the move by hand, `git mv` the specs into `tests/` before upgrading. The migration only runs while there is no `tests/` directory at all. diff --git a/docs/publishing.mdx b/docs/publishing.mdx new file mode 100644 index 0000000..3845ae8 --- /dev/null +++ b/docs/publishing.mdx @@ -0,0 +1,148 @@ +--- +title: "Publishing Plugwright" +description: "Release the packages and the gradle plugin to npmjs and the Plugin Portal, or to a registry of your own." +--- + +This page is for people releasing Plugwright itself, or running a fork of it inside an +organisation. If you are writing tests for your own plugin you want [Quickstart](/quickstart) +instead. + +Plugwright ships as three artifacts that move as one version: + +| Artifact | Kind | Public home | +| --- | --- | --- | +| `@plugwright/runner` | npm | npmjs.com | +| `@plugwright/auth-authme` | npm | npmjs.com | +| `io.github.drownek.plugwright` | gradle plugin | Gradle Plugin Portal | + +Both destinations — the public one and a private one — use the same two commands. What +changes is the environment they run in. + +## A public release + +Tagging a commit `v*` runs `.github/workflows/release.yml`, which does the whole thing. To +do it by hand: + +```bash +npm run install:packages +npm run publish:packages + +cd gradle-plugin +./gradlew publishToPublicRepository +``` + +`publish:packages` with nothing configured publishes both npm packages to npmjs.com. It +uses whatever credentials npm already has, so an `npm login` session or an `NPM_TOKEN` is +enough. In CI it is an `NPM_TOKEN` secret rather than the job's OIDC token: a trusted +publisher is configured per package, and the `@plugwright` names have never been published, +so there is nothing to authenticate against until the first release has gone out. Provenance +is signed from the OIDC token either way, so `--provenance` works with both. Once both +packages exist, `npm trust github --file release.yml` replaces the secret. + +`publishToPublicRepository` is the Gradle Plugin Portal, and reads `GRADLE_PUBLISH_KEY` and +`GRADLE_PUBLISH_SECRET` the way the `plugin-publish` plugin always has. + +## Publishing to your own registry + +An organisation that cannot reach npmjs.com or the Plugin Portal — an air-gapped build farm, +or one that only resolves through a mirror — needs these artifacts somewhere its builds can +reach. Nothing about that registry is written into the repository: a URL in a `package.json` +or a build script would send the *public* release there too, and a password in either would +be a password in version control. Both come from the environment instead. + +Copy `.env.example` to `.env` and fill in what applies. `.env` is ignored by git. + +```bash +PLUGWRIGHT_NPM_REGISTRY=https://registry.example.com/repository/npm-hosted/ +PLUGWRIGHT_NPM_USER=deploy +PLUGWRIGHT_NPM_PASSWORD=... + +PLUGWRIGHT_PUBLISH_URL=https://repo.example.com/repository/maven-releases/ +PLUGWRIGHT_PUBLISH_USER=deploy +PLUGWRIGHT_PUBLISH_PASSWORD=... +``` + +Then the same two commands, pointed elsewhere: + +```bash +npm run publish:packages + +cd gradle-plugin +./gradlew publishToPrivateRepository +``` + + +Leave the user and password unset for a registry that accepts anonymous deploys, or one that +authenticates through an `.npmrc` you already have. The credentials are only used when both +are given. + + +### Switching a destination off + +Neither destination is mandatory, and neither being available is a normal state rather than a +failure. + +Private publishing is off until `plugwright.publish.url` names a repository. Without one, +`publishToPrivateRepository` succeeds, publishes nothing, and says why — so a build script or a +CI job can name the task unconditionally without every un-configured checkout failing on it. + +Public publishing is on by default, since that is where a release goes. Turn either off +explicitly when the implicit rule gets it wrong — a fork that publishes only inside a company +wants the public one off, and a machine that has the private URL in its environment for +*resolving* may still want to publish nowhere: + +```bash +./gradlew publishToPublicRepository -Pplugwright.publish.public.enabled=false +./gradlew publishToPrivateRepository -Pplugwright.publish.private.enabled=false +``` + +Both also read `PLUGWRIGHT_PUBLISH_PUBLIC_ENABLED` and `PLUGWRIGHT_PUBLISH_PRIVATE_ENABLED`. +A switched-off destination reports its publish task as `SKIPPED`. + +On the npm side the same rule falls out of the configuration: with no `PLUGWRIGHT_NPM_REGISTRY` +the packages go to npmjs, and a private registry is used only when one is named. + +The npm credentials are written to a temporary npm config outside the working tree and passed +with `--userconfig`, then deleted whether the publish worked or not. They go in as a Basic +`_auth` pair rather than a bearer `_authToken`, because some registries — Nexus among them — +answer a bearer token with `401`. + +### Settings + +Everything below can be given as an environment variable or as a flag to +`npm run publish:packages -- --flag value`. Flags win. + +| Variable | Flag | Default | +| --- | --- | --- | +| `PLUGWRIGHT_NPM_REGISTRY` | `--registry` | npmjs.com | +| `PLUGWRIGHT_NPM_USER` | `--user` | npm's own credentials | +| `PLUGWRIGHT_NPM_PASSWORD` | `--password` | npm's own credentials | +| `PLUGWRIGHT_NPM_TAG` | `--tag` | `latest` | +| `PLUGWRIGHT_NPM_ACCESS` | `--access` | `public` | +| `PLUGWRIGHT_NPM_PROVENANCE` | `--provenance` | off | + +`--dry-run` packs every package and reports what would be sent, without sending it. Worth +running once against a new registry before the real thing, since most registries refuse to +overwrite a release. + +The gradle side takes gradle properties as well as environment variables: +`plugwright.publish.url`, `plugwright.publish.user`, `plugwright.publish.password`, and the two +`.enabled` switches above. + +## Consuming a private registry + +Publishing is one half. The builds that resolve these artifacts need to be pointed at the +same places — an `npm { registry(...) }` block for the packages and a `pluginManagement` +repository for the plugin. That is covered in +[Private npm registries](/ci-cd#private-npm-registries) and +[Configuration](/configuration). + +## Moving the version + +All three artifacts carry one version, kept in `version.txt`. `npm run bump` moves it +everywhere at once — the two `package.json` files and the lockfiles that record the +runner's version, plus the README, the quickstart and the example plugin for a stable +release — then tags the commit. + +Publish after the tag, not before: most registries refuse to overwrite a release that already +exists, so a version published from a half-finished tree cannot be re-published. diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 4596843..3417393 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -17,21 +17,26 @@ description: "Start running your first test in less than 5 minutes." ```kotlin plugins { - id("io.github.drownek.plugwright") version "2.0.4" + id("io.github.drownek.plugwright") version "3.0.0" } plugwright { - minecraftVersion.set("1.19.4") - testsDir.set(file("src/test/e2e")) - acceptEula.set(true) - - // Download some dependencies your plugin might need - downloadPlugins { - url("https://url.to/plugin1.jar") - url("https://url.to/plugin2.jar") - // ... etc + environments { + // Paper downloaded, patched, started and killed by plugwright itself. + create("local", LocalMode) { + minecraftVersion.set("1.19.4") + acceptEula.set(true) + // Download some dependencies your plugin might need + downloadPlugins { + url("https://url.to/plugin1.jar") + url("https://url.to/plugin2.jar") + // ... etc + } + } } + testsDir.set(file("src/test/e2e")) + // If true, always downloads and uses an isolated Node.js version, ignoring the system Node. downloadNode.set(true) } @@ -40,17 +45,38 @@ description: "Start running your first test in less than 5 minutes." If you already have Node.js installed on your system, you can comment out `downloadNode.set(true)` to speed up initialization. Otherwise, leave it uncommented. + + If npm at your company goes through a private registry, add an `npm { }` block now — the next step installs packages, and it will need it: + + ```kotlin + plugwright { + npm { + registry("https://nexus.corp/repository/npm-group/") { + authToken(secret.env("NPM_TOKEN")) + } + } + } + ``` + + Plugwright writes that to a gitignored `.npmrc` in the workspace before each install. See [Configuration](/configuration#npm-registries). - Run the init command to set up your test folder. - This will automatically generate your package.json, TypeScript configuration, and an example test in a chosen directory. - - This command is interactive, so simply follow the prompts on your screen: + Run the init command to set up your test folder. It asks where to put it, then writes an npm project with a `package.json`, a TypeScript config, a `.gitignore`, an example spec and an example runner plugin: ```bash ./gradlew plugwrightInit ``` + + ``` + src/test/e2e/ + tests/example.spec.ts your specs go here + plugins/example-plugin.ts hooks, fixtures and matchers + package.json, tsconfig.json + .gitignore node_modules, dist, generated, .npmrc + ``` + + Everything a run generates — the compiled specs, the server the local environment starts — stays inside that directory, under `dist` and `generated`. See [Project Layout](/project-layout). diff --git a/docs/reports.mdx b/docs/reports.mdx new file mode 100644 index 0000000..54d7b4c --- /dev/null +++ b/docs/reports.mdx @@ -0,0 +1,110 @@ +--- +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. `botUsername` is the bot that ran it, when one connected. + +Every skip carries its reason: excluded by name, wrong environment, a capability the environment doesn't have, or an earlier test in the same [`describe.serial`](/writing-tests) block that stopped the chain. A skipped test that doesn't say why is worse than a failing one, because it reads as coverage. + +Tests from a serial block appear as ordinary entries, in the order they ran, under their full `describe` path. + +## Concurrent tests + +A test (or block) run with [`concurrency`](/writing-tests) still gets one entry, not N. `durationMs` is the slowest instance, and `instances` carries every instance's own outcome: + +```json +{ + "file": "…/dist/claim.spec.js", + "name": "only one player can claim the chest", + "status": "fail", + "durationMs": 812, + "error": "Expected message matching \"Claimed\" not received", + "skipReason": null, + "plugin": null, + "botUsername": null, + "instances": [ + { "index": 1, "botUsername": "pw_a1", "passed": true, "durationMs": 640, "error": null }, + { "index": 2, "botUsername": "pw_b2", "passed": false, "durationMs": 812, "error": "Expected message matching \"Claimed\" not received" }, + { "index": 3, "botUsername": "pw_c3", "passed": true, "durationMs": 701, "error": null } + ] +} +``` + +`instances` is `null` for an ordinary, non-concurrent test — `botUsername` on the row itself is where its bot lives instead. The JUnit report doesn't carry this breakdown; it only ever sees the one aggregated pass/fail/duration, so read the JSON report when a concurrent test fails. + +## JUnit XML + +```xml + + + + + + +``` + +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..9c6cce6 100644 --- a/docs/test-filtering.mdx +++ b/docs/test-filtering.mdx @@ -1,43 +1,94 @@ --- 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: true, op: true } }, 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 | + +A boolean `true` capability is satisfied by anything other than `false`, `'none'` or an absent value. To demand one specific value, map it in the object: + +```ts +test('console matters', { requires: { consoleOutput: 'full' } }, async ({ server }) => { + await server.execute('say hello'); + await expect(server).toHaveReceivedMessage('hello'); +}); +``` + +That form exists because `{ requires: { console: true } }` is satisfied by an RCON console that answers its own commands, while reading the server log needs a console that streams all of it. Without the distinction you get tests that neither skip nor work. + +**By environment name** — for when the difference isn't a capability but what's installed on that particular server: + +```ts +test('the /debug dev command', { environments: ['local'] }, async ({ player }) => { + player.chat('/debug'); +}); +``` + +## Serial blocks filter as one + +A [`describe.serial`](/writing-tests) block is filtered whole. If any filter — a name pattern, `excludeTests`, `requires`, `environments` — rules out one test in the block, every test in it is skipped and reports the same reason: + +``` + Serial block: kit lifecycle - SKIPPED ("kit lifecycle > is on cooldown right after" excluded by tests.exclude (matches "cooldown"), and a serial block runs whole or not at all) +``` + +Running half a chain is worse than running none of it: the steps left behind assert against state the skipped step was supposed to create. `-PtestNames` against a serial block is an all-or-nothing choice, so match the block's name rather than one test inside it. + +## Skips are reported + +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/docs/writing-tests.mdx b/docs/writing-tests.mdx index 9a8fcbb..8c6166a 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -8,7 +8,7 @@ description: "Learn how to write and structure your Plugwright tests." Tests use a simple API similar to Jest: ```typescript -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('test description', async ({ player }) => { // Your test code here @@ -17,10 +17,10 @@ test('test description', async ({ player }) => { ## Your First Test -Create `src/test/e2e/first.spec.ts`: +Create `src/test/e2e/tests/first.spec.ts` — specs live in `tests`, in whatever subdirectories you like ([Project Layout](/project-layout)): ```typescript -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('player receives welcome message', async ({ player }) => { await expect(player).toHaveReceivedMessage('Welcome'); @@ -52,7 +52,7 @@ test('player starts with default balance', async ({ player }) => { }); test('player can purchase items', async ({ player, server }) => { - server.execute(`eco give ${player.username} 500`); + await server.execute(`eco give ${player.username} 500`); player.chat('/buy diamond'); await expect(player).toHaveReceivedMessage('Purchased'); await expect(player).toContainItem('diamond'); @@ -73,7 +73,7 @@ test('player inventory has starter items', async ({ player }) => { ```typescript test('server executes commands', async ({ player, server }) => { - server.execute(`give ${player.username} diamond 64`); + await server.execute(`give ${player.username} diamond 64`); await expect(player).toContainItem('diamond'); }); ``` @@ -112,7 +112,7 @@ await expect(player).toContainItem('item_name') The framework provides several exported utilities for advanced waiting and polling: ```typescript -import { test, sleep, poll, waitForAssertion, waitUntil, waitForStable } from '@drownek/plugwright'; +import { test, sleep, poll, waitForAssertion, waitUntil, waitForStable } from '@plugwright/runner'; test('advanced waiting', async ({ player }) => { // Sleep for 1 second @@ -123,9 +123,155 @@ test('advanced waiting', async ({ player }) => { }); ``` +## Tests that share a player: `describe.serial` + +Every test gets its own bot, connected before it starts and disconnected after it ends. That is the right default, and most tests want nothing else. + +Some scenarios aren't one test, though. "Claim a kit, see it on cooldown, wait, see it claimable again" is three assertions about the same player in a fixed order, and three independent bots can't express it however you schedule them. `describe.serial` is for exactly that: + +```typescript +import { describe, test, expect, sleep } from '@plugwright/runner'; + +describe.serial('kit lifecycle', () => { + test('claims the starter kit', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Received starter kit'); + }); + + test('is on cooldown right after', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('cooldown'); + }); + + test('can claim again once the cooldown expires', async ({ player }) => { + await sleep(5000); + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Received starter kit'); + }); +}); +``` + +One player, one connection, one leased account for the whole block. The tests run in the order they are written, and the account goes back to the pool when the block ends. + +### The block is the unit + +Filters apply to the block, not to the tests inside it. If `requires`, `environments`, `tests.names` or `tests.exclude` rules out any test in the block, the whole block is skipped and every test in it is reported skipped with that reason. A chain missing a link is worse than no chain: the steps after it would assert against state that nothing produced. + +The same logic covers failure. When a test fails or times out, the rest of its block is reported **skipped**, not failed — those tests never ran, and reporting them as failures would bury the one real cause under a pile of noise. A test can also say so itself: + +```typescript +test('buys the last item in stock', async ({ player, invalidatePlayer }) => { + // ... + if (theShopIsNowEmpty) invalidatePlayer(player, 'shop stock is spent'); +}); +``` + +Outside a serial block `invalidatePlayer` does nothing, since the bot is disconnected at the end of the test anyway. + +### Hooks inside a block + +Plugin `beforeEach`/`afterEach` run **once around the whole block** — before its first test and after its last. A plugin whose job is to reset an account between tests would otherwise undo exactly what the block is built on. Spec-level `beforeEach`/`afterEach` still run for every test, and `describe` nesting works inside a block the way it does anywhere else: + +```typescript +describe.serial('shop lifecycle', () => { + beforeEach(async ({ player }) => { + // runs before each of the three tests below + }); + + describe('buying', () => { + test('adds the item', async ({ player }) => { /* ... */ }); + test('takes the money', async ({ player }) => { /* ... */ }); + }); +}); +``` + +Each test starts with a clear message buffer, so a message from an earlier step can't satisfy an assertion about this one. What carries over is server state, which is the point. + +A block can't contain another `describe.serial` — nesting one ordered chain inside another is a scheduling question the runner deliberately doesn't answer. + +### Naming an account + +On a stand where one specific account carries the state a scenario needs — a permission group, a starting balance, a whitelist entry — name it: + +```typescript +describe.serial('vip shop', { account: 'pw_0001' }, async () => { + // every test in the block runs as pw_0001 +}); +``` + +The account must exist in the environment's `accounts { }` pool and be free. If it is already leased, or the environment has no pool at all (`LocalMode` invents a name per bot and has none), the block fails with that message rather than quietly running as somebody else. + +### A second bot for the block + +`ctx.createPlayer()` connects an extra bot that lives as long as the test does. Inside a block, `as` gives it a name so a later test gets the same bot back: + +```typescript +describe.serial('trading', () => { + test('sends the payment', async ({ player, createPlayer }) => { + const buyer = await createPlayer({ as: 'buyer' }); + buyer.chat(`/pay ${player.username} 100`); + await expect(player).toHaveReceivedMessage('Received $100'); + }); + + test('the buyer is out of money', async ({ createPlayer }) => { + const buyer = await createPlayer({ as: 'buyer' }); // the same bot as above + buyer.chat('/balance'); + await expect(buyer).toHaveReceivedMessage('$900'); + }); +}); +``` + +## Racing bots against each other: `concurrency` + +One bot can't produce a race. Two players opening the same chest at once, three players buying the last item in stock — bugs like that only exist when several bots hit the same feature at the same time, on the same server. `concurrency` runs a test as N independent instances at once, each with its own bot: + +```typescript +test('only one player can claim the chest', { concurrency: 3 }, async ({ player }) => { + player.chat('/claim'); + await expect(player).toHaveReceivedMessage(/Claimed|already claimed/); +}); +``` + +Each instance leases its own account and runs the full test body on its own bot. The test only passes if every instance does — one instance losing the race it wasn't supposed to lose is the bug you're trying to catch, not noise to average away. + +`describe.serial` blocks take the same option, running N independent copies of the whole ordered chain at once: + +```typescript +describe.serial('kit lifecycle', { concurrency: 5 }, () => { + test('claims the starter kit', async ({ player }) => { /* ... */ }); + test('is on cooldown right after', async ({ player }) => { /* ... */ }); +}); +``` + +### The report still has one row per test + +`concurrency` multiplies how many bots run a test, not how many rows it produces in the summary. That row's duration is the slowest instance, and it carries an `instances` array with every instance's own outcome — bot username, pass or fail, duration — so a failure tells you which bot lost, not just that somebody did. + + +**Console and Server Log Assertions under Concurrency:** +- **Player chat is isolated**: `expect(player).toHaveReceivedMessage(...)` checks that specific bot's private message buffer. It never collides between concurrent instances. +- **Server console is shared**: `expect(server).toHaveReceivedMessage(...)` inspects the server's single shared log. When multiple bots run concurrently, distinguish log entries by incorporating the bot's unique username: + ```typescript + test('claims bounty', { concurrency: 3 }, async ({ player, server }) => { + player.chat('/bounty claim'); + // Qualify server log assertions with the player's unique name: + await expect(server).toHaveReceivedMessage(new RegExp(`Bounty awarded to ${player.username}`)); + }); + ``` +- **Global commands**: Tests asserting on unparameterized global server events (like `/reload` or global broadcasts without player names) should run as standard single tests without the `concurrency` option. + + +### How many you can ask for + +`concurrency: N` needs N free accounts. On an environment with an account pool, this is checked before any test in the run starts: ask for `concurrency: 10` against a 4-account pool and it fails immediately with a clear error, instead of the 5th bot hanging on a lease nobody's going to release. `LocalMode` has no pool — it mints a throwaway account per bot — so there's nothing to check there; its only real ceiling is the server's own `max-players`. + +### The server log is still one shared log + +`expect(server)` reads the console output the whole session shares, so one instance's commands sit in that log right next to every other instance's. Nothing filters that for you, and that's deliberate — asserting the server log never produced something no bot should have caused (`expect(server).not.toHaveReceivedMessage('NullPointerException')`) is exactly what a concurrency test is for. If you need one bot's output specifically, either put its username in the pattern or check `expect(player)` instead, since a player's own messages never mix with another bot's. + ## Best Practices -1. **Keep tests isolated** - Each test gets a fresh bot +1. **Keep tests isolated** - Each test gets a fresh bot unless it is in a `describe.serial` block 2. **Use descriptive names** - Make test failures easy to understand 3. **Wait for conditions** - Use assertions that auto-retry 4. **Test one thing** - Each test should verify one behavior @@ -134,7 +280,7 @@ test('advanced waiting', async ({ player }) => { ## Tips -- Tests run sequentially, not in parallel +- Tests run sequentially by default — a test that needs bots racing each other can opt into `concurrency` - Server starts fresh for each test run - Bot automatically connects to the server - Server logs are visible in the console output diff --git a/example_plugin/README.md b/example_plugin/README.md new file mode 100644 index 0000000..344d3b4 --- /dev/null +++ b/example_plugin/README.md @@ -0,0 +1,62 @@ +# 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 `src/test/e2e/generated/local/run`, installs PlaceholderAPI and AuthMe next to the plugin under test, writes an AuthMe config a bot can get through, starts the server, runs everything, and shuts it down. Every test gets a fresh username, which AuthMe treats as a fresh registration, which `@plugwright/auth-authme` answers. + +## `stand` — someone else owns the server + +This one connects to a server that is already running and leaves it running. Provision it once, start it by hand, then point the tests at it. + +```bash +# 1. Prepare the run directory (Paper, plugins, server.properties with RCON enabled) +./gradlew plugwrightProvisionLocal + +# 2. Start the server yourself, from where the local environment put it +cd src/test/e2e/generated/local/run && ./start.sh +``` + +`generated/` is not in version control, so `start.sh` is yours to write. Anything that starts the jar with Java 21 will do — CI keeps a tracked copy at `src/test/e2e/stand-run/start.sh` and copies it in before starting the server: + +```sh +#!/usr/bin/env sh +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("stand-reset") }`. + +## Layout + +``` +src/main/java/…/ExamplePlugin.java the plugin under test +src/test/e2e/tests/*.spec.ts the suite, run against both environments +src/test/e2e/plugins/stand-reset.ts a runner plugin, stand only +src/test/e2e/dist/ compiled specs and plugins +src/test/e2e/generated/local/run/ the Paper server the local environment owns +build.gradle.kts both environment declarations +``` diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index 7ea7f43..727b7d3 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -1,18 +1,145 @@ +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" id("com.gradleup.shadow") version "9.0.0" - id("io.github.drownek.plugwright") version "2.0.4" + id("io.github.drownek.plugwright") version "3.0.0" } +// Password every bot on the local server registers with. It guards a server that lives for +// 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") +val mcVersion: String = providers.environmentVariable("MC_VERSION").getOrElse("1.21.11") +val javaVersion: Int = providers.environmentVariable("JAVA_VERSION").map { it.toInt() }.getOrElse(21) + plugwright { - minecraftVersion.set("26.1.2") - acceptEula.set(true) testsDir.set(file("src/test/e2e")) - downloadPlugins { - url("https://hangarcdn.papermc.io/plugins/HelpChat/PlaceholderAPI/versions/2.11.6/PAPER/PlaceholderAPI-2.11.6.jar") - } downloadNode.set(System.getenv("CI") != "true") + primaryEnvironment.set("local") + + environments { + // Paper downloaded, patched, started and killed by plugwright itself. + create("local", LocalMode) { + minecraftVersion.set(mcVersion) + acceptEula.set(true) + // No runDir: the server goes to src/test/e2e/generated/local/run, which is where + // the layout puts what an environment generates. + + // 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: + # On, so a bot that reconnects within the session timeout gets + # resumed silently instead of prompted — the case the authme + # plugin's sessionResumedPattern is built to detect. + enabled: true + registration: + dialog: + preJoin: + 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 + // src/test/e2e/generated/local/run, still up when the tests connect, still up after + // they finish (the local environment left it there). Out of the + // default matrix because it needs that server to be running. + create("stand", ExternalMode) { + host.set("localhost") + port.set(25565) + minecraftVersion.set(mcVersion) + 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") + // src/test/e2e/plugins/stand-reset.ts, by the name of the file. + local("stand-reset") + } + + // Matched against test names. What is left out here is what no command puts back: + // an arena slot that is filled once and stays filled, and a first join, which only + // happens on an account the server has never seen. Op, inventory, balance and kit + // cooldowns are reset per test by the stand-reset plugin instead. + excludeTests.set(listOf( + "arena", "first join" + )) + } + } } group = "me.drownek" @@ -82,6 +209,6 @@ tasks.withType { java { toolchain { - languageVersion.set(JavaLanguageVersion.of(26)) + languageVersion.set(JavaLanguageVersion.of(javaVersion)) } } diff --git a/example_plugin/settings.gradle.kts b/example_plugin/settings.gradle.kts index 04498ec..527534c 100644 --- a/example_plugin/settings.gradle.kts +++ b/example_plugin/settings.gradle.kts @@ -5,10 +5,6 @@ pluginManagement { } } -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" -} - includeBuild("../gradle-plugin") rootProject.name = "example-plugin" diff --git a/example_plugin/src/main/java/me/drownek/example/ExamplePlugin.java b/example_plugin/src/main/java/me/drownek/example/ExamplePlugin.java index 1707560..27e9d2a 100644 --- a/example_plugin/src/main/java/me/drownek/example/ExamplePlugin.java +++ b/example_plugin/src/main/java/me/drownek/example/ExamplePlugin.java @@ -107,6 +107,12 @@ public boolean onCommand(CommandSender sender, Command command, String label, St String target = args[1]; int amount = Integer.parseInt(args[2]); balances.put(target, balances.getOrDefault(target, 1000) + amount); + } else if (args.length >= 3 && args[0].equalsIgnoreCase("set")) { + // What a stand needs to put an account back where it started: give only ever + // adds, so a balance spent by one test would stay spent for the next one. + String target = args[1]; + balances.put(target, Integer.parseInt(args[2])); + sender.sendMessage("Set balance of " + target + " to $" + args[2]); } return true; } @@ -175,6 +181,20 @@ public boolean onCommand(CommandSender sender, Command command, String label, St p.getInventory().addItem(new ItemStack(Material.BREAD)); } } + } else if (args[0].equalsIgnoreCase("reset") && args.length >= 2) { + // Admin-only, and only meaningful from a console: it exists so a stand can + // hand the next test an account whose kit is claimable again. + if (!sender.isOp()) { + sender.sendMessage("no permission"); + return true; + } + Player target = Bukkit.getPlayerExact(args[1]); + if (target == null) { + sender.sendMessage("Player not found: " + args[1]); + } else { + lastKitUse.remove(target.getUniqueId()); + sender.sendMessage("Kit cooldown reset for " + target.getName()); + } } else if (args[0].equalsIgnoreCase("vip")) { if (!sender.isOp() && !sender.hasPermission("kit.vip")) { sender.sendMessage("no permission"); diff --git a/example_plugin/src/test/e2e/.gitignore b/example_plugin/src/test/e2e/.gitignore new file mode 100644 index 0000000..c2ac073 --- /dev/null +++ b/example_plugin/src/test/e2e/.gitignore @@ -0,0 +1,8 @@ +# Installed by plugwrightCompileTests +node_modules/ +# Compiled specs and plugins +dist/ +# Whatever the environments write while they run: servers, worlds, logs +generated/ +# Generated from the npm { } block; may hold registry credentials +.npmrc diff --git a/example_plugin/src/test/e2e/kits.spec.ts b/example_plugin/src/test/e2e/kits.spec.ts deleted file mode 100644 index 9874194..0000000 --- a/example_plugin/src/test/e2e/kits.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test, expect } from '@drownek/plugwright'; - -test('starter kit gives items', async ({ player }) => { - player.chat('/kit starter'); - - await expect(player).toHaveReceivedMessage('Received starter kit'); - await expect(player).toContainItem('diamond_sword'); - await expect(player).toContainItem('bread'); -}); - -test('kit has cooldown', async ({ player }) => { - player.chat('/kit starter'); - player.chat('/kit starter'); - await expect(player).toHaveReceivedMessage('cooldown'); -}); - -test('VIP kit requires permission', async ({ player }) => { - player.chat('/kit vip'); - await expect(player).toHaveReceivedMessage('no permission'); -}); diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index 2c219bf..20ec3c9 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -5,7 +5,8 @@ "packages": { "": { "dependencies": { - "@drownek/plugwright": "file:../../../../runner-package" + "@plugwright/auth-authme": "file:../../../../auth-authme-package", + "@plugwright/runner": "file:../../../../runner-package" }, "devDependencies": { "@types/node": "^22.10.5", @@ -13,16 +14,37 @@ "typescript": "^5.7.3" } }, + "../../../../auth-authme-package": { + "name": "@plugwright/auth-authme", + "version": "3.0.0", + "license": "MIT", + "devDependencies": { + "@plugwright/runner": "file:../runner-package", + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@plugwright/runner": ">=3.0.0-dev.0" + } + }, "../../../../runner-package": { - "name": "@drownek/plugwright", - "version": "2.0.4", + "name": "@plugwright/runner", + "version": "3.0.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", - "mineflayer": "^4.38.0", + "mineflayer": "^4.39.0", "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" }, + "bin": { + "plugwright": "dist/cli.js" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.5", @@ -34,7 +56,11 @@ "node": ">=16.0.0" } }, - "node_modules/@drownek/plugwright": { + "node_modules/@plugwright/auth-authme": { + "resolved": "../../../../auth-authme-package", + "link": true + }, + "node_modules/@plugwright/runner": { "resolved": "../../../../runner-package", "link": true }, diff --git a/example_plugin/src/test/e2e/package.json b/example_plugin/src/test/e2e/package.json index fd63e49..c921806 100644 --- a/example_plugin/src/test/e2e/package.json +++ b/example_plugin/src/test/e2e/package.json @@ -4,7 +4,8 @@ "build": "rimraf dist && tsc" }, "dependencies": { - "@drownek/plugwright": "file:../../../../runner-package" + "@plugwright/runner": "file:../../../../runner-package", + "@plugwright/auth-authme": "file:../../../../auth-authme-package" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/example_plugin/src/test/e2e/plugins/stand-reset.ts b/example_plugin/src/test/e2e/plugins/stand-reset.ts new file mode 100644 index 0000000..7bb8103 --- /dev/null +++ b/example_plugin/src/test/e2e/plugins/stand-reset.ts @@ -0,0 +1,33 @@ +import { definePlugin, expect } from '@plugwright/runner'; + +/** What a fresh account starts with, per ExamplePlugin's own default. */ +const STARTING_BALANCE = 1000; + +/** + * Undoes what one test leaves on a leased account before the next test gets it. + * + * The local environment never needs this: it hands every test a brand new username on a + * server it just created. An external stand has neither — the same four accounts come back + * around all run, still opped, still holding whatever the last test gave them. + * + * Everything reset here is state the plugin under test owns, which is why this lives in the + * example project rather than in the runner: only the suite knows what "back to the start" + * means for the plugin it tests, and what commands say it. + * + * Loaded through `plugins { local(...) }` in build.gradle.kts, for the "stand" environment + * only. + */ +export default definePlugin({ + name: 'stand-reset', + + async beforeEach({ player, server }) { + await player.deOp(); + await player.clearInventory(); + + const ecoOutput = await server.execute(`eco set ${player.username} ${STARTING_BALANCE}`); + expect(ecoOutput).toContain(`Set balance of ${player.username} to $${STARTING_BALANCE}`); + + const kitOutput = await server.execute(`kit reset ${player.username}`); + expect(kitOutput).toContain(`Kit cooldown reset for ${player.username}`); + }, +}); diff --git a/example_plugin/src/test/e2e/stand-run/start.sh b/example_plugin/src/test/e2e/stand-run/start.sh new file mode 100755 index 0000000..481c160 --- /dev/null +++ b/example_plugin/src/test/e2e/stand-run/start.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +# Tracked copy of the launcher example_plugin/README.md tells you to write yourself for a local +# `stand` run. CI copies this into generated/local/run/ (see ci.yml) since that directory is +# gitignored and can't hold a script of its own. Keep this in sync with the README snippet. +set -e + +cd "$(dirname "$0")" + +JAVA_BIN="${JAVA_BIN:-java}" +JVM_ARGS="${JVM_ARGS:--Xmx2G}" + +exec "$JAVA_BIN" $JVM_ARGS -Dcom.mojang.eula.agree=true -jar server.jar --nogui diff --git a/example_plugin/src/test/e2e/tests/auth-resume.spec.ts b/example_plugin/src/test/e2e/tests/auth-resume.spec.ts new file mode 100644 index 0000000..423353e --- /dev/null +++ b/example_plugin/src/test/e2e/tests/auth-resume.spec.ts @@ -0,0 +1,15 @@ +/** + * Reproduces a bot reconnect while AuthMe still considers it logged in — no login/register + * prompt arrives at all. Covers the fix: the authme plugin recognizes AuthMe's own + * session-resume message and stops there, instead of guessing and sending a command blind. + */ + +import { expect, test } from '@plugwright/runner'; + +test('rejoin resumes the session without a prompt', async ({ player }) => { + // onPlayerCreate has already run and resolved by the time rejoin() returns, so the + // resume message is in the buffer already — no extra wait needed. + await player.rejoin(); + + await expect(player).toHaveReceivedMessage(/session reconnection/i); +}); diff --git a/example_plugin/src/test/e2e/commands.spec.ts b/example_plugin/src/test/e2e/tests/commands.spec.ts similarity index 92% rename from example_plugin/src/test/e2e/commands.spec.ts rename to example_plugin/src/test/e2e/tests/commands.spec.ts index 676e22b..92edc73 100644 --- a/example_plugin/src/test/e2e/commands.spec.ts +++ b/example_plugin/src/test/e2e/tests/commands.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('help command shows available commands', async ({ player }) => { player.chat('/help'); diff --git a/example_plugin/src/test/e2e/tests/concurrency.spec.ts b/example_plugin/src/test/e2e/tests/concurrency.spec.ts new file mode 100644 index 0000000..3ec86a2 --- /dev/null +++ b/example_plugin/src/test/e2e/tests/concurrency.spec.ts @@ -0,0 +1,42 @@ +/** + * `concurrency` fans a test (or a `describe.serial` block) out into N independent bots running + * at once — the shape a race between real players needs. These also stand in as regression + * coverage for the two bugs that concurrency exposed in the previously-sequential-only runner: + * - `session.consoleLog` used to be wiped by `clear()` at the start of every test; N bots + * writing into it at once would have raced. Each instance now reads from its own + * `ServerWrapper.startIndex` cursor instead, so its own marker is never missed no matter what + * the other instances are doing to the same shared log. + * - `createBotScope.close()` used to disconnect every bot in the session, not just its own — + * the first instance to finish would have kicked every other still-running instance's bot. + */ + +import { describe, expect, test } from '@plugwright/runner'; + +test( + 'concurrent bots each see their own marker and stay connected', + { concurrency: 3, requires: { consoleOutput: 'full' } }, + async ({ player, server }) => { + const marker = `concurrency-marker-${player.username}`; + player.chat(marker); + await expect(server).toHaveReceivedMessage(marker, { timeout: 10000 }); + + // Still connected: an earlier-finishing sibling instance's teardown must not have + // disconnected this one. + await player.teleport(50, 100, 50); + await expect(player).toBeNear(50, 100, 50, { tolerance: 2, timeout: 10000 }); + } +); + +describe.serial('concurrent kit lifecycle', { concurrency: 2 }, () => { + test('claims the starter kit', async ({ player }) => { + player.chat('/kit starter'); + + await expect(player).toHaveReceivedMessage('Received starter kit'); + await expect(player).toContainItem('diamond_sword'); + }); + + test('is on cooldown right after', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('cooldown'); + }); +}); diff --git a/example_plugin/src/test/e2e/describe.spec.ts b/example_plugin/src/test/e2e/tests/describe.spec.ts similarity index 99% rename from example_plugin/src/test/e2e/describe.spec.ts rename to example_plugin/src/test/e2e/tests/describe.spec.ts index 4801aea..34569f9 100644 --- a/example_plugin/src/test/e2e/describe.spec.ts +++ b/example_plugin/src/test/e2e/tests/describe.spec.ts @@ -2,7 +2,7 @@ * This test is mainly related to core runner package to check if everything executes in right order */ -import { afterEach, beforeEach, describe, test, expect } from "@drownek/plugwright"; +import { afterEach, beforeEach, describe, test, expect } from "@plugwright/runner"; const executionLog: string[] = []; diff --git a/example_plugin/src/test/e2e/economy.spec.ts b/example_plugin/src/test/e2e/tests/economy.spec.ts similarity index 72% rename from example_plugin/src/test/e2e/economy.spec.ts rename to example_plugin/src/test/e2e/tests/economy.spec.ts index 9ee7097..67ac390 100644 --- a/example_plugin/src/test/e2e/economy.spec.ts +++ b/example_plugin/src/test/e2e/tests/economy.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('player starts with default balance', async ({ player }) => { player.chat('/balance'); @@ -6,8 +6,8 @@ test('player starts with default balance', async ({ player }) => { }); test('player can send money', async ({ player, server }) => { - server.execute(`eco give ${player.username} 500`); - player.chat('/pay Test_xx 100'); + await server.execute(`eco give ${player.username} 500`); + player.chat('/pay pw_dummy 100'); await expect(player).toHaveReceivedMessage('Sent $100'); player.chat('/balance'); @@ -15,6 +15,6 @@ test('player can send money', async ({ player, server }) => { }); test('cannot send more money than balance', async ({ player }) => { - player.chat('/pay Test_xx 999999'); + player.chat('/pay pw_dummy 999999'); await expect(player).toHaveReceivedMessage('insufficient'); }); diff --git a/example_plugin/src/test/e2e/events.spec.ts b/example_plugin/src/test/e2e/tests/events.spec.ts similarity index 63% rename from example_plugin/src/test/e2e/events.spec.ts rename to example_plugin/src/test/e2e/tests/events.spec.ts index a91ffbc..da4110f 100644 --- a/example_plugin/src/test/e2e/events.spec.ts +++ b/example_plugin/src/test/e2e/tests/events.spec.ts @@ -1,5 +1,7 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; +// Depends on the join itself, not just on a player's current state: it only holds for an +// account the server has never seen before. test('player receives item on first join', async ({ player }) => { await expect(player).toHaveReceivedMessage('Welcome'); await expect(player).toContainItem('wooden_sword'); diff --git a/example_plugin/src/test/e2e/tests/kits.spec.ts b/example_plugin/src/test/e2e/tests/kits.spec.ts new file mode 100644 index 0000000..93b3310 --- /dev/null +++ b/example_plugin/src/test/e2e/tests/kits.spec.ts @@ -0,0 +1,32 @@ +import { describe, test, expect, sleep } from '@plugwright/runner'; + +// One player, three steps: what the second and third assert only exists because the first ran. +// Three independent bots could not express it, however they were scheduled. +describe.serial('kit lifecycle', () => { + test('claims the starter kit', async ({ player }) => { + player.chat('/kit starter'); + + await expect(player).toHaveReceivedMessage('Received starter kit'); + await expect(player).toContainItem('diamond_sword'); + await expect(player).toContainItem('bread'); + }); + + test('is on cooldown right after', async ({ player }) => { + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('cooldown'); + }); + + test('can claim again once the cooldown expires', async ({ player }) => { + await sleep(5000); + + player.chat('/kit starter'); + await expect(player).toHaveReceivedMessage('Received starter kit'); + }); +}); + +// Op bypasses permission checks in Bukkit by default, so this only proves anything against a +// player that isn't one — which every test gets, since each one connects a fresh bot. +test('VIP kit requires permission', async ({ player }) => { + player.chat('/kit vip'); + await expect(player).toHaveReceivedMessage('no permission'); +}); diff --git a/example_plugin/src/test/e2e/message-buffer.spec.ts b/example_plugin/src/test/e2e/tests/message-buffer.spec.ts similarity index 83% rename from example_plugin/src/test/e2e/message-buffer.spec.ts rename to example_plugin/src/test/e2e/tests/message-buffer.spec.ts index 5bfb4b0..2900fdb 100644 --- a/example_plugin/src/test/e2e/message-buffer.spec.ts +++ b/example_plugin/src/test/e2e/tests/message-buffer.spec.ts @@ -1,7 +1,7 @@ -import { expect, test } from '@drownek/plugwright'; +import { expect, test } from '@plugwright/runner'; test('Cross-bot message separation', async ({ player, createPlayer }) => { - const friend = await createPlayer({ username: 'FriendBot' }); + const friend = await createPlayer(); player.chat('/help'); diff --git a/example_plugin/src/test/e2e/minigame.spec.ts b/example_plugin/src/test/e2e/tests/minigame.spec.ts similarity index 81% rename from example_plugin/src/test/e2e/minigame.spec.ts rename to example_plugin/src/test/e2e/tests/minigame.spec.ts index de5c759..b9846ed 100644 --- a/example_plugin/src/test/e2e/minigame.spec.ts +++ b/example_plugin/src/test/e2e/tests/minigame.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('join arena game', async ({ player }) => { player.chat('/arena join'); @@ -11,7 +11,7 @@ test('join arena game', async ({ player }) => { test('cannot join full arena', async ({ player, server }) => { // Fill arena with fake players for (let i = 0; i < 10; i++) { - server.execute(`arena addplayer Player${i}`); + await server.execute(`arena addplayer Player${i}`); } player.chat('/arena join'); diff --git a/example_plugin/src/test/e2e/multi-bot.spec.ts b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts similarity index 72% rename from example_plugin/src/test/e2e/multi-bot.spec.ts rename to example_plugin/src/test/e2e/tests/multi-bot.spec.ts index 304ab31..70b3838 100644 --- a/example_plugin/src/test/e2e/multi-bot.spec.ts +++ b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts @@ -2,16 +2,19 @@ * We test there whether multi-player tests are working as well. */ -import { expect, test } from '@drownek/plugwright'; +import { expect, test } from '@plugwright/runner'; test('multi-bot teleportation', async ({ player, createPlayer }) => { // This executes op server command, and we wait for response from server // so when await completes, we are sure player is op. - // This can be also done with defining test as `opTest` instead of `test` or even within `beforeEach` block. + // This can be also done within a `beforeEach` block. await player.makeOp(); + await player.setGameMode('creative'); - // Spawn a second player - const friend = await createPlayer({ username: 'FriendBot' }); + // Spawn a second player. No username: the test needs a second bot, not a specific one, + // so on a stand this leases the next free pool account instead of bypassing the pool. + const friend = await createPlayer(); + await friend.setGameMode('creative'); // Teleport the friend to a specific location // We wait for friend player to actually teleport. diff --git a/example_plugin/src/test/e2e/pagination.spec.ts b/example_plugin/src/test/e2e/tests/pagination.spec.ts similarity index 74% rename from example_plugin/src/test/e2e/pagination.spec.ts rename to example_plugin/src/test/e2e/tests/pagination.spec.ts index d796719..0114a2f 100644 --- a/example_plugin/src/test/e2e/pagination.spec.ts +++ b/example_plugin/src/test/e2e/tests/pagination.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('navigate through paginated GUI', async ({ player }) => { await player.makeOp(); @@ -6,7 +6,7 @@ test('navigate through paginated GUI', async ({ player }) => { const gui = await player.gui({ title: 'Warps' }); // verify page 1 - const spawnItem = gui.locator(i => i.getDisplayName().includes('Spawn')); + const spawnItem = gui.locator(i => i.displayName.includes('Spawn')); await expect.poll(() => spawnItem.displayName()).toContain('Spawn'); // click arrow @@ -14,7 +14,7 @@ test('navigate through paginated GUI', async ({ player }) => { await nextButton.click(); // verify page 2 without reopening the GUI - const arenaItem = gui.locator(i => i.getDisplayName().includes('Arena')); + const arenaItem = gui.locator(i => i.displayName.includes('Arena')); // This expects the item to eventually appear on the same GUI instance await expect.poll(() => arenaItem.displayName()).toContain('Arena'); diff --git a/example_plugin/src/test/e2e/player-wrapper.spec.ts b/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts similarity index 96% rename from example_plugin/src/test/e2e/player-wrapper.spec.ts rename to example_plugin/src/test/e2e/tests/player-wrapper.spec.ts index b43aec9..34ccd22 100644 --- a/example_plugin/src/test/e2e/player-wrapper.spec.ts +++ b/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts @@ -2,7 +2,7 @@ * Tests for basic PlayerWrapper methods. */ -import { expect, test } from '@drownek/plugwright'; +import { expect, test } from '@plugwright/runner'; test('makeOp', async ({ player }) => { // This executes op server command, and we wait for response from server diff --git a/example_plugin/src/test/e2e/shop.spec.ts b/example_plugin/src/test/e2e/tests/shop.spec.ts similarity index 94% rename from example_plugin/src/test/e2e/shop.spec.ts rename to example_plugin/src/test/e2e/tests/shop.spec.ts index 0b0ef34..b2f8e5b 100644 --- a/example_plugin/src/test/e2e/shop.spec.ts +++ b/example_plugin/src/test/e2e/tests/shop.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('shop opens with correct items', async ({ player }) => { player.chat('/shop'); diff --git a/example_plugin/src/test/e2e/simple-ts.spec.ts b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts similarity index 67% rename from example_plugin/src/test/e2e/simple-ts.spec.ts rename to example_plugin/src/test/e2e/tests/simple-ts.spec.ts index aa59fbd..4b1fcfd 100644 --- a/example_plugin/src/test/e2e/simple-ts.spec.ts +++ b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts @@ -1,4 +1,4 @@ -import {expect, test} from '@drownek/plugwright'; +import {expect, test} from '@plugwright/runner'; test('command permission works', async ({ player }) => { player.chat('/example gui-settings'); @@ -14,7 +14,7 @@ test('admin can interact with gui', async ({ player }) => { const gui = await player.gui({ title: 'guiSettings' }); // 3. Interact: Click the item named "guiItemInfo" - await gui.locator(item => item.getDisplayName().includes('guiItemInfo')).click(); + await gui.locator(item => item.displayName.includes('guiItemInfo')).click(); // 4. Assertion: Check for the callback message await expect(player).toHaveReceivedMessage('You clicked on item'); @@ -25,7 +25,9 @@ test('help displays message', async ({ player }) => { await expect(player).toHaveReceivedMessage('Help'); }); -test('server logs command execution', async ({ server }) => { - server.execute('say hello'); +// 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 }) => { + await server.execute('say hello'); await expect(server).toHaveReceivedMessage('hello'); }); \ No newline at end of file diff --git a/example_plugin/src/test/e2e/teleport.spec.ts b/example_plugin/src/test/e2e/tests/teleport.spec.ts similarity index 89% rename from example_plugin/src/test/e2e/teleport.spec.ts rename to example_plugin/src/test/e2e/tests/teleport.spec.ts index c0cb9bd..c54ce71 100644 --- a/example_plugin/src/test/e2e/teleport.spec.ts +++ b/example_plugin/src/test/e2e/tests/teleport.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('warp command teleports player', async ({ player }) => { player.chat('/warp spawn'); @@ -19,7 +19,7 @@ test('warp GUI lists available warps', async ({ player }) => { const gui = await player.gui({ title: 'Warps' }); const spawn = gui.locator(item => - item.getDisplayName().includes('Spawn') + item.displayName.includes('Spawn') ); await expect.poll(() => spawn.displayName()).toContain('Spawn'); diff --git a/example_plugin/src/test/e2e/tsconfig.json b/example_plugin/src/test/e2e/tsconfig.json index 7c07169..68a0509 100644 --- a/example_plugin/src/test/e2e/tsconfig.json +++ b/example_plugin/src/test/e2e/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "node", + "rootDir": ".", "outDir": "./dist", "strict": true, "esModuleInterop": true, @@ -10,5 +11,6 @@ "sourceMap": true, "inlineSources": true }, - "include": ["*.spec.ts"] -} \ No newline at end of file + "include": ["tests/**/*.ts", "plugins/**/*.ts"], + "exclude": ["node_modules", "dist", "generated"] +} diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 31c0ff3..081308b 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -1,56 +1,31 @@ +// Loaded once here so every subproject resolves the same Kotlin plugin classes: applying +// `kotlin-dsl` from each subproject's own plugins block loads the Kotlin plugin several +// times over, which Gradle warns about and does not support. plugins { - `kotlin-dsl` - `maven-publish` - id("com.gradle.plugin-publish") version "1.2.1" + `kotlin-dsl` apply false } -group = "io.github.drownek" val projectVersion = file("../version.txt").readText().trim() -version = projectVersion -repositories { - mavenCentral() - gradlePluginPortal() -} - -dependencies { - implementation(gradleApi()) - implementation("com.google.code.gson:gson:2.10.1") - implementation("org.yaml:snakeyaml:2.0") - implementation("org.jetbrains.gradle.plugin.idea-ext:org.jetbrains.gradle.plugin.idea-ext.gradle.plugin:1.4.1") -} +allprojects { + group = "io.github.drownek" + version = projectVersion -gradlePlugin { - website.set("https://github.com/drownek/plugwright") - vcsUrl.set("https://github.com/drownek/plugwright.git") - plugins { - create("plugwright") { - id = "io.github.drownek.plugwright" - displayName = "Plugwright Testing Plugin" - description = "End-to-end testing framework for Paper/Spigot Minecraft plugins" - tags.set(listOf("minecraft", "paper", "spigot", "testing", "e2e")) - implementationClass = "me.drownek.plugwright.PlugwrightPlugin" - } + repositories { + mavenCentral() + // The idea-ext plugin marker plugwright-core compiles against lives here, not in Central. + gradlePluginPortal() } } -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } -} +subprojects { + apply(plugin = "org.gradle.kotlin.kotlin-dsl") -val generateVersionResource = tasks.register("generateVersionResource") { - val outFile = layout.buildDirectory.file("generated/version-resource/plugwright-version.properties") - inputs.property("version", projectVersion) - outputs.file(outFile) - doLast { - val f = outFile.get().asFile - f.parentFile.mkdirs() - f.writeText("version=$projectVersion\n") + plugins.withId("java") { + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } } } - -sourceSets.named("main") { - resources.srcDir(generateVersionResource.map { it.outputs.files.singleFile.parentFile }) -} diff --git a/gradle-plugin/plugwright-api/build.gradle.kts b/gradle-plugin/plugwright-api/build.gradle.kts new file mode 100644 index 0000000..db63032 --- /dev/null +++ b/gradle-plugin/plugwright-api/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + implementation(gradleApi()) +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ConfigNode.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ConfigNode.kt new file mode 100644 index 0000000..7dba16d --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ConfigNode.kt @@ -0,0 +1,76 @@ +package me.drownek.plugwright.api + +import java.io.Serializable + +/** + * A JSON-shaped value in the runner configuration. + * + * Modes build these instead of writing JSON directly: it keeps the api module free of a + * JSON library, and it lets core render [Secret] entries as references rather than values. + */ +sealed class ConfigValue : Serializable { + data class Str(val value: String) : ConfigValue() + data class Num(val value: Number) : ConfigValue() + data class Bool(val value: Boolean) : ConfigValue() + data class Secret(val ref: SecretRef) : ConfigValue() + data class Arr(val values: List) : ConfigValue() + data class Obj(val entries: Map) : ConfigValue() + object Null : ConfigValue() { + private fun readResolve(): Any = Null + } + + companion object { + private const val serialVersionUID: Long = 1L + } +} + +/** The object a mode serializes its spec into. */ +typealias ConfigNode = ConfigValue.Obj + +/** + * Builder handed to [PlugwrightMode.serialize]. + * + * Keys are written in insertion order so a regenerated config file stays diff-friendly. + */ +class ConfigNodeBuilder { + private val entries = LinkedHashMap() + + fun put(key: String, value: String) = apply { entries[key] = ConfigValue.Str(value) } + fun put(key: String, value: Number) = apply { entries[key] = ConfigValue.Num(value) } + fun put(key: String, value: Boolean) = apply { entries[key] = ConfigValue.Bool(value) } + fun put(key: String, value: SecretRef) = apply { entries[key] = ConfigValue.Secret(value) } + fun put(key: String, value: ConfigValue) = apply { entries[key] = value } + fun putNull(key: String) = apply { entries[key] = ConfigValue.Null } + + /** Omits the key entirely when [value] is null — absent and null mean different things downstream. */ + fun putIfPresent(key: String, value: String?) = apply { if (value != null) put(key, value) } + + fun putStrings(key: String, values: Iterable) = apply { + entries[key] = ConfigValue.Arr(values.map { ConfigValue.Str(it) }) + } + + fun obj(key: String, action: ConfigNodeBuilder.() -> Unit) = apply { + entries[key] = ConfigNodeBuilder().apply(action).build() + } + + fun array(key: String, action: ConfigArrayBuilder.() -> Unit) = apply { + entries[key] = ConfigValue.Arr(ConfigArrayBuilder().apply(action).build()) + } + + fun build(): ConfigNode = ConfigValue.Obj(LinkedHashMap(entries)) +} + +class ConfigArrayBuilder { + private val values = mutableListOf() + + fun add(value: String) = apply { values.add(ConfigValue.Str(value)) } + fun add(value: Number) = apply { values.add(ConfigValue.Num(value)) } + fun add(value: Boolean) = apply { values.add(ConfigValue.Bool(value)) } + fun add(value: ConfigValue) = apply { values.add(value) } + + fun obj(action: ConfigNodeBuilder.() -> Unit) = apply { + values.add(ConfigNodeBuilder().apply(action).build()) + } + + fun build(): List = values.toList() +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/EnvironmentSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/EnvironmentSpec.kt new file mode 100644 index 0000000..ee63eb3 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/EnvironmentSpec.kt @@ -0,0 +1,32 @@ +package me.drownek.plugwright.api + +import org.gradle.api.Named +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** + * Build-script description of one environment tests can run against. + * + * A mode subtypes this with its own fields (`host`, `runDir`, …); everything declared + * here is owned by plugwright itself and behaves the same for every mode. + */ +interface EnvironmentSpec : Named { + + /** Name used in task names and report files: `local` becomes `plugwrightTestLocal`. */ + override fun getName(): String + + /** + * Whether `plugwrightTest` includes this environment. Ignored when the per-environment + * task is invoked directly — an explicit request always runs. + */ + val includeInMatrix: Property + + /** + * Whether failures here fail the build when running the matrix. Failures are still + * reported as failures. Ignored when the per-environment task is invoked directly. + */ + val allowFailure: Property + + /** Test name substrings to skip in this environment. */ + val excludeTests: ListProperty +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/LegacyEnvironmentProperties.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/LegacyEnvironmentProperties.kt new file mode 100644 index 0000000..be54cda --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/LegacyEnvironmentProperties.kt @@ -0,0 +1,23 @@ +package me.drownek.plugwright.api + +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** + * The pre-3.0 flat properties on the `plugwright { }` extension, kept so a build with no + * `environments { }` block keeps working. + * + * A mode reads these in [PlugwrightMode.applyLegacyDefaults] to seed the environment it is + * asked to create implicitly. Modes with no legacy shape simply ignore this. + */ +interface LegacyEnvironmentProperties { + val minecraftVersion: Property + val jvmArgs: ListProperty + val acceptEula: Property + val runDir: DirectoryProperty + val pluginUrls: ListProperty + val runDirFiles: ListProperty + val cleanExcludePatterns: ListProperty + val useExternalPluginsOnly: Property +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt new file mode 100644 index 0000000..f8de7b4 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt @@ -0,0 +1,181 @@ +package me.drownek.plugwright.api + +import java.io.Serializable + +/** + * Credentials for one registry, as pointers to secrets — never the values. + * + * The values are read when the `.npmrc` is written, during task execution. Reading them + * while the build script is being configured would put them into the configuration cache. + */ +data class NpmCredentials( + val authToken: SecretRef? = null, + val username: SecretRef? = null, + val password: SecretRef? = null +) : Serializable { + + val isEmpty: Boolean get() = authToken == null && username == null && password == null + + companion object { + private const val serialVersionUID: Long = 1L + + val NONE = NpmCredentials() + } +} + +/** + * A registry npm should fetch from: the default one, or the one a single scope resolves to. + * + * @param scope npm scope including the leading `@`, e.g. `@plugwright`; null for the default registry + * @param url registry URL, e.g. `https://nexus.corp/repository/npm-private/` + */ +data class NpmRegistry( + val scope: String?, + val url: String, + val credentials: NpmCredentials = NpmCredentials.NONE +) : Serializable { + companion object { + private const val serialVersionUID: Long = 1L + } +} + +/** + * What the workspace's generated `.npmrc` should say: which registries to fetch from, how to + * authenticate against them, and any other npm option the build script sets. + * + * Built from the `npm { }` block ([NpmSpec]) and carried into the tasks that run `npm install`. + */ +data class NpmConfig( + val registries: List = emptyList(), + val options: Map = emptyMap() +) : Serializable { + + /** No `npm { }` block, or an empty one: nothing to generate, and no `.npmrc` to keep. */ + val isEmpty: Boolean get() = registries.isEmpty() && options.isEmpty() + + /** + * Configuration mistakes worth failing the build over, reported before anything runs + * `npm install` — npm answers a malformed registry line with a 404 against the public + * registry, which is a much longer way round to the same conclusion. + */ + fun problems(): List { + val problems = mutableListOf() + + registries.forEach { registry -> + val label = registry.scope?.let { "scope '$it'" } ?: "the default registry" + + if (registry.scope != null && !registry.scope.startsWith("@")) { + problems += "npm scope '${registry.scope}' must start with '@'" + } + if (!registry.url.startsWith("http://") && !registry.url.startsWith("https://")) { + problems += "registry URL for $label must start with http:// or https://, got '${registry.url}'" + } + + val credentials = registry.credentials + if (credentials.username != null && credentials.password == null) { + problems += "$label has a username but no password" + } + if (credentials.password != null && credentials.username == null) { + problems += "$label has a password but no username" + } + } + + val duplicateScopes = registries.groupBy { it.scope }.filterValues { it.size > 1 }.keys + duplicateScopes.forEach { scope -> + problems += scope?.let { "npm scope '$it' is declared more than once" } + ?: "the default npm registry is declared more than once" + } + + options.keys.filter { it.isBlank() }.forEach { _ -> + problems += "npm option keys cannot be blank" + } + + return problems + } + + companion object { + private const val serialVersionUID: Long = 1L + + val EMPTY = NpmConfig() + } +} + +/** + * Credentials for one registry, as a build-script block. + * + * Only [SecretRef]s: a literal token in a build script ends up in the configuration cache, + * in build scans, and — for anyone who forgets what a build script is — in version control. + * Use `secret.env("NPM_TOKEN")`, which is also what a CI job already has. + */ +class NpmCredentialsSpec { + private var authToken: SecretRef? = null + private var username: SecretRef? = null + private var password: SecretRef? = null + + /** Bearer token for this registry, written as `_authToken`. */ + fun authToken(ref: SecretRef) { + authToken = ref + } + + /** Basic-auth user, written as `username`; needs a [password]. */ + fun username(ref: SecretRef) { + username = ref + } + + /** Basic-auth password, written base64-encoded as `_password`; needs a [username]. */ + fun password(ref: SecretRef) { + password = ref + } + + internal fun build(): NpmCredentials = NpmCredentials(authToken, username, password) +} + +/** + * The `npm { }` block: which registries this workspace installs from. + * + * ```kotlin + * plugwright { + * npm { + * registry("https://nexus.corp/repository/npm-group/") { + * authToken(secret.env("NPM_TOKEN")) + * } + * scope("@plugwright", "https://nexus.corp/repository/npm-private/") { + * username(secret.env("NPM_USER")) + * password(secret.env("NPM_PASS")) + * } + * option("strict-ssl", "false") + * } + * } + * ``` + * + * The block becomes a `.npmrc` in the workspace root, written just before each `npm install` + * the build runs. It covers the whole workspace rather than one environment: there is one + * `node_modules` and one install for the entire matrix. + */ +class NpmSpec { + private val registries = mutableListOf() + private val options = linkedMapOf() + + /** The registry every package comes from unless a scope says otherwise. */ + @JvmOverloads + fun registry(url: String, action: NpmCredentialsSpec.() -> Unit = {}) { + registries += NpmRegistry(null, url, NpmCredentialsSpec().apply(action).build()) + } + + /** The registry packages under [scope] (`@plugwright`, leading `@` included) come from. */ + @JvmOverloads + fun scope(scope: String, url: String, action: NpmCredentialsSpec.() -> Unit = {}) { + registries += NpmRegistry(scope, url, NpmCredentialsSpec().apply(action).build()) + } + + /** + * Any other npm setting, written verbatim: `option("strict-ssl", "false")`, + * `option("cafile", "/etc/ssl/corp-ca.pem")`. + */ + fun option(key: String, value: String) { + options[key] = value + } + + /** Snapshot of the block, for the tasks that write the `.npmrc`. */ + fun toConfig(): NpmConfig = NpmConfig(registries.toList(), options.toMap()) +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt new file mode 100644 index 0000000..a3a40a3 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt @@ -0,0 +1,28 @@ +package me.drownek.plugwright.api + +import java.io.Serializable + +/** + * One runner plugin to load: an npm package name or a resolvable local file path, plus its + * options and whether its declared `tests` are inherited into the run. + * + * Lands in the top-level `plugins` array of the runner config — a sibling of `environment`, + * not part of `environment.config` — via [TaskRegistrationContext.pluginConfigs]. + */ +data class PluginRef @JvmOverloads constructor( + val specifier: String, + val options: Map = emptyMap(), + val inheritTests: Boolean = true +) : Serializable { + companion object { + private const val serialVersionUID: Long = 1L + + /** + * Marks a [specifier] that names a plugin in the workspace's `plugins` directory + * rather than an npm package or a path — what `plugins { local("stand-reset") }` + * produces. The build resolves it against [PlugwrightLayout.compiledPluginsDir] + * before the config is written, so the runner only ever sees a real path. + */ + const val WORKSPACE_SCHEME: String = "plugwright-workspace:" + } +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt new file mode 100644 index 0000000..37f86b1 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt @@ -0,0 +1,50 @@ +package me.drownek.plugwright.api + +import java.io.File + +/** Per-plugin options and inheritance flag, configured in the trailing lambda of [PluginsSpec.npm] + * / [PluginsSpec.local]. */ +class PluginRefSpec { + /** `options["loginCommand"] = "/login"` or `options.put("loginCommand", "/login")`. */ + val options: MutableMap = linkedMapOf() + + /** Set false to load the plugin's hooks/matchers without pulling in its `tests`. */ + var inheritTests: Boolean = true +} + +/** + * `plugins { npm("@plugwright/auth-authme") { ... }; local(file("...")) { ... } }`. + * + * Declares runner plugins to load for 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) + entries.add(PluginRef(specifier, spec.options, spec.inheritTests)) + } + + /** + * A plugin written in the workspace's `plugins` directory, named without its extension: + * `local("stand-reset")` loads what `plugins/stand-reset.ts` compiles into. + */ + fun local(name: String, action: PluginRefSpec.() -> Unit = {}) { + val spec = PluginRefSpec().apply(action) + entries.add(PluginRef(PluginRef.WORKSPACE_SCHEME + name, spec.options, spec.inheritTests)) + } + + /** A plugin at a path of your own choosing. Prefer [local] with a name: it follows the + * workspace layout, so the path stops being something the build script has to know. */ + fun local(file: File, action: PluginRefSpec.() -> Unit = {}) { + val spec = PluginRefSpec().apply(action) + entries.add(PluginRef(file.absolutePath, spec.options, spec.inheritTests)) + } +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightApi.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightApi.kt new file mode 100644 index 0000000..7acdb9c --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightApi.kt @@ -0,0 +1,12 @@ +package me.drownek.plugwright.api + +/** + * Version of the contract in this module. + * + * A mode declares the version it was compiled against via [PlugwrightMode.apiVersion]. + * Plugwright refuses to load a mode whose version it does not understand instead of + * failing later with a [NoSuchMethodError] from a mismatched classpath. + */ +object PlugwrightApi { + const val VERSION: Int = 1 +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.kt new file mode 100644 index 0000000..625eb12 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.kt @@ -0,0 +1,78 @@ +package me.drownek.plugwright.api + +import java.io.File + +/** + * The directories inside a plugwright workspace — the directory `plugwright.testsDir` points + * at, `src/test/e2e` by default. + * + * ``` + * src/test/e2e/ + * tests/ spec sources + * plugins/ runner plugin sources + * dist/ compiled output, mirroring the two directories above + * generated// whatever an environment writes while it runs + * ``` + * + * Everything under `dist`, `generated` and `node_modules` is disposable: the build recreates + * it, and `plugwrightInit` writes a `.gitignore` that keeps all three out of version control. + * + * A mode reads the layout through [TaskRegistrationContext.layout], and gets a chance to seed + * spec defaults from it in [PlugwrightMode.applyLayoutDefaults]. + */ +interface PlugwrightLayout { + + /** Root of the npm project: the value of `plugwright.testsDir`. */ + val workspaceDir: File + + /** Spec sources, `/tests`. */ + val testsDir: File + + /** Runner plugin sources, `/plugins`. */ + val pluginsDir: File + + /** Compiled output root, `/dist`. */ + val compiledDir: File + + /** Compiled specs, `/dist/tests`. */ + val compiledTestsDir: File + + /** Compiled runner plugins, `/dist/plugins`. */ + val compiledPluginsDir: File + + /** Root of the per-environment scratch space, `/generated`. */ + val generatedRootDir: File + + /** Where environment [environmentName] writes what it generates: `/generated/`. + * The local mode puts its server here; nothing else may write outside its own directory. */ + fun generatedDir(environmentName: String): File + + /** + * The directory the runner scans for `.spec.js`: the compiled one once it exists, and the + * sources otherwise — a workspace of plain JavaScript specs has nothing to compile. + */ + fun runnableTestsDir(): File + + companion object { + const val TESTS_DIR_NAME = "tests" + const val PLUGINS_DIR_NAME = "plugins" + const val COMPILED_DIR_NAME = "dist" + const val GENERATED_DIR_NAME = "generated" + + /** The layout of the workspace rooted at [workspaceDir]. */ + fun of(workspaceDir: File): PlugwrightLayout = DefaultPlugwrightLayout(workspaceDir) + } +} + +private class DefaultPlugwrightLayout(override val workspaceDir: File) : PlugwrightLayout { + override val testsDir: File get() = File(workspaceDir, PlugwrightLayout.TESTS_DIR_NAME) + override val pluginsDir: File get() = File(workspaceDir, PlugwrightLayout.PLUGINS_DIR_NAME) + override val compiledDir: File get() = File(workspaceDir, PlugwrightLayout.COMPILED_DIR_NAME) + override val compiledTestsDir: File get() = File(compiledDir, PlugwrightLayout.TESTS_DIR_NAME) + override val compiledPluginsDir: File get() = File(compiledDir, PlugwrightLayout.PLUGINS_DIR_NAME) + override val generatedRootDir: File get() = File(workspaceDir, PlugwrightLayout.GENERATED_DIR_NAME) + + override fun generatedDir(environmentName: String): File = File(generatedRootDir, environmentName) + + override fun runnableTestsDir(): File = if (compiledTestsDir.exists()) compiledTestsDir else testsDir +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt new file mode 100644 index 0000000..9bfc18c --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt @@ -0,0 +1,55 @@ +package me.drownek.plugwright.api + +import org.gradle.api.model.ObjectFactory + +/** + * How one kind of environment is declared in the build script and prepared for a test run. + * + * Implementations are stateless singletons: everything configurable lives in the spec, and + * everything executed lives in the tasks registered by [registerTasks]. + */ +interface PlugwrightMode { + + /** Stable id, written into the runner config: `local`, `external`, `velocity`. */ + val id: String + + /** Spec type this mode creates; also the key the environment container registers a factory under. */ + val specType: Class + + /** Contract version this mode was compiled against. See [PlugwrightApi.VERSION]. */ + val apiVersion: Int get() = PlugwrightApi.VERSION + + /** Creates an empty spec. Use [ObjectFactory.newInstance] so Gradle manages the properties. */ + fun createSpec(name: String, objects: ObjectFactory): S + + /** npm packages the runner needs for this configuration. */ + fun runnerPackages(spec: S): List = emptyList() + + /** Configuration-time checks. Report problems through [ValidationContext], do not throw. */ + fun validate(spec: S, ctx: ValidationContext) {} + + /** + * Seeds [spec] from the deprecated flat extension properties, for a build with no + * `environments { }` block. No-op for modes with no legacy shape to migrate from. + */ + fun applyLegacyDefaults(spec: S, legacy: LegacyEnvironmentProperties) {} + + /** + * Fills in whatever [spec] leaves unset that follows from the workspace layout, before + * validation and [registerTasks] run. The local mode places its server this way, so a + * build script that never mentions `runDir` still gets one, under + * `/generated/`. + * + * Only set properties the build script did not: an explicit value always wins. + */ + fun applyLayoutDefaults(spec: S, layout: PlugwrightLayout) {} + + /** + * Writes the mode-specific part of the runner config, landing under + * `environment.config`. Runs at configuration time, so secrets stay [SecretRef]s. + */ + fun serialize(spec: S, node: ConfigNodeBuilder) + + /** Registers the tasks for this environment: provisioning, mode-specific extras. */ + fun registerTasks(spec: S, ctx: TaskRegistrationContext) {} +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunDirFile.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunDirFile.kt new file mode 100644 index 0000000..3137ad0 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunDirFile.kt @@ -0,0 +1,18 @@ +package me.drownek.plugwright.api + +import java.io.File +import java.io.Serializable + +/** + * One file to write into an environment's run directory before the server starts. + * Exactly one of [content] or [sourceFile] is non-null. + */ +data class RunDirFile( + val path: String, + val content: String?, + val sourceFile: File? +) : Serializable { + companion object { + private const val serialVersionUID: Long = 1L + } +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunnerPackageRef.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunnerPackageRef.kt new file mode 100644 index 0000000..4ea170d --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/RunnerPackageRef.kt @@ -0,0 +1,24 @@ +package me.drownek.plugwright.api + +import java.io.Serializable + +/** + * An npm package the runner needs for a given environment, plus the export that + * provides its [Environment factory][PlugwrightMode]. + * + * The set of packages depends on the configuration, not only on the mode: an external + * environment pulls the RCON console package only when the build script declares one. + * + * @param name npm package name, e.g. `@plugwright/runner` + * @param version npm version range; null means "whatever the test project already has" + * @param export named export of the package holding the factory; null means the default export + */ +data class RunnerPackageRef @JvmOverloads constructor( + val name: String, + val version: String? = null, + val export: String? = null +) : Serializable { + companion object { + private const val serialVersionUID: Long = 1L + } +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt new file mode 100644 index 0000000..c3fbd0e --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/SecretRef.kt @@ -0,0 +1,44 @@ +package me.drownek.plugwright.api + +import org.gradle.api.Project +import java.io.File +import java.io.Serializable + +/** + * A pointer to a secret value, never the value itself. + * + * Secrets are resolved by the runner at execution time. Resolving them during the + * configuration phase would put passwords into the configuration cache and into + * build artifacts. + */ +sealed class SecretRef : Serializable { + + /** Read the secret from the environment variable [name]. */ + data class FromEnv(val name: String) : SecretRef() + + /** Read the secret from the first line of [path]. */ + data class FromFile(val path: String) : SecretRef() { + constructor(file: File) : this(file.absolutePath) + } + + /** Read the secret from the system property [name]. */ + data class FromSystemProperty(val name: String) : SecretRef() + + companion object { + private const val serialVersionUID: Long = 1L + } +} + +/** + * Factory for [SecretRef] values, exposed to build scripts as `secret`. + */ +object Secrets { + fun env(name: String): SecretRef = SecretRef.FromEnv(name) + fun file(path: String): SecretRef = SecretRef.FromFile(path) + fun file(file: File): SecretRef = SecretRef.FromFile(file) + fun systemProperty(name: String): SecretRef = SecretRef.FromSystemProperty(name) +} + +/** `secret.env("X")` / `secret.file(path)` in a build script, anywhere the implicit `Project` + * receiver is reachable — including nested `environments { create(...) { ... } }` blocks. */ +val Project.secret: Secrets get() = Secrets diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt new file mode 100644 index 0000000..8c6ffb9 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt @@ -0,0 +1,74 @@ +package me.drownek.plugwright.api + +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import java.io.File +import kotlin.reflect.KClass + +/** + * Handed to [PlugwrightMode.registerTasks] so a mode can add its own tasks for one environment. + * + * Preparation work belongs in a task, not in a callback executed inside someone else's + * `@TaskAction`: a task keeps the configuration cache intact, gets up-to-date checks, and + * can be invoked by hand. + */ +interface TaskRegistrationContext { + + val project: Project + + /** Name of the environment these tasks belong to. */ + val environmentName: String + + /** + * The jar of the plugin under test, from `shadowJar` / `reobfJar` / `jar`. + * + * Absent when the build asked for external plugins only, or when no jar-producing + * task exists. Modes that do not install the plugin themselves ignore it. + */ + val projectPluginJar: Provider + + /** Root of the npm project (`plugwright.testsDir`), same value `plugwrightTest` + * uses as its working directory. For the directories inside it, use [layout]. */ + val testsDir: Provider + + /** Directory conventions of the workspace, including where this environment may write + * what it generates: `layout.generatedDir(environmentName)`. */ + val layout: PlugwrightLayout + + /** + * Registers a task named `plugwright`, e.g. `plugwrightProvisionLocal` + * for `register("Provision", …)` in the `local` environment. + */ + fun register(suffix: String, type: Class, action: T.() -> Unit): TaskProvider + + /** + * Marks a task as the environment's preparation step. `plugwrightTest` and + * the matrix run it before the tests. + */ + fun prepareTask(task: TaskProvider) + + /** + * Overrides the mode-specific part of this environment's runner config ([ConfigNode], + * landing under `environment.config`), computed lazily at task execution time. + * + * Use this instead of [PlugwrightMode.serialize] when the value needs something only a + * task can reach — a Gradle service such as the Java toolchain, for instance. + */ + fun environmentConfig(node: Provider) + + /** + * Declares the runner plugins this environment should load — the top-level `plugins` + * array in the config, sibling to `environment.config` rather than part of it. Empty by + * default; most modes have none. + */ + fun pluginConfigs(refs: Provider>) +} + +/** Kotlin-friendly overload of [TaskRegistrationContext.register]. */ +fun TaskRegistrationContext.register( + suffix: String, + type: KClass, + action: T.() -> Unit +): TaskProvider = register(suffix, type.java, action) diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ValidationContext.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ValidationContext.kt new file mode 100644 index 0000000..b60f62c --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/ValidationContext.kt @@ -0,0 +1,19 @@ +package me.drownek.plugwright.api + +/** + * Collects configuration-time problems found by [PlugwrightMode.validate]. + * + * Modes report through this instead of throwing so one build failure can list every + * problem in every environment at once. + */ +interface ValidationContext { + + /** Environment being validated. */ + val environmentName: String + + /** Records a problem that must fail the build. */ + fun error(message: String) + + /** Records a problem worth printing that does not fail the build. */ + fun warn(message: String) +} diff --git a/gradle-plugin/plugwright-bundle/build.gradle.kts b/gradle-plugin/plugwright-bundle/build.gradle.kts new file mode 100644 index 0000000..1db1047 --- /dev/null +++ b/gradle-plugin/plugwright-bundle/build.gradle.kts @@ -0,0 +1,179 @@ +plugins { + `maven-publish` + id("com.gradle.plugin-publish") version "1.2.1" +} + +// This module's jar physically embeds the other modules' classes (see below), so their jar +// tasks must be configured before this script reaches that point. +evaluationDependsOn(":plugwright-api") +evaluationDependsOn(":plugwright-core") +evaluationDependsOn(":plugwright-local") +evaluationDependsOn(":plugwright-external") + +dependencies { + implementation(gradleApi()) + implementation("com.google.code.gson:gson:2.10.1") + implementation("org.yaml:snakeyaml:2.0") + implementation("org.jetbrains.gradle.plugin.idea-ext:org.jetbrains.gradle.plugin.idea-ext.gradle.plugin:1.4.1") + + // Compile-time only, all four: none of them is published under its own coordinates, and + // their classes reach the runtime classpath through this module's merged jar below. + // + // As `implementation` they would instead be written into the published POM as runtime + // dependencies on io.github.drownek:plugwright-core, -local and -external — coordinates + // that exist in no repository, so every consumer resolving this plugin from a maven + // repository failed with "Could not find io.github.drownek:plugwright-core". + // + // gson, snakeyaml and idea-ext above stay `implementation` deliberately: those are real + // artifacts that are not merged into the jar, so the POM does have to ask for them. + compileOnly(project(":plugwright-api")) + compileOnly(project(":plugwright-core")) + compileOnly(project(":plugwright-local")) + compileOnly(project(":plugwright-external")) +} + +// This is the module published under the plugin id, so its jar must carry the api, core and +// mode classes too — none of them are published under their own coordinates. +val apiJar = project(":plugwright-api").tasks.named("jar", Jar::class) +val coreJar = project(":plugwright-core").tasks.named("jar", Jar::class) +val localJar = project(":plugwright-local").tasks.named("jar", Jar::class) +val externalJar = project(":plugwright-external").tasks.named("jar", Jar::class) + +tasks.named("jar") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(apiJar.map { zipTree(it.archiveFile) }) + from(coreJar.map { zipTree(it.archiveFile) }) + from(localJar.map { zipTree(it.archiveFile) }) + from(externalJar.map { zipTree(it.archiveFile) }) +} + +gradlePlugin { + website.set("https://github.com/drownek/plugwright") + vcsUrl.set("https://github.com/drownek/plugwright.git") + plugins { + create("plugwright") { + id = "io.github.drownek.plugwright" + displayName = "Plugwright Testing Plugin" + description = "End-to-end testing framework for Paper/Spigot Minecraft plugins" + tags.set(listOf("minecraft", "paper", "spigot", "testing", "e2e")) + implementationClass = "me.drownek.plugwright.PlugwrightPlugin" + } + } +} + +// An organisation that cannot reach the Gradle Plugin Portal needs the plugin somewhere it +// can reach, so the plugin is publishable to an arbitrary maven repository as well. +// +// Nothing about that repository is written down here: a URL in this file would tie an +// otherwise public build to one company's infrastructure, and a password in it would be a +// password in version control. All three come from properties or the environment, and the +// repository only exists when the URL does — a build without them publishes exactly where it +// did before. +val publishUrl = providers.gradleProperty("plugwright.publish.url") + .orElse(providers.environmentVariable("PLUGWRIGHT_PUBLISH_URL")) +val publishUser = providers.gradleProperty("plugwright.publish.user") + .orElse(providers.environmentVariable("PLUGWRIGHT_PUBLISH_USER")) +val publishPassword = providers.gradleProperty("plugwright.publish.password") + .orElse(providers.environmentVariable("PLUGWRIGHT_PUBLISH_PASSWORD")) + +publishing { + repositories { + if (publishUrl.isPresent) { + maven { + name = "private" + url = uri(publishUrl.get()) + + // A repository that lets anyone write is its own kind of problem, but it is + // not this build's to solve: an anonymous deploy is still a valid one. + if (publishUser.isPresent && publishPassword.isPresent) { + credentials { + username = publishUser.get() + password = publishPassword.get() + } + } + } + } + } +} + +// Either destination can be switched off, and neither being available is a normal state +// rather than a failure. +// +// The public one is on by default: it is where a release goes. The private one is off until a +// repository is named, because most builds have none — a build that never opted in has nothing +// to publish privately, and treating that as an error would make `publishToPrivateRepository` +// fail on every checkout that has not been set up. +// +// The explicit properties exist for the case the implicit rule gets wrong: a fork that +// publishes only inside a company wants the public one off, and a machine that has the private +// URL in its environment for resolving may still want to publish nowhere. +val publicEnabled = providers.gradleProperty("plugwright.publish.public.enabled") + .orElse(providers.environmentVariable("PLUGWRIGHT_PUBLISH_PUBLIC_ENABLED")) + .map { it.toBoolean() } + .orElse(true) +val privateEnabled = providers.gradleProperty("plugwright.publish.private.enabled") + .orElse(providers.environmentVariable("PLUGWRIGHT_PUBLISH_PRIVATE_ENABLED")) + .map { it.toBoolean() } + .orElse(publishUrl.map { true }) + .orElse(false) + +// The two destinations under one pair of names, so a release reads the same whichever it is +// going to. Both wrap tasks that already exist — `publishPlugins` from the plugin-publish +// plugin, and the publication task Gradle derives from the repository above. +// +// The switch goes on the wrapped task, not on the wrapper: `onlyIf` skips the task it is set +// on and nothing it depends on, so a wrapper that skipped itself would still have run the +// publish underneath it. +tasks.named("publishPlugins") { + onlyIf { publicEnabled.get() } +} + +tasks.register("publishToPublicRepository") { + group = "publishing" + description = "Publishes the plugin to the Gradle Plugin Portal, unless it is switched off." + dependsOn(tasks.named("publishPlugins")) + + doLast { + if (!publicEnabled.get()) { + logger.lifecycle("Public publishing is off (plugwright.publish.public.enabled=false).") + } + } +} + +if (publishUrl.isPresent) { + tasks.named("publishAllPublicationsToPrivateRepository") { + onlyIf { privateEnabled.get() } + } +} + +tasks.register("publishToPrivateRepository") { + group = "publishing" + description = "Publishes the plugin to the maven repository named by plugwright.publish.url, when there is one." + + if (publishUrl.isPresent) { + dependsOn(tasks.named("publishAllPublicationsToPrivateRepository")) + } + + // Says why it did nothing rather than failing. The task is registered whether or not a + // repository is configured, so a build script and a CI job can name it unconditionally. + doLast { + if (!publishUrl.isPresent) { + logger.lifecycle( + "No private repository configured, nothing published. Set plugwright.publish.url " + + "(or PLUGWRIGHT_PUBLISH_URL), plus plugwright.publish.user and " + + "plugwright.publish.password if the repository asks for them." + ) + } else if (!privateEnabled.get()) { + logger.lifecycle("Private publishing is off (plugwright.publish.private.enabled=false).") + } + } +} + +// `maven-publish` already registers `publish` as the lifecycle task for this module, but on +// its own it only reaches the private repository above; the portal side lives under +// `plugin-publish`'s `publishPlugins` and never joins it on its own. Wiring both wrapper +// tasks onto `publish` makes it the one command a release runs, still skipping either side +// exactly as `publishToPrivateRepository`/`publishToPublicRepository` do on their own. +tasks.named("publish") { + dependsOn(tasks.named("publishToPrivateRepository"), tasks.named("publishToPublicRepository")) +} diff --git a/gradle-plugin/plugwright-bundle/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt b/gradle-plugin/plugwright-bundle/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt new file mode 100644 index 0000000..6053412 --- /dev/null +++ b/gradle-plugin/plugwright-bundle/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt @@ -0,0 +1,22 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.external.ExternalMode +import me.drownek.plugwright.local.LocalMode +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** + * Entry point for the `io.github.drownek.plugwright` id. + * + * Applies the mode-agnostic engine and registers both built-in modes: `local` and `external`. + * A third-party mode registers itself the same way, from its own plugin or from the build + * script directly, via `plugwright.registerMode(...)`. + */ +class PlugwrightPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply(PlugwrightCorePlugin::class.java) + val extension = project.extensions.getByType(PlugwrightExtension::class.java) + extension.registerMode(LocalMode) + extension.registerMode(ExternalMode) + } +} diff --git a/gradle-plugin/plugwright-core/build.gradle.kts b/gradle-plugin/plugwright-core/build.gradle.kts new file mode 100644 index 0000000..c86e57c --- /dev/null +++ b/gradle-plugin/plugwright-core/build.gradle.kts @@ -0,0 +1,38 @@ +val projectVersion = version.toString() + +dependencies { + implementation(gradleApi()) + implementation("com.google.code.gson:gson:2.10.1") + // Carries `afterSync`, used to run the compile task after an IntelliJ sync. Applied to a + // consumer's build only when that build already applies the `idea` plugin. + implementation("org.jetbrains.gradle.plugin.idea-ext:org.jetbrains.gradle.plugin.idea-ext.gradle.plugin:1.4.1") + + // The api module has no separate published coordinates yet, so its classes are + // merged into this jar below. compileOnly keeps it out of the published POM. + compileOnly(project(":plugwright-api")) +} + +// Until plugwright-api is published on its own, ship it inside this jar so both this +// module and whatever entry-point module publishes it (currently plugwright-local) +// resolve the same contract classes. +val apiJar = project(":plugwright-api").tasks.named("jar", Jar::class) + +tasks.named("jar") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(apiJar.map { zipTree(it.archiveFile) }) +} + +val generateVersionResource = tasks.register("generateVersionResource") { + val outFile = layout.buildDirectory.file("generated/version-resource/plugwright-version.properties") + inputs.property("version", projectVersion) + outputs.file(outFile) + doLast { + val f = outFile.get().asFile + f.parentFile.mkdirs() + f.writeText("version=$projectVersion\n") + } +} + +sourceSets.named("main") { + resources.srcDir(generateVersionResource.map { it.outputs.files.singleFile.parentFile }) +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt new file mode 100644 index 0000000..fc923c1 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt @@ -0,0 +1,189 @@ +package me.drownek.plugwright + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import java.io.File + +/** + * Base for tasks that shell out to Node.js or to the test server. + * + * Holds the Node.js resolution inputs and the process plumbing; knows nothing about + * server provisioning. + */ +abstract class AbstractNodeTask : DefaultTask() { + + private companion object { + /** What `cmd /c` acts on rather than hands to the program it runs. */ + const val CMD_SPECIAL_CHARACTERS = "^&|<>()!%\" \t" + } + + @get:Input + abstract val nodeVersion: Property + + @get:Input + abstract val downloadNode: Property + + @get:Internal + abstract val nodeInstallDir: DirectoryProperty + + protected fun resolveNode(): NodeManager.NodePaths = + NodeManager.getOrDownloadNode(nodeInstallDir.get().asFile, nodeVersion.get(), downloadNode.get()) + + /** Environment that puts the resolved Node.js on PATH for child processes. */ + protected fun nodePathEnv(nodePaths: NodeManager.NodePaths): Map { + val nodeDir = File(nodePaths.node).parent ?: return emptyMap() + val pathKey = System.getenv().keys.firstOrNull { it.equals("PATH", ignoreCase = true) } ?: "PATH" + return mapOf(pathKey to nodeDir + File.pathSeparator + (System.getenv(pathKey) ?: "")) + } + + protected fun runCommand( + dir: File, + vararg command: String, + env: Map = emptyMap(), + interactive: Boolean = false, + onStdoutLine: ((String) -> Unit)? = null + ) { + val isWindows = System.getProperty("os.name").lowercase().contains("win") + val cmdName = File(command[0]).nameWithoutExtension.lowercase() + val cmd = if (isWindows && (cmdName == "npm" || cmdName == "node")) { + // "call" after /c so the line handed to cmd starts with a letter, not a quote: + // when it starts with a quote and holds more than two quotes total (guaranteed + // once quoteForCmd wraps an argument), cmd strips the outer pair itself, mangling + // a spaced npm.cmd path Java already quoted. + listOf("cmd", "/c", "call") + command.map { quoteForCmd(it) } + } else { + command.toList() + } + + val processBuilder = ProcessBuilder(cmd) + processBuilder.directory(dir) + processBuilder.environment().putAll(env) + + val process = processBuilder.start() + + val shutdownHook = Thread { + if (process.isAlive) killProcessTree(process) + } + Runtime.getRuntime().addShutdownHook(shutdownHook) + try { + runProcess(process, command, interactive, onStdoutLine) + } finally { + try { + Runtime.getRuntime().removeShutdownHook(shutdownHook) + } catch (_: IllegalStateException) {} + } + } + + /** + * Makes an argument survive the `cmd /c` in front of it. + * + * `cmd` re-parses the line it is handed and `^` is its escape character, so an npm range + * like `@scope/pkg@^1.2.0` reaches npm as `@scope/pkg@1.2.0` — an exact version nobody + * published, reported as "No matching version found". Java quotes an argument only when + * it holds a space or a redirection, and `^` is neither, so the quoting that makes it + * literal has to happen here. + * + * An argument already carrying a quote of its own is left alone: it is either quoted + * already or means something by it, and Java rejects a quoted argument with a quote + * inside outright. + */ + private fun quoteForCmd(argument: String): String = when { + argument.none { it in CMD_SPECIAL_CHARACTERS } -> argument + argument.contains('"') -> argument + else -> "\"$argument\"" + } + + protected fun runProcess( + process: Process, + command: Array, + interactive: Boolean = false, + onStdoutLine: ((String) -> Unit)? = null + ) { + val stdoutThread = Thread { + process.inputStream.bufferedReader(Charsets.UTF_8).useLines { lines -> + lines.forEach { line -> + logger.lifecycle(line) + onStdoutLine?.invoke(line) + } + } + } + stdoutThread.isDaemon = true + + val stderrThread = Thread { + process.errorStream.bufferedReader(Charsets.UTF_8).useLines { lines -> + lines.forEach { logger.error(it) } + } + } + stderrThread.isDaemon = true + + if (interactive) { + val stdinThread = Thread { + try { + val reader = System.`in`.bufferedReader(Charsets.UTF_8) + val out = process.outputStream + while (true) { + val line = reader.readLine() ?: break + out.write((line + "\n").toByteArray(Charsets.UTF_8)) + out.flush() + } + } catch (_: Exception) {} + } + stdinThread.isDaemon = true + stdinThread.start() + } + + stdoutThread.start() + stderrThread.start() + + val exitCode = try { + process.waitFor() + } catch (e: InterruptedException) { + logger.lifecycle("[E2E] Build cancelled, gracefully terminating server process tree...") + + killProcessTree(process) + + // Re-interrupt the thread after doing the cleanup + Thread.currentThread().interrupt() + throw RuntimeException("E2E build cancelled; spawned server was terminated.", e) + } + + try { stdoutThread.join(2000) } catch (_: InterruptedException) {} + try { stderrThread.join(2000) } catch (_: InterruptedException) {} + + if (exitCode != 0) { + throw RuntimeException("Command '${command.joinToString(" ")}' failed with exit code: $exitCode") + } + } + + protected fun killProcessTree(process: Process) { + try { + val isJava = process.info().command().orElse("")?.contains("java") ?: false + if (isJava) { + try { + val out = process.outputStream + out.write("stop\n".toByteArray()) + out.flush() + } catch (_: Exception) {} + process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS) + } + + val handle = process.toHandle() + val descendants = handle.descendants().toList() + + // Kill parent first to prevent respawning + handle.destroyForcibly() + process.waitFor(2, java.util.concurrent.TimeUnit.SECONDS) + + // Then kill descendants + descendants.forEach { + try { it.destroyForcibly() } catch (_: Throwable) {} + } + + } catch (_: Throwable) { + // best effort + } + } +} diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/Banner.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/Banner.kt similarity index 100% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/Banner.kt rename to gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/Banner.kt diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/EnvironmentContainer.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/EnvironmentContainer.kt new file mode 100644 index 0000000..da3a04e --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/EnvironmentContainer.kt @@ -0,0 +1,64 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.EnvironmentSpec +import me.drownek.plugwright.api.PlugwrightMode +import org.gradle.api.GradleException +import org.gradle.api.model.ObjectFactory + +/** + * Registry of [PlugwrightMode]s and the [EnvironmentSpec]s declared against them. + * + * A hand-rolled container rather than Gradle's `ExtensiblePolymorphicDomainObjectContainer`: + * environments are created once while the build script is evaluated and read back once in + * `afterEvaluate`, so the extra machinery of a live domain object container buys nothing here. + */ +class EnvironmentContainer(private val objects: ObjectFactory) { + + class Entry(val spec: EnvironmentSpec, val mode: PlugwrightMode<*>) + + private val modesById = mutableMapOf>() + private val entries = linkedMapOf() + + fun registerMode(mode: PlugwrightMode<*>) { + modesById[mode.id] = mode + } + + fun modeById(id: String): PlugwrightMode<*> = + modesById[id] ?: throw GradleException( + "No plugwright mode is registered under id '$id'. Call registerMode(...) first " + + "(the built-in local mode registers itself when the plugin is applied)." + ) + + /** Declares environment [name], backed by [mode]'s spec type. */ + fun create(name: String, mode: PlugwrightMode, action: S.() -> Unit = {}): S { + if (entries.containsKey(name)) { + throw GradleException("Environment '$name' is already declared.") + } + val spec = mode.createSpec(name, objects) + spec.action() + entries[name] = Entry(spec, mode) + return spec + } + + /** + * Creates environment [name] from whatever mode is registered under that same id, with no + * build-script configuration. Used for the implicit "local" environment. + */ + fun createImplicit(name: String): Entry { + create(name, modeById(name).erased()) {} + return entries.getValue(name) + } + + val isEmpty: Boolean get() = entries.isEmpty() + val names: Set get() = entries.keys + val all: Collection get() = entries.values + operator fun get(name: String): Entry? = entries[name] +} + +/** + * Recovers usable static typing after a [PlugwrightMode] has been erased to `PlugwrightMode<*>`. + * Safe because the [EnvironmentSpec] passed alongside it always came from that same mode's + * [PlugwrightMode.createSpec]. + */ +@Suppress("UNCHECKED_CAST") +internal fun PlugwrightMode<*>.erased(): PlugwrightMode = this as PlugwrightMode diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/MatrixSpec.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/MatrixSpec.kt new file mode 100644 index 0000000..ecf81b5 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/MatrixSpec.kt @@ -0,0 +1,25 @@ +package me.drownek.plugwright + +import org.gradle.api.provider.Property + +/** + * Settings for the `plugwrightTest` matrix run: every environment with `includeInMatrix = true`, + * aggregated into one summary. + */ +abstract class MatrixSpec { + + /** + * Runs environments concurrently instead of one after another. Off by default: two local + * Paper servers double the `-Xmx` footprint, and a shared external IP intensifies + * join-throttle contention and ban risk on a public stand. + */ + abstract val parallel: Property + + /** Upper bound on concurrent environment runs when [parallel] is enabled. */ + abstract val maxParallel: Property + + init { + parallel.convention(false) + maxParallel.convention(2) + } +} diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/NodeManager.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NodeManager.kt similarity index 100% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/NodeManager.kt rename to gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NodeManager.kt diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt new file mode 100644 index 0000000..71bf6fb --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt @@ -0,0 +1,196 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.NpmConfig +import me.drownek.plugwright.api.NpmRegistry +import me.drownek.plugwright.api.SecretRef +import org.gradle.api.GradleException +import org.gradle.api.logging.Logger +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.util.Base64 + +/** + * Turns the `npm { }` block into the workspace's `.npmrc`, right before something runs + * `npm install` in it. + * + * The file is written at execution time and not a moment earlier: it holds resolved secrets, + * and the configuration cache is a file on disk like any other. + * + * Only a file plugwright wrote itself is ever replaced — the [MARKER] on the first line says + * so. A workspace with a hand-written `.npmrc` keeps it, and the build says which one won. + */ +internal object NpmrcWriter { + + const val FILE_NAME = ".npmrc" + + const val MARKER = "# Generated by plugwright - do not edit" + + private const val EXPLANATION = "# Edit the npm { } block in your build script instead." + + private const val IGNORE_COMMENT = "# Generated from the npm { } block; may hold registry credentials" + + /** + * Brings `/.npmrc` in line with [config]. + * + * An empty config removes a file plugwright generated earlier — a registry that has been + * deleted from the build script should stop applying — and leaves everything else alone. + */ + fun write(workspace: File, config: NpmConfig, logger: Logger) { + val file = File(workspace, FILE_NAME) + + if (file.exists() && !isGenerated(file)) { + if (!config.isEmpty) { + logger.warn( + "${file.absolutePath} was not written by plugwright, so the npm { } block in the " + + "build script is being ignored. Delete the file to let plugwright manage it." + ) + } + return + } + + if (config.isEmpty) { + if (file.exists() && file.delete()) { + logger.lifecycle("Removed ${file.absolutePath}: no npm { } block declares a registry anymore") + } + return + } + + file.parentFile?.mkdirs() + file.writeText(render(config)) + restrictPermissions(file) + ensureIgnored(workspace, logger) + logger.lifecycle("Wrote ${file.absolutePath} (${summarize(config)})") + } + + /** + * Keeps the file out of version control. + * + * `plugwrightInit` scaffolds a `.gitignore` that already covers it, but a workspace made + * before this existed has one without the entry — and the file it is missing may hold a + * registry token. + */ + private fun ensureIgnored(workspace: File, logger: Logger) { + val gitignore = File(workspace, ".gitignore") + val entry = FILE_NAME + + if (gitignore.exists()) { + val present = gitignore.readLines().any { it.trim().trimStart('/') == entry } + if (present) return + val separator = if (gitignore.readText().endsWith("\n")) "" else "\n" + gitignore.appendText("$separator$IGNORE_COMMENT\n$entry\n") + } else { + gitignore.writeText("$IGNORE_COMMENT\n$entry\n") + } + logger.lifecycle("Added $entry to ${gitignore.absolutePath}") + } + + /** Whether the file is one of ours: the marker is the first thing in it. */ + private fun isGenerated(file: File): Boolean = + file.useLines { lines -> lines.firstOrNull()?.trim() == MARKER } + + // ---- Rendering --------------------------------------------------------------------- + + private fun render(config: NpmConfig): String = buildString { + appendLine(MARKER) + appendLine(EXPLANATION) + + config.registries.forEach { registry -> + val key = registry.scope?.let { "$it:registry" } ?: "registry" + appendLine("$key=${registry.url}") + } + + config.registries.forEach { registry -> + credentialLines(registry).forEach { appendLine(it) } + } + + config.options.forEach { (key, value) -> appendLine("$key=$value") } + } + + private fun credentialLines(registry: NpmRegistry): List { + val credentials = registry.credentials + if (credentials.isEmpty) return emptyList() + + val prefix = authKeyPrefix(registry.url) + val label = registry.scope?.let { "npm scope '$it'" } ?: "the default npm registry" + val lines = mutableListOf() + + credentials.authToken?.let { lines += "$prefix:_authToken=${resolve(it, label)}" } + credentials.username?.let { lines += "$prefix:username=${resolve(it, label)}" } + credentials.password?.let { + val encoded = Base64.getEncoder().encodeToString(resolve(it, label).toByteArray(Charsets.UTF_8)) + lines += "$prefix:_password=$encoded" + } + return lines + } + + /** + * The `//host/path/` npm keys credentials hang off, from a registry URL. + * + * npm matches these against the registry it is about to talk to, so the path matters: + * a Nexus with `/repository/npm-private/` authenticates separately from its sibling + * repositories on the same host. + */ + private fun authKeyPrefix(url: String): String = + "//" + url.substringAfter("://").trimEnd('/') + "/" + + // ---- Secrets ----------------------------------------------------------------------- + + /** + * The value behind a [SecretRef], read now rather than at configuration time. + * + * A secret that resolves to nothing fails the build here, with the name of what was + * empty — the alternative is npm answering 401 several minutes into a CI job. + */ + private fun resolve(ref: SecretRef, label: String): String { + val (source, value) = when (ref) { + is SecretRef.FromEnv -> "environment variable '${ref.name}'" to System.getenv(ref.name) + is SecretRef.FromSystemProperty -> "system property '${ref.name}'" to System.getProperty(ref.name) + is SecretRef.FromFile -> { + val file = File(ref.path) + "file '${ref.path}'" to if (file.isFile) file.useLines { it.firstOrNull() } else null + } + } + + if (value.isNullOrBlank()) { + throw GradleException( + "The credentials for $label read from $source, which is empty or unset. " + + "Set it, or drop the credentials from the npm { } block." + ) + } + return value.trim() + } + + // ---- Housekeeping ------------------------------------------------------------------ + + /** Owner-only, on the systems that have a say in it: the file holds tokens. */ + private fun restrictPermissions(file: File) { + try { + Files.setPosixFilePermissions(file.toPath(), PosixFilePermissions.fromString("rw-------")) + } catch (_: UnsupportedOperationException) { + // Windows: the closest equivalent the java.io API offers. + file.setReadable(false, false) + file.setReadable(true, true) + file.setWritable(false, false) + file.setWritable(true, true) + } catch (_: IOException) { + // Best effort; a file we could write but not chmod is still a working .npmrc. + } + } + + /** What went into the file, with the secrets left out of the build log. */ + private fun summarize(config: NpmConfig): String { + val parts = mutableListOf() + + config.registries.forEach { registry -> + val name = registry.scope ?: "default" + val authenticated = if (registry.credentials.isEmpty) "" else ", credentials ***" + parts += "$name -> ${registry.url}$authenticated" + } + if (config.options.isNotEmpty()) { + parts += "options: ${config.options.keys.joinToString(", ")}" + } + return parts.joinToString("; ") + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt new file mode 100644 index 0000000..0cd3a75 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt @@ -0,0 +1,242 @@ +package me.drownek.plugwright + +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import me.drownek.plugwright.api.NpmConfig +import me.drownek.plugwright.api.PlugwrightLayout +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import java.io.File + +/** + * Installs the workspace's npm dependencies and compiles its TypeScript. + * + * Sources live in two directories — `tests` for specs, `plugins` for runner plugins — and + * `tsc` mirrors both into `dist`. A workspace still holding its specs at the root (the layout + * before `tests` existed) is moved into place the first time this task runs. + * + * Split out of the test task so several environments share one install and one `tsc` + * run instead of paying for them per environment. + */ +abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { + + /** + * Root of the npm project: `plugwright.testsDir`. + * + * Not declared as an input directory: the task never reports itself up to date, and the + * workspace holds `node_modules` and a running server's `generated` directory — fingerprinting + * either of those costs seconds and decides nothing. + */ + @get:Internal + abstract val testsDir: DirectoryProperty + + /** + * 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 + + /** + * The registries npm should install from, from the build script's `npm { }` block. + * + * Holds [me.drownek.plugwright.api.SecretRef]s rather than credentials: the values are + * read when the `.npmrc` is written, in [compile]. + */ + @get:Input + abstract val npmConfig: Property + + init { + group = "verification" + description = "Install npm dependencies and compile the E2E tests" + // The compiled output depends on node_modules and on the installed runner package, + // neither of which is a declared input, so never report this as up to date. + outputs.upToDateWhen { false } + } + + @TaskAction + fun compile() { + val workspace = if (testsDir.isPresent) { + testsDir.get().asFile + } else { + logger.warn("Tests directory not configured") + return + } + + if (!workspace.exists()) { + logger.warn("Tests directory does not exist: ${workspace.absolutePath}") + return + } + + val layout = PlugwrightLayout.of(workspace) + migrateRootLevelSpecs(layout) + + val nodePaths = resolveNode() + val npmEnv = nodePathEnv(nodePaths) + + // Before any install: both the dependency install below and the runner packages after + // it run with this workspace as their working directory, so one file covers both. + NpmrcWriter.write(workspace, npmConfig.getOrElse(NpmConfig.EMPTY), logger) + + // Install dependencies if needed + if (!File(workspace, "node_modules").exists()) { + logger.lifecycle("Installing Node.js dependencies...") + runCommand(workspace, nodePaths.npm, "install", env = npmEnv) + } + + installMissingRunnerPackages(workspace, nodePaths, npmEnv) + + // Build TypeScript tests if tsconfig.json exists + val tsconfigFile = File(workspace, "tsconfig.json") + if (tsconfigFile.exists()) { + logger.lifecycle("TypeScript config found, compiling tests...") + runCommand(workspace, nodePaths.npm, "run", "build", env = npmEnv) + } else { + logger.lifecycle("No TypeScript config found, running JavaScript tests directly") + } + } + + // ---- Migration ------------------------------------------------------------------- + + /** + * Moves a workspace laid out the old way — specs anywhere under the root — into `tests`. + * + * Runs only while there is no `tests` directory at all, so it happens once and never + * touches a workspace that already follows the layout. The `tsconfig.json` goes along + * with the files: its `include` still describes where the specs used to be. + */ + private fun migrateRootLevelSpecs(layout: PlugwrightLayout) { + if (layout.testsDir.exists()) return + + val strays = findSpecSources(layout.workspaceDir, layout) + if (strays.isEmpty()) return + + strays.forEach { source -> + val destination = File(layout.testsDir, source.relativeTo(layout.workspaceDir).path) + destination.parentFile.mkdirs() + if (!source.renameTo(destination)) { + source.copyTo(destination, overwrite = true) + source.delete() + } + } + removeEmptyDirectories(layout.workspaceDir, layout) + + logger.lifecycle( + "Moved ${strays.size} spec file(s) into ${layout.testsDir.absolutePath} — " + + "plugwright looks for specs under 'tests' now." + ) + retargetTsConfig(layout) + } + + /** Spec files outside the directories the layout owns; empty for a workspace that has + * already been migrated or was created by `plugwrightInit`. */ + private fun findSpecSources(directory: File, layout: PlugwrightLayout): List { + val children = directory.listFiles() ?: return emptyList() + return children.flatMap { child -> + when { + child.isDirectory && isIgnoredDirectory(child, layout) -> emptyList() + child.isDirectory -> findSpecSources(child, layout) + child.name.endsWith(".spec.ts") || child.name.endsWith(".spec.js") -> listOf(child) + else -> emptyList() + } + } + } + + private fun isIgnoredDirectory(directory: File, layout: PlugwrightLayout): Boolean = + directory.name == "node_modules" || directory.name == ".git" || + directory == layout.compiledDir || directory == layout.generatedRootDir || + directory == layout.pluginsDir || directory == layout.testsDir + + private fun removeEmptyDirectories(directory: File, layout: PlugwrightLayout) { + val children = directory.listFiles() ?: return + children.filter { it.isDirectory && !isIgnoredDirectory(it, layout) }.forEach { child -> + removeEmptyDirectories(child, layout) + if (child.list()?.isEmpty() == true) child.delete() + } + } + + /** + * Points a migrated workspace's `tsconfig.json` at the directories the sources now live + * in, and at the `dist` that mirrors them. + * + * A config the parser chokes on (comments are legal in `tsconfig.json`, and JSON says + * otherwise) is left alone with an explanation — a rewrite that drops the comments is a + * worse outcome than an edit by hand. + */ + private fun retargetTsConfig(layout: PlugwrightLayout) { + val tsconfigFile = File(layout.workspaceDir, "tsconfig.json") + if (!tsconfigFile.exists()) return + + val config = try { + JsonParser.parseString(tsconfigFile.readText()).asJsonObject + } catch (e: Exception) { + logger.warn( + "Could not update ${tsconfigFile.absolutePath} (${e.message}). Point its \"include\" at " + + "\"tests/**/*.ts\" and \"plugins/**/*.ts\" by hand." + ) + return + } + + val compilerOptions = config.getAsJsonObject("compilerOptions") ?: JsonObject().also { + config.add("compilerOptions", it) + } + compilerOptions.addProperty("rootDir", ".") + compilerOptions.addProperty("outDir", "./${PlugwrightLayout.COMPILED_DIR_NAME}") + config.add("include", jsonArrayOf( + "${PlugwrightLayout.TESTS_DIR_NAME}/**/*.ts", + "${PlugwrightLayout.PLUGINS_DIR_NAME}/**/*.ts", + )) + config.add("exclude", jsonArrayOf( + "node_modules", + PlugwrightLayout.COMPILED_DIR_NAME, + PlugwrightLayout.GENERATED_DIR_NAME, + )) + + tsconfigFile.writeText(GsonBuilder().setPrettyPrinting().create().toJson(config) + "\n") + logger.lifecycle("Updated ${tsconfigFile.absolutePath} for the new layout") + } + + private fun jsonArrayOf(vararg values: String): JsonArray = + JsonArray().apply { values.forEach { add(it) } } + + // ---- npm ------------------------------------------------------------------------- + + private fun installMissingRunnerPackages( + workspace: File, + nodePaths: NodeManager.NodePaths, + npmEnv: Map + ) { + val nodeModules = File(workspace, "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(workspace, nodePaths.npm, "install", "--no-save", *missing.toTypedArray(), env = npmEnv) + } catch (e: Exception) { + // A package that can't be installed is not a reason to stop compiling the tests: + // only the environment that asked for it is affected, and the runner reports the + // 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 new file mode 100644 index 0000000..8de5861 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt @@ -0,0 +1,469 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.ConfigNodeBuilder +import me.drownek.plugwright.api.PluginRef +import me.drownek.plugwright.api.PlugwrightLayout +import org.gradle.api.GradleException +import org.gradle.api.plugins.ExtensionAware +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import org.gradle.plugins.ide.idea.model.IdeaModel +import org.jetbrains.gradle.ext.ProjectSettings +import org.jetbrains.gradle.ext.TaskTriggersConfig +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import org.gradle.process.ExecOperations + +interface InjectedExecOps { + @get:Inject + val execOperations: ExecOperations +} + +object BannerState { + val printed = AtomicBoolean(false) +} + +/** + * Name of the implicit environment used while the build script has no `environments { }` + * block: the flat extension properties describe one environment under this name. + */ +const val DEFAULT_ENVIRONMENT_NAME = "local" + +/** + * Mode-agnostic engine: the extension, the shared compile step, and per-environment task + * generation. Knows nothing about `local`/`external`/any other mode — those register + * themselves through [PlugwrightExtension.registerMode] before this plugin's + * `afterEvaluate` runs. See [PlugwrightPlugin] (in the module that publishes the plugin id) + * for where the built-in modes actually get registered. + */ +class PlugwrightCorePlugin : Plugin { + override fun apply(project: Project) { + val extension = project.extensions.create("plugwright", PlugwrightExtension::class.java, project) + + // Shared per-user cache so Node.js is downloaded once for all projects + // and survives 'gradle clean'. Safe for concurrent builds thanks to the + // file lock in NodeManager. + val defaultNodeInstallDir = File(project.gradle.gradleUserHomeDir, "caches/plugwright/node") + + val plugwrightCompileTests = project.tasks.register("plugwrightCompileTests", PlugwrightCompileTestsTask::class.java) { + doFirst { + if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) + } + + testsDir.set(extension.testsDir) + // Filled in once every environment has been wired; empty until then. + runnerPackages.convention(emptyList()) + npmConfig.set(project.provider { extension.npm.toConfig() }) + nodeVersion.set(extension.nodeVersion) + downloadNode.set(extension.downloadNode) + nodeInstallDir.set(defaultNodeInstallDir) + } + + registerInitTask(project, extension, defaultNodeInstallDir) + + registerIdeaSyncTrigger(project, plugwrightCompileTests) + + project.afterEvaluate { + wireEnvironments(project, extension, plugwrightCompileTests, defaultNodeInstallDir) + } + } + + /** + * Runs the compile task after an IntelliJ IDEA sync, so a fresh checkout has its + * `node_modules` and its compiled specs before anyone opens a spec file and finds every + * import unresolved. + * + * Only when the project already applies the `idea` plugin — `idea-ext` is what carries + * `afterSync`, and applying it unconditionally would push a plugin onto builds that never + * asked for one. + * + * That plugin does not always show up while the project is being configured: an IDEA sync + * applies it to an already-evaluated project, and `Project.afterEvaluate` throws once that + * has happened. So the trigger is wired right away in that case and deferred only while + * configuration is still running. + */ + private fun registerIdeaSyncTrigger( + project: Project, + plugwrightCompileTests: TaskProvider, + ) { + project.plugins.withId("idea") { + project.pluginManager.apply("org.jetbrains.gradle.plugin.idea-ext") + if (project.state.executed) { + wireIdeaSyncTrigger(project, plugwrightCompileTests) + } else { + project.afterEvaluate { wireIdeaSyncTrigger(project, plugwrightCompileTests) } + } + } + } + + /** + * The `taskTriggers` block lives on the root project's `idea.project.settings`, so on a + * subproject the lookup finds nothing and the trigger is simply skipped. + */ + private fun wireIdeaSyncTrigger( + project: Project, + plugwrightCompileTests: TaskProvider, + ) { + val ideaModel = project.extensions.findByType(IdeaModel::class.java) ?: return + val ideaProject = ideaModel.project as? ExtensionAware + val settings = ideaProject?.extensions?.findByType(ProjectSettings::class.java) as? ExtensionAware + settings?.extensions?.findByType(TaskTriggersConfig::class.java)?.afterSync(plugwrightCompileTests) + } + + private fun wireEnvironments( + project: Project, + extension: PlugwrightExtension, + plugwrightCompileTests: TaskProvider, + defaultNodeInstallDir: File + ) { + // No environments { } block: fold the deprecated flat properties into one implicit + // environment, using whatever mode was registered under the default name. + if (extension.environments.isEmpty) { + val entry = extension.environments.createImplicit(DEFAULT_ENVIRONMENT_NAME) + entry.mode.erased().applyLegacyDefaults(entry.spec, extension) + } + + val primaryName = extension.primaryEnvironment.get() + if (extension.environments[primaryName] == null) { + throw GradleException( + "plugwright.primaryEnvironment is set to '$primaryName', but no such environment is " + + "declared. Declared environments: ${extension.environments.names.joinToString()}" + ) + } + + val layout = PlugwrightLayout.of(extension.testsDir.get().asFile) + val projectPluginJarProvider = resolveProjectPluginJar(project) + val validationProblems = mutableListOf() + validationProblems += extension.npm.toConfig().problems().map { "[npm] $it" } + val reportsDir = project.layout.buildDirectory.dir("reports/plugwright") + + // -Pplugwright.env=a,b narrows the matrix; ignored by direct plugwrightTest calls. + val matrixEnvFilter = (project.findProperty("plugwright.env") as? String) + ?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() + + val matrixEntries = mutableListOf() + val matrixPrepareTasks = mutableListOf>() + // Keyed by package name rather than by the whole spec: a mode names a package with the + // version it needs, and the same build script names it again — bare — in that + // environment's plugins { npm(...) }. Both specs in one `npm install` is a package + // asked for twice at two different versions, and the bare one resolves "latest", + // which a package released only under another tag does not have. + val runnerPackageSpecs = linkedMapOf() + fun addRunnerPackage(spec: String) { + val name = npmPackageNameOf(spec) + val existing = runnerPackageSpecs[name] + when { + // A version beats no version; between two versions the mode's comes first and + // wins, because it is the half that knows what its own export needs. + existing == null || existing == name -> runnerPackageSpecs[name] = spec + spec == name || spec == existing -> Unit + else -> project.logger.warn( + "plugwright: $name is asked for as both '$existing' and '$spec'. Installing " + + "'$existing'; drop the version from one of them to say which you meant." + ) + } + } + + extension.environments.all.forEach { entry -> + val envName = entry.spec.name + val mode = entry.mode.erased() + // Before validation and registerTasks: a mode fills in what it can derive from + // the layout here, and both of those already expect a complete spec. + mode.applyLayoutDefaults(entry.spec, layout) + val ctx = TaskRegistrationContextImpl( + project, envName, envName == primaryName, projectPluginJarProvider, + extension.testsDir.map { it.asFile }, layout, extension, defaultNodeInstallDir + ) + 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 { + if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) + } + dependsOn(plugwrightCompileTests) + testsDir.set(extension.testsDir) + environmentName.set(envName) + modeId.set(mode.id) + excludeTests.set(entry.spec.excludeTests) + configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$envName.json")) + jsonReportFile.set(reportsDir.map { it.file("$envName.json") }) + junitReportFile.set(reportsDir.map { it.dir("junit").file("$envName.xml") }) + 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 -> + addRunnerPackage(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" } + + mode.registerTasks(entry.spec, ctx) + + val environmentConfigProvider = ctx.environmentConfigProvider + ?: project.provider { ConfigNodeBuilder().apply { mode.serialize(entry.spec, this) }.build() } + val pluginConfigsProvider = (ctx.pluginConfigsProvider ?: project.provider { emptyList() }) + .map { refs -> refs.map { resolveWorkspacePlugin(it, layout) } } + + // A plugin declared by npm name is installed alongside the environment's own + // runner packages; a plugin given as a path is already in the project. + pluginConfigsProvider.get() + .map { it.specifier } + .filter { isNpmPackageName(it) } + .forEach { addRunnerPackage(it) } + + testTask.configure { + ctx.prepareTaskRef?.let { dependsOn(it) } + environmentConfig.set(environmentConfigProvider) + pluginConfigs.set(pluginConfigsProvider) + } + + if (entry.spec.includeInMatrix.get() && (matrixEnvFilter == null || envName in matrixEnvFilter)) { + val reportsDirFile = reportsDir.get().asFile + matrixEntries += MatrixEnvironmentInput( + name = envName, + modeId = mode.id, + allowFailure = entry.spec.allowFailure.get(), + workspaceDir = layout.workspaceDir, + configFile = project.layout.buildDirectory.file("tmp/plugwright/$envName.json").get().asFile, + jsonReportFile = File(reportsDirFile, "$envName.json"), + junitReportFile = File(File(reportsDirFile, "junit"), "$envName.xml"), + logFile = File(reportsDirFile, "$envName.log"), + excludeTests = entry.spec.excludeTests.get(), + environmentConfig = environmentConfigProvider, + pluginConfigs = pluginConfigsProvider, + runtimePackage = runtimeRef?.name, + runtimeExport = runtimeRef?.export, + ) + ctx.prepareTaskRef?.let { matrixPrepareTasks += it } + } + } + + plugwrightCompileTests.configure { runnerPackages.set(runnerPackageSpecs.values.toList()) } + + if (validationProblems.isNotEmpty()) { + throw GradleException("plugwright configuration problems:\n" + validationProblems.joinToString("\n") { " $it" }) + } + + project.tasks.register("plugwrightTest", PlugwrightMatrixTask::class.java) { + doFirst { + if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) + } + dependsOn(plugwrightCompileTests) + matrixPrepareTasks.forEach { dependsOn(it) } + entries = matrixEntries + parallel.set(extension.matrix.parallel) + maxParallel.set(extension.matrix.maxParallel) + nodeVersion.set(extension.nodeVersion) + downloadNode.set(extension.downloadNode) + nodeInstallDir.set(defaultNodeInstallDir) + if (project.hasProperty("testFiles")) testFiles.set(project.property("testFiles") as String) + if (project.hasProperty("testNames")) testNames.set(project.property("testNames") as String) + } + } + + /** Turns `plugins { local("stand-reset") }` into the path the compiler writes it to. + * Anything else — an npm name, a path the build script spelled out — passes through. */ + private fun resolveWorkspacePlugin(ref: PluginRef, layout: PlugwrightLayout): PluginRef { + if (!ref.specifier.startsWith(PluginRef.WORKSPACE_SCHEME)) return ref + val name = ref.specifier.removePrefix(PluginRef.WORKSPACE_SCHEME) + return ref.copy(specifier = File(layout.compiledPluginsDir, "$name.js").absolutePath) + } + + /** `@scope/name@^1.0.0` → `@scope/name`; the version separator is the last `@`, which for + * a scoped package is never the leading one. A git/URL spec (`git+ssh://git@host/repo`, + * `https://user:pass@registry/pkg`) carries its own `@`s that aren't a version separator + * at all, so it is returned as-is instead of being cut at the last one. */ + private fun npmPackageNameOf(spec: String): String { + if (spec.contains("://") || spec.startsWith("git+")) return spec + val separator = spec.lastIndexOf('@') + return if (separator > 0) spec.substring(0, separator) else spec + } + + /** 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 + * no jar-producing task exists. */ + private fun resolveProjectPluginJar(project: Project): Provider { + val jarTask = when { + project.tasks.findByName("shadowJar") != null -> project.tasks.named("shadowJar") + project.tasks.findByName("reobfJar") != null -> project.tasks.named("reobfJar") + project.tasks.findByName("jar") != null -> project.tasks.named("jar") + else -> null + } ?: return project.objects.property(File::class.java) + return jarTask.map { it.outputs.files.singleFile } + } + + private fun registerInitTask(project: Project, extension: PlugwrightExtension, defaultNodeInstallDir: File) { + project.tasks.register("plugwrightInit") { + group = "verification" + description = "Interactively initializes a plugwright-test environment with required configs and an initial test file." + + doFirst { + if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) + } + + doLast { + val defaultDir = "src/test/e2e" + val propertyDir = project.findProperty("plugwrightDir") as? String + + val inputDir = propertyDir ?: run { + if (System.console() != null) { + project.logger.lifecycle("Enter the test directory location [default: $defaultDir]:") + val consoleInput = readlnOrNull()?.trim() + if (consoleInput.isNullOrEmpty()) defaultDir else consoleInput + } else { + project.logger.lifecycle("Non-interactive environment detected. Using default test directory: $defaultDir") + defaultDir + } + } + + project.logger.lifecycle("Using directory: $inputDir") + + val projectRootDir = project.projectDir.canonicalFile + val targetDir = projectRootDir.resolve(inputDir).canonicalFile + + if (!targetDir.path.startsWith(projectRootDir.path)) { + throw GradleException("SECURITY ERROR: Target directory ($targetDir) resolves outside the project root directory. Path traversal aborted.") + } + + if (!targetDir.exists() && !targetDir.mkdirs()) { + throw GradleException("IO ERROR: Failed to create target directory: ${targetDir.absolutePath}. Check your file permissions.") + } + + val layout = PlugwrightLayout.of(targetDir) + writeGitignore(project, targetDir) + writeIfAbsent(project, targetDir.resolve("package.json"), initTemplate("package.json")) + writeIfAbsent(project, targetDir.resolve("tsconfig.json"), initTemplate("tsconfig.json")) + writeIfAbsent(project, layout.testsDir.resolve("example.spec.ts"), initTemplate("example.spec.ts")) + writeIfAbsent(project, layout.pluginsDir.resolve("example-plugin.ts"), initTemplate("example-plugin.ts")) + + // The install below is the first one this workspace runs, so it needs the + // registries too — a scaffold that can only reach the public registry is no + // use to a project that lives behind a private one. + NpmrcWriter.write(targetDir, extension.npm.toConfig(), project.logger) + + project.logger.lifecycle("Executing 'npm install' in ${targetDir.absolutePath}...") + val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get()) + + try { + val isWin = System.getProperty("os.name").lowercase().contains("windows") + val cmd = if (isWin) listOf("cmd", "/c", nodePaths.npm, "install") else listOf(nodePaths.npm, "install") + val nodeDir = File(nodePaths.node).parent + + val execOps = project.objects.newInstance(InjectedExecOps::class.java) + val execResult = execOps.execOperations.exec { + workingDir = targetDir + commandLine = cmd + if (nodeDir != null) { + val pathKey = environment.keys.firstOrNull { it.equals("PATH", ignoreCase = true) } ?: "PATH" + environment[pathKey] = nodeDir + File.pathSeparator + (environment[pathKey] ?: "") + } + isIgnoreExitValue = true + } + + if (execResult.exitValue != 0) { + throw GradleException("EXEC ERROR: 'npm install' failed with exit code ${execResult.exitValue}.") + } + project.logger.lifecycle("Dependencies installed successfully.") + project.logger.lifecycle("\nYou're all set! Run tests with: ./gradlew plugwrightTest") + project.logger.lifecycle( + "To load the example plugin, add plugins { local(\"example-plugin\") } to an environment." + ) + } catch (e: Exception) { + if (e is GradleException) throw e + throw GradleException("EXEC FATAL: Failed to launch npm process. Original error: ${e.message}", e) + } + } + } + } + + private fun writeIfAbsent(project: Project, file: File, content: String) { + if (file.exists()) return + file.parentFile?.mkdirs() + file.writeText(content) + project.logger.lifecycle("Created: ${file.absolutePath}") + } + + /** + * One of the files `plugwrightInit` scaffolds, from `src/main/resources/plugwright-init`. + * + * They are real `.ts` / `.json` files rather than string literals in here, so an editor + * checks them and nothing has to be escaped past the Kotlin parser. `@runnerVersion@` is + * the only placeholder. + */ + private fun initTemplate(name: String): String { + val stream = PlugwrightCorePlugin::class.java.getResourceAsStream("/plugwright-init/$name") + ?: throw GradleException("plugwright is missing its '$name' template. Reinstall the plugin.") + return stream.bufferedReader().use { it.readText() } + .replace("@runnerVersion@", runnerVersionRange()) + } + + /** + * Keeps the three generated directories out of version control. + * + * Appends to a `.gitignore` that is already there rather than replacing it: the workspace + * may well have entries of its own, and none of them are this task's to decide about. + */ + private fun writeGitignore(project: Project, workspaceDir: File) { + val gitignore = File(workspaceDir, ".gitignore") + val template = initTemplate("gitignore") + + if (!gitignore.exists()) { + gitignore.writeText(template) + project.logger.lifecycle("Created: ${gitignore.absolutePath}") + return + } + + val required = template.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") } + val present = gitignore.readLines().map { it.trim().trimEnd('/') }.toSet() + val missing = required.filter { it.trimEnd('/') !in present } + if (missing.isEmpty()) return + + val separator = if (gitignore.readText().endsWith("\n")) "" else "\n" + gitignore.appendText(separator + missing.joinToString("\n", postfix = "\n")) + project.logger.lifecycle("Added ${missing.joinToString(", ")} to ${gitignore.absolutePath}") + } + + /** + * npm range for the runner that goes with this plugin: `2.0.4-dev.0` asks for `^2.0.0`. + * + * The runner and the plugin are released together, so the plugin's own version is the + * right thing to derive from — but only down to the minor. A pre-release plugin names a + * patch npm has never seen, and `^2.0.0` resolves to the newest 2.x either way. + */ + private fun runnerVersionRange(): String { + val match = Regex("""^(\d+)\.(\d+)\.""").find(Banner.pluginVersion()) ?: return "latest" + val (major, minor) = match.destructured + return "^$major.$minor.0" + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt new file mode 100644 index 0000000..fa23912 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt @@ -0,0 +1,166 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.LegacyEnvironmentProperties +import me.drownek.plugwright.api.NpmSpec +import me.drownek.plugwright.api.PlugwrightMode +import me.drownek.plugwright.api.RunDirFile +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import java.io.File + +abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperties { + /** + * Directory containing test files (.spec.js / .spec.ts) + */ + val testsDir: DirectoryProperty = project.objects.directoryProperty().convention( + project.layout.projectDirectory.dir("src/test/e2e") + ) + + /** + * The Node.js version to download and use if downloadNode is true. + */ + val nodeVersion: Property = project.objects.property(String::class.java).convention("22.14.0") + + /** + * Whether to automatically download Node.js. Disabled by default: the system-installed + * node/npm on PATH is used, and the build fails with instructions if Node.js is missing. + * Set to true to download a verified Node.js distribution into a shared per-user cache. + */ + val downloadNode: Property = project.objects.property(Boolean::class.java).convention(false) + + /** + * Environment the unsuffixed task aliases (`plugwrightTest`, `plugwrightClean`, …) point + * at. Only meaningful once more than one environment is declared. + */ + val primaryEnvironment: Property = project.objects.property(String::class.java).convention(DEFAULT_ENVIRONMENT_NAME) + + /** + * Mode registry and declared environments. See [registerMode] and [environments]. + */ + val environments: EnvironmentContainer = EnvironmentContainer(project.objects) + + /** Registers a [PlugwrightMode] so [environments] can create environments of its spec type. */ + fun registerMode(mode: PlugwrightMode<*>) { + environments.registerMode(mode) + } + + /** Declares the environments tests can run against. */ + fun environments(action: EnvironmentContainer.() -> Unit) { + environments.action() + } + + /** Settings for `plugwrightTest`'s multi-environment matrix run. See [matrix]. */ + val matrix: MatrixSpec = project.objects.newInstance(MatrixSpec::class.java) + + /** Configures the matrix run: `matrix { parallel.set(true); maxParallel.set(2) }`. */ + fun matrix(action: MatrixSpec.() -> Unit) { + matrix.action() + } + + /** Registries the workspace installs from, and the credentials for them. See [npm]. */ + val npm: NpmSpec = NpmSpec() + + /** + * Configures npm itself: `npm { registry("https://nexus.corp/repository/npm-group/") }`. + * + * The block covers the whole workspace rather than one environment — there is one + * `node_modules` and one install behind the entire matrix. It is written to a `.npmrc` + * next to `package.json` before each install; without a block, no file is written and + * npm keeps using whatever the machine already configures. + */ + fun npm(action: NpmSpec.() -> Unit) { + npm.action() + } + + // ---- Deprecated flat properties -------------------------------------------------- + // Pre-3.0 shape: describes a single implicit "local" environment. Still read whenever + // the build script has no environments { } block — see PlugwrightMode.applyLegacyDefaults. + + @Deprecated("Use environments { create(\"local\", LocalMode) { minecraftVersion.set(...) } }") + override val minecraftVersion: Property = project.objects.property(String::class.java).convention("1.19.4") + + @Deprecated("Use environments { create(\"local\", LocalMode) { jvmArgs.set(...) } }") + override val jvmArgs: ListProperty = project.objects.listProperty(String::class.java).convention( + listOf("-Xmx2G") + ) + + @Deprecated("Use environments { create(\"local\", LocalMode) { acceptEula.set(...) } }") + override val acceptEula: Property = project.objects.property(Boolean::class.java).convention(true) + + /** + * Left unset on purpose: an absent value is what tells the local mode to place the server + * under `/generated//run`. Setting it here is still honoured, and + * still means "this exact directory". + */ + @Deprecated("Use environments { create(\"local\", LocalMode) { runDir.set(...) } }") + override val runDir: DirectoryProperty = project.objects.directoryProperty() + + @Deprecated("Use environments { create(\"local\", LocalMode) { cleanExcludePatterns.set(...) } }") + override val cleanExcludePatterns: ListProperty = project.objects.listProperty(String::class.java).convention( + listOf("server.jar", ".minecraft-version", "cache", "libraries") + ) + + @Deprecated("Use environments { create(\"local\", LocalMode) { downloadPlugins { ... } } }") + override val pluginUrls: ListProperty = project.objects.listProperty(String::class.java).convention(emptyList()) + + @Deprecated("Use environments { create(\"local\", LocalMode) { useExternalPluginsOnly.set(...) } }") + override val useExternalPluginsOnly: Property = project.objects.property(Boolean::class.java).convention(false) + + @Deprecated("Use environments { create(\"local\", LocalMode) { writeFiles { ... } } }") + override val runDirFiles: ListProperty = project.objects.listProperty(RunDirFile::class.java).convention(emptyList()) + + /** + * DSL method for staging files into the run directory before server start. + * + * Paths are relative to the run directory. + */ + @Deprecated("Use environments { create(\"local\", LocalMode) { writeFiles { ... } } }") + fun writeFiles(action: RunDirFileSpec.() -> Unit) { + val spec = RunDirFileSpec() + action(spec) + runDirFiles.set(spec.entries) + } + + /** + * Specification for run-dir file staging. + */ + class RunDirFileSpec { + internal val entries = mutableListOf() + + /** Write [content] (as UTF-8 text) to [path] relative to the run directory. */ + fun file(path: String, content: String) { + entries.add(RunDirFile(path, content, null)) + } + + /** Copy [sourceFile] to [path] relative to the run directory. */ + fun file(path: String, sourceFile: File) { + entries.add(RunDirFile(path, null, sourceFile)) + } + } + + /** + * DSL method for configuring plugin downloads. + */ + @Deprecated("Use environments { create(\"local\", LocalMode) { downloadPlugins { ... } } }") + fun downloadPlugins(action: PluginDownloadSpec.() -> Unit) { + val spec = PluginDownloadSpec() + action(spec) + pluginUrls.set(spec.urls) + } + + /** + * Specification for plugin downloads. + */ + class PluginDownloadSpec { + internal val urls = mutableListOf() + + /** + * Add a plugin URL to download. + */ + fun url(pluginUrl: String) { + urls.add(pluginUrl) + } + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt new file mode 100644 index 0000000..690f7d8 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt @@ -0,0 +1,183 @@ +package me.drownek.plugwright + +import com.google.gson.JsonParser +import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.PluginRef +import org.gradle.api.GradleException +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.util.concurrent.Callable +import java.util.concurrent.Executors + +/** Everything [PlugwrightMatrixTask] needs to launch one environment, resolved once at + * `afterEvaluate` in [PlugwrightCorePlugin] — the same shape [PlugwrightTestTask] uses, + * minus the Gradle task machinery this task doesn't need per-environment. */ +internal data class MatrixEnvironmentInput( + val name: String, + val modeId: String, + val allowFailure: Boolean, + val workspaceDir: File, + val configFile: File, + val jsonReportFile: File, + val junitReportFile: File, + val logFile: File, + val excludeTests: List, + val environmentConfig: Provider, + val pluginConfigs: Provider>, + 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) + +/** + * `plugwrightTest`: runs every environment with `includeInMatrix = true`, one runner process + * each, and aggregates the result. Does not `dependsOn` the per-environment `plugwrightTest` + * tasks — it launches the same [RunnerLauncher] they use directly, so one environment failing + * doesn't stop the others from reporting. + */ +abstract class PlugwrightMatrixTask : AbstractNodeTask() { + + @get:Internal + internal var entries: List = emptyList() + + @get:Internal + abstract val testFiles: Property + + @get:Internal + abstract val testNames: Property + + @get:Internal + abstract val parallel: Property + + @get:Internal + abstract val maxParallel: Property + + init { + group = "verification" + description = "Runs plugwrightTest for every environment with includeInMatrix = true, and aggregates the result." + outputs.upToDateWhen { false } + } + + @TaskAction + fun runMatrix() { + val active = entries + if (active.isEmpty()) { + logger.lifecycle("plugwrightTest: no environment has includeInMatrix = true, nothing to run.") + return + } + + val nodePaths = resolveNode() + val fileFilters = with(RunnerLauncher) { testFiles.orNull.splitFilter() } + val nameFilters = with(RunnerLauncher) { testNames.orNull.splitFilter() } + + val outcomes = if (parallel.get() && active.size > 1) { + val pool = Executors.newFixedThreadPool(maxParallel.get().coerceAtLeast(1)) + try { + active.map { env -> pool.submit(Callable { runOne(env, nodePaths, fileFilters, nameFilters) }) }.map { it.get() } + } finally { + pool.shutdown() + } + } else { + active.map { runOne(it, nodePaths, fileFilters, nameFilters) } + } + + printSummaryTable(outcomes) + + val hardFailures = outcomes.filter { (env, summary, error) -> + val environmentHadTrouble = error != null || summary == null || summary.failed > 0 + environmentHadTrouble && !env.allowFailure + } + if (hardFailures.isNotEmpty()) { + throw GradleException( + "plugwrightTest matrix failed: ${hardFailures.joinToString(", ") { it.env.name }}. " + + "See per-environment logs under build/reports/plugwright/." + ) + } + } + + private data class Outcome(val env: MatrixEnvironmentInput, val summary: EnvironmentSummary?, val error: Throwable?) + + private fun runOne( + env: MatrixEnvironmentInput, + nodePaths: NodeManager.NodePaths, + fileFilters: List?, + nameFilters: List? + ): Outcome { + logger.lifecycle("plugwrightTest [${env.name}]: starting") + env.logFile.parentFile?.mkdirs() + env.logFile.writeText("") + + return try { + val entry = RunnerLauncher.Entry( + environmentName = env.name, + modeId = env.modeId, + environmentConfig = env.environmentConfig.get(), + workspaceDir = env.workspaceDir, + configFile = env.configFile, + testFiles = fileFilters, + testNames = nameFilters, + excludeTests = env.excludeTests, + jsonReportFile = env.jsonReportFile, + junitReportFile = env.junitReportFile, + pluginConfigs = env.pluginConfigs.get(), + runtimePackage = env.runtimePackage, + runtimeExport = env.runtimeExport, + ) + RunnerLauncher.writeConfig(entry) + val cliJs = RunnerLauncher.resolveCliJs(env.workspaceDir) + + runCommand( + env.workspaceDir, nodePaths.node, cliJs.absolutePath, "--config", entry.configFile.absolutePath, + onStdoutLine = { line -> env.logFile.appendText(line + System.lineSeparator()) } + ) + Outcome(env, readSummary(env.jsonReportFile), null) + } catch (t: Throwable) { + logger.error("plugwrightTest [${env.name}]: ${t.message}") + Outcome(env, readSummary(env.jsonReportFile), t) + } + } + + private fun readSummary(file: File): EnvironmentSummary? { + if (!file.exists()) return null + return try { + val root = JsonParser.parseString(file.readText()).asJsonObject + val summary = root.getAsJsonObject("summary") + EnvironmentSummary( + total = summary.get("total").asInt, + passed = summary.get("passed").asInt, + failed = summary.get("failed").asInt, + skipped = summary.get("skipped").asInt, + durationMs = summary.get("durationMs").asLong, + ) + } catch (_: Exception) { + null + } + } + + private fun printSummaryTable(outcomes: List) { + val nameWidth = outcomes.maxOf { it.env.name.length } + logger.lifecycle("") + logger.lifecycle("Environment summaries:") + for ((env, summary, error) in outcomes) { + val label = env.name.padEnd(nameWidth) + val flag = if (env.allowFailure) " [allowFailure]" else "" + if (summary != null) { + logger.lifecycle(" $label ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped (${formatDuration(summary.durationMs)})$flag") + } else { + logger.lifecycle(" $label ERROR: ${error?.message ?: "no report produced"}$flag") + } + } + logger.lifecycle("") + } + + private fun formatDuration(ms: Long): String { + val totalSeconds = ms / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s" + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt new file mode 100644 index 0000000..bbea1f9 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt @@ -0,0 +1,137 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.PluginRef +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* + +/** + * Runs the compiled test suite against one environment. + * + * Mode-agnostic: whichever mode owns this environment prepares whatever it needs through + * its own tasks (wired in via [me.drownek.plugwright.api.TaskRegistrationContext.prepareTask]) + * and hands over the mode-specific part of the runner config through [environmentConfig]. + */ +abstract class PlugwrightTestTask : AbstractNodeTask() { + + /** + * Root of the npm project: `plugwright.testsDir`. The runner is pointed at the compiled + * specs inside it — see [RunnerLauncher.writeConfig]. + * + * Not an input directory, for the same reason as in [PlugwrightCompileTestsTask]: this + * task always runs, and the workspace now contains the server's own generated files. + */ + @get:Internal + abstract val testsDir: DirectoryProperty + + @get:Input + @get:Optional + abstract val testFiles: Property + + @get:Input + @get:Optional + abstract val testNames: Property + + /** Name of the environment under test. Written into the runner config and report names. */ + @get:Input + abstract val environmentName: Property + + /** Mode id this environment runs under (`local`, `external`, …). */ + @get:Input + abstract val modeId: Property + + /** Test name substrings to skip in this environment. */ + @get:Input + @get:Optional + abstract val excludeTests: ListProperty + + /** + * The mode-specific part of the runner config (`environment.config`). Set by the plugin + * from either [me.drownek.plugwright.api.PlugwrightMode.serialize] or the mode's own + * [me.drownek.plugwright.api.TaskRegistrationContext.environmentConfig] override. + */ + @get:Internal + abstract val environmentConfig: Property + + /** Runner plugins this environment loads, from [me.drownek.plugwright.api.TaskRegistrationContext.pluginConfigs]. */ + @get:Internal + abstract val pluginConfigs: ListProperty + + /** 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 + + /** Where the runner writes its JSON report (`build/reports/plugwright/.json`). */ + @get:OutputFile + abstract val jsonReportFile: RegularFileProperty + + /** Where the runner writes its JUnit XML report (`build/reports/plugwright/junit/.xml`). */ + @get:OutputFile + abstract val junitReportFile: RegularFileProperty + + init { + group = "verification" + description = "Run E2E tests for Paper plugin" + // Declaring the config file as an output must not make the run itself skippable: + // the test result depends on the plugin, the server and the spec files alike. + outputs.upToDateWhen { false } + } + + @TaskAction + fun runTests() { + val nodePaths = resolveNode() + + val workspace = if (testsDir.isPresent) { + testsDir.get().asFile + } else { + logger.warn("Tests directory not configured") + return + } + + if (!workspace.exists()) { + logger.warn("Tests directory does not exist: ${workspace.absolutePath}") + return + } + + logger.lifecycle("Running E2E tests for environment '${environmentName.get()}'...") + + val configDestination = configFile.get().asFile + val entry = RunnerLauncher.Entry( + environmentName = environmentName.get(), + modeId = modeId.get(), + environmentConfig = environmentConfig.get(), + workspaceDir = workspace, + configFile = configDestination, + testFiles = with(RunnerLauncher) { testFiles.orNull.splitFilter() }, + testNames = with(RunnerLauncher) { testNames.orNull.splitFilter() }, + excludeTests = if (excludeTests.isPresent) excludeTests.get() else emptyList(), + jsonReportFile = jsonReportFile.get().asFile, + junitReportFile = junitReportFile.get().asFile, + pluginConfigs = pluginConfigs.get(), + runtimePackage = runtimePackage.orNull, + runtimeExport = runtimeExport.orNull, + ) + RunnerLauncher.writeConfig(entry) + logger.lifecycle("Runner config: ${configDestination.absolutePath}") + + val cliJsFile = RunnerLauncher.resolveCliJs(workspace) + + runCommand(workspace, nodePaths.node, cliJsFile.absolutePath, "--config", configDestination.absolutePath) + + logger.lifecycle("E2E tests completed successfully") + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerConfigWriter.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerConfigWriter.kt new file mode 100644 index 0000000..f172ee3 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerConfigWriter.kt @@ -0,0 +1,63 @@ +package me.drownek.plugwright + +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.ConfigValue +import me.drownek.plugwright.api.SecretRef +import java.io.File + +/** + * Renders the runner configuration file passed to the CLI as `--config`. + * + * Secrets are written as references, never as values: the file lands in `build/` and + * would otherwise leak passwords into build artifacts. + */ +object RunnerConfigWriter { + + /** Bumped when the file layout changes in a way the runner must notice. */ + const val CONFIG_VERSION: Int = 1 + + private val gson = GsonBuilder() + .setPrettyPrinting() + .disableHtmlEscaping() + .serializeNulls() + .create() + + fun write(destination: File, root: ConfigNode): File { + destination.parentFile?.mkdirs() + destination.writeText(gson.toJson(toJson(root)), Charsets.UTF_8) + return destination + } + + fun toJson(value: ConfigValue): JsonElement = when (value) { + is ConfigValue.Str -> JsonPrimitive(value.value) + is ConfigValue.Num -> JsonPrimitive(value.value) + is ConfigValue.Bool -> JsonPrimitive(value.value) + is ConfigValue.Secret -> toJson(value.ref) + is ConfigValue.Arr -> JsonArray().apply { value.values.forEach { add(toJson(it)) } } + is ConfigValue.Obj -> JsonObject().apply { value.entries.forEach { (k, v) -> add(k, toJson(v)) } } + ConfigValue.Null -> JsonNull.INSTANCE + } + + private fun toJson(ref: SecretRef): JsonObject = JsonObject().apply { + when (ref) { + is SecretRef.FromEnv -> { + addProperty("from", "env") + addProperty("name", ref.name) + } + is SecretRef.FromFile -> { + addProperty("from", "file") + addProperty("path", ref.path) + } + is SecretRef.FromSystemProperty -> { + addProperty("from", "systemProperty") + addProperty("name", ref.name) + } + } + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt new file mode 100644 index 0000000..344d4aa --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt @@ -0,0 +1,112 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.ConfigNodeBuilder +import me.drownek.plugwright.api.PluginRef +import me.drownek.plugwright.api.PlugwrightLayout +import org.gradle.api.GradleException +import java.io.File + +/** + * Config-writing and `cli.js` resolution shared by [PlugwrightTestTask] (one environment), + * [PlugwrightMatrixTask] (many, in one process each), and the service tasks a mode registers + * for itself (ping). Process execution itself stays on + * [AbstractNodeTask] — every task type here extends it and already has `runCommand`/`resolveNode`. + */ +object RunnerLauncher { + + /** Everything needed to write one environment's `config.json` and locate its `cli.js`. + * [jsonReportFile]/[junitReportFile] are omitted for service runs (`--ping`) + * that never produce a report. */ + data class Entry( + val environmentName: String, + val modeId: String, + val environmentConfig: ConfigNode, + /** Root of the npm project. The directory the runner actually scans is derived from + * it — see [PlugwrightLayout.runnableTestsDir]. */ + val workspaceDir: File, + val configFile: File, + val testFiles: List?, + val testNames: List?, + val excludeTests: List, + 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, + ) + + fun writeConfig(entry: Entry) { + val root = ConfigNodeBuilder().apply { + put("version", RunnerConfigWriter.CONFIG_VERSION) + 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") { + put("dir", PlugwrightLayout.of(entry.workspaceDir).runnableTestsDir().absolutePath) + if (entry.testFiles != null) putStrings("include", entry.testFiles) else putNull("include") + if (entry.testNames != null) putStrings("names", entry.testNames) else putNull("names") + if (entry.excludeTests.isNotEmpty()) putStrings("exclude", entry.excludeTests) else putNull("exclude") + // null means "runner default", which TEST_TIMEOUT can still override. + putNull("timeoutMs") + } + if (entry.jsonReportFile != null || entry.junitReportFile != null) { + obj("reports") { + entry.jsonReportFile?.let { put("json", it.absolutePath) } + entry.junitReportFile?.let { put("junit", it.absolutePath) } + } + } + if (entry.pluginConfigs.isNotEmpty()) { + array("plugins") { + entry.pluginConfigs.forEach { ref -> + obj { + put("specifier", ref.specifier) + put("inheritTests", ref.inheritTests) + if (ref.options.isNotEmpty()) { + obj("options") { ref.options.forEach { (k, v) -> put(k, v) } } + } + } + } + } + } + }.build() + + RunnerConfigWriter.write(entry.configFile, root) + } + + /** Resolves `cli.js` relative to the workspace's `node_modules`, falling back to the + * in-repo build for `example_plugin`-style development setups. */ + fun resolveCliJs(workspaceDir: File): File { + val defaultCliJs = File(workspaceDir, "node_modules/@plugwright/runner/dist/cli.js") + return sequenceOf( + // Canonical path resolves npm symlink bugs on CI + defaultCliJs.canonicalFile, + defaultCliJs, + // Dev-environment fallback when running inside this repository + File(workspaceDir, "../../../../runner-package/dist/cli.js") + ).firstOrNull { it.exists() } + ?: throw GradleException( + "plugwright cli.js not found at ${defaultCliJs.absolutePath}. " + + "Did 'npm install' succeed in ${workspaceDir.absolutePath}?" + ) + } + + /** Splits a comma-separated `-P` property value the same way for every task. */ + fun String?.splitFilter(): List? = + this?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.takeIf { it.isNotEmpty() } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt new file mode 100644 index 0000000..e4bb5e3 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt @@ -0,0 +1,92 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.PluginRef +import me.drownek.plugwright.api.PlugwrightLayout +import me.drownek.plugwright.api.TaskRegistrationContext +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import java.io.File + +/** + * A task registered through [register] gets a name of the form `plugwright`. + * When [environmentName] is the build's primary environment, the first registration of a + * given suffix also gets a bare `plugwright` alias. + */ +internal class TaskRegistrationContextImpl( + override val project: Project, + override val environmentName: String, + private val isPrimary: Boolean, + override val projectPluginJar: Provider, + override val testsDir: Provider, + override val layout: PlugwrightLayout, + private val extension: PlugwrightExtension, + private val nodeInstallDir: File +) : TaskRegistrationContext { + + /** Set by [prepareTask]; read by the plugin once every mode has registered its tasks. */ + var prepareTaskRef: TaskProvider? = null + private set + + /** Set by [environmentConfig]; when null, the plugin falls back to [me.drownek.plugwright.api.PlugwrightMode.serialize]. */ + var environmentConfigProvider: Provider? = null + private set + + /** Set by [pluginConfigs]; when null, the environment loads no runner plugins. */ + var pluginConfigsProvider: Provider>? = null + private set + + private val aliasedSuffixes = mutableSetOf() + + override fun register(suffix: String, type: Class, action: T.() -> Unit): TaskProvider = + registerInternal(suffix, type, aliasBare = true, action) + + /** + * Same as [register], but never creates the bare `plugwright` alias. Used by core + * itself for the "Test" suffix: `plugwrightTest` is claimed by [PlugwrightMatrixTask] + * instead, which runs the matrix rather than aliasing to one arbitrary environment. + */ + fun registerWithoutAlias(suffix: String, type: Class, action: T.() -> Unit): TaskProvider = + registerInternal(suffix, type, aliasBare = false, action) + + private fun registerInternal(suffix: String, type: Class, aliasBare: Boolean, action: T.() -> Unit): TaskProvider { + val envSuffix = environmentName.replaceFirstChar { it.uppercaseChar() } + val taskName = "plugwright$suffix$envSuffix" + val provider = project.tasks.register(taskName, type) { + // A mode's task that shells out to Node gets the same Node resolution as core's + // own tasks, without every mode having to know where the shared cache lives. + if (this is AbstractNodeTask) { + nodeVersion.set(extension.nodeVersion) + downloadNode.set(extension.downloadNode) + this.nodeInstallDir.set(this@TaskRegistrationContextImpl.nodeInstallDir) + } + action() + } + + if (aliasBare && isPrimary && aliasedSuffixes.add(suffix)) { + val aliasName = "plugwright$suffix" + if (project.tasks.findByName(aliasName) == null) { + project.tasks.register(aliasName) { + group = "verification" + description = "Alias for $taskName (primary environment '$environmentName')" + dependsOn(provider) + } + } + } + return provider + } + + override fun prepareTask(task: TaskProvider) { + prepareTaskRef = task + } + + override fun environmentConfig(node: Provider) { + environmentConfigProvider = node + } + + override fun pluginConfigs(refs: Provider>) { + pluginConfigsProvider = refs + } +} diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ValidationContextImpl.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ValidationContextImpl.kt new file mode 100644 index 0000000..f365d6f --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/ValidationContextImpl.kt @@ -0,0 +1,22 @@ +package me.drownek.plugwright + +import me.drownek.plugwright.api.ValidationContext +import org.gradle.api.logging.Logger + +/** Warnings are logged immediately; errors are collected so the plugin can report every + * environment's problems in one build failure instead of stopping at the first one. */ +internal class ValidationContextImpl( + override val environmentName: String, + private val logger: Logger +) : ValidationContext { + + val errors = mutableListOf() + + override fun error(message: String) { + errors.add(message) + } + + override fun warn(message: String) { + logger.warn("plugwright [$environmentName]: $message") + } +} diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts new file mode 100644 index 0000000..2aeeb2c --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts @@ -0,0 +1,34 @@ +import { definePlugin } from '@plugwright/runner'; + +/** + * A runner plugin: hooks that run around every test, plus fixtures the tests can + * destructure. Load it by adding this to the environment in build.gradle.kts: + * + * plugins { local("example-plugin") } + * + * The name is the file name — plugwright compiles plugins/example-plugin.ts into + * dist/plugins/example-plugin.js and points the runner at that. + */ +export default definePlugin({ + name: 'example-plugin', + + // Runs before every test, with the bot already connected. + async beforeEach({ player, server }) { + if (!server.session.env.capabilities.console) return; + + await player.clearInventory(); + }, + + // What this returns becomes part of the object every test destructures: + // test('...', async ({ player, say }) => { ... }) + extendContext({ player }) { + return { say: (message: string) => player.chat(message) }; + }, +}); + +// Without this block the fixture still works and TypeScript still complains. +declare module '@plugwright/runner' { + interface TestContext { + say: (message: string) => void; + } +} diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts new file mode 100644 index 0000000..a8df0f0 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts @@ -0,0 +1,6 @@ +import {expect, test} from '@plugwright/runner'; + +test('help displays message', async ({ player, server }) => { + player.chat('/help'); + await expect(player).toHaveReceivedMessage('Help'); +}); diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore new file mode 100644 index 0000000..921a22a --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore @@ -0,0 +1,8 @@ +# Installed by plugwrightCompileTests +node_modules/ +# Generated from the npm { } block; may hold registry credentials +.npmrc +# Compiled specs and plugins +dist/ +# Whatever the environments write while they run: servers, worlds, logs +generated/ diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json new file mode 100644 index 0000000..01a05a4 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json @@ -0,0 +1,14 @@ +{ + "type": "module", + "scripts": { + "build": "rimraf dist && tsc" + }, + "dependencies": { + "@plugwright/runner": "@runnerVersion@" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + } +} diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json new file mode 100644 index 0000000..a591734 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": [ + "tests/**/*.ts", + "plugins/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "generated" + ] +} diff --git a/gradle-plugin/plugwright-external/build.gradle.kts b/gradle-plugin/plugwright-external/build.gradle.kts new file mode 100644 index 0000000..ce5af71 --- /dev/null +++ b/gradle-plugin/plugwright-external/build.gradle.kts @@ -0,0 +1,8 @@ +dependencies { + implementation(gradleApi()) + implementation(project(":plugwright-core")) + + // Compile-time only: its classes reach the runtime classpath through the bundle module's + // merged jar, which is what actually gets published. + compileOnly(project(":plugwright-api")) +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt new file mode 100644 index 0000000..72e9f20 --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/AccountsSpec.kt @@ -0,0 +1,73 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.api.SecretRef +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property + +/** One named account in the fixed `pool`. */ +class PoolAccountSpec(val username: String, objects: ObjectFactory) { + val password: Property = objects.property(SecretRef::class.java) +} + +/** `pool { account("TestBot1") { password.set(...) } }`. */ +class PoolSpec(private val objects: ObjectFactory) { + internal val accounts = mutableListOf() + + fun account(username: String, action: PoolAccountSpec.() -> Unit) { + accounts.add(PoolAccountSpec(username, objects).apply(action)) + } +} + +/** `autoRegister { usernamePattern.set("pw_%04d"); password.set(...); max.set(4) }`. Generates + * accounts on demand, up to [max] connected at once; each one registers on its first login. */ +class AutoRegisterSpec(objects: ObjectFactory) { + /** + * Must start with `pw_` — generated accounts have to be recognizable as test accounts. + * + * The placeholder decides what happens to a name once the test holding it finishes. + * `%d` (optionally zero-padded, `%04d`) numbers a fixed set of accounts the run keeps coming + * back to: cheap, but every test inherits whatever the last one left on that account, so + * anything the stand cannot reset has to stay out of the suite. `%s` puts a random suffix + * there instead (`pw_%s` → `pw_a8f2`) and never reuses a name, which is the only way a test + * gets an account with no history — at the cost of a registration the server keeps after the + * run, so a stand on this shape needs its own way to prune old test accounts. + */ + val usernamePattern: Property = objects.property(String::class.java).convention("pw_%04d") + val password: Property = objects.property(SecretRef::class.java) + + /** How many generated accounts can be connected at the same time. With `%d` it is also the + * total number of accounts that will ever exist; with `%s` the run keeps making new ones. */ + val max: Property = objects.property(Int::class.java).convention(4) +} + +/** `microsoft { account("bot@example.com"); cacheDir.set(...) }`. Online-mode accounts; + * no password — mineflayer authenticates through a cached Microsoft token. */ +class MicrosoftAccountsSpec(objects: ObjectFactory) { + internal val accountNames = mutableListOf() + val cacheDir: DirectoryProperty = objects.directoryProperty() + + fun account(usernameOrEmail: String) { + accountNames.add(usernameOrEmail) + } +} + +/** `accounts { pool { ... }; autoRegister { ... }; microsoft { ... } }` — the three sources an + * account pool merges at runtime. All three are optional and independent. */ +class AccountsSpec(private val objects: ObjectFactory) { + internal var pool: PoolSpec? = null + internal var autoRegister: AutoRegisterSpec? = null + internal var microsoft: MicrosoftAccountsSpec? = null + + fun pool(action: PoolSpec.() -> Unit) { + pool = PoolSpec(objects).apply(action) + } + + fun autoRegister(action: AutoRegisterSpec.() -> Unit) { + autoRegister = AutoRegisterSpec(objects).apply(action) + } + + fun microsoft(action: MicrosoftAccountsSpec.() -> Unit) { + microsoft = MicrosoftAccountsSpec(objects).apply(action) + } +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt new file mode 100644 index 0000000..dc05c48 --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ConsoleSpec.kt @@ -0,0 +1,30 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.api.SecretRef +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property + +/** One console channel a build script can declare. Channels are probed in declaration order + * at runtime; the first one that connects becomes the session's console. */ +sealed class ConsoleChannelSpec { + + /** `console { rcon { port.set(25575); password.set(secret.env("RCON_PASS")) } }`. */ + class Rcon(objects: ObjectFactory) : ConsoleChannelSpec() { + val port: Property = objects.property(Int::class.java).convention(25575) + val password: Property = objects.property(SecretRef::class.java) + } +} + +/** + * `console { rcon { ... } }`. + * + * Declaring no channel is valid — the environment just runs without a console, and any + * test requiring one is skipped and reported as such. + */ +class ConsoleSpec(private val objects: ObjectFactory) { + internal val channels = mutableListOf() + + fun rcon(action: ConsoleChannelSpec.Rcon.() -> Unit) { + channels.add(ConsoleChannelSpec.Rcon(objects).apply(action)) + } +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt new file mode 100644 index 0000000..15a0e70 --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalEnvironmentSpec.kt @@ -0,0 +1,53 @@ +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 + +/** + * Build-script description of an already-running server: bots connect to [host]:[port] + * instead of anything this mode spawns, patches or owns. Deploying the plugin under test onto + * that server is left to the user — this mode assumes it's already installed. + */ +class ExternalEnvironmentSpec(private val environmentName: String, private val objects: ObjectFactory) : EnvironmentSpec { + + override fun getName(): String = environmentName + + // Opt-in, unlike local's opt-out default: a shared external stand shouldn't join every + // local `plugwrightTest` run unasked. + override val includeInMatrix: Property = objects.property(Boolean::class.java).convention(false) + override val allowFailure: Property = objects.property(Boolean::class.java).convention(false) + override val excludeTests: ListProperty = objects.listProperty(String::class.java).convention(emptyList()) + + val host: Property = objects.property(String::class.java) + val port: Property = objects.property(Int::class.java).convention(25565) + + /** Mandatory: a proxy in front of the stand (ViaVersion and similar) defeats automatic + * protocol version detection, so this can't default to "whatever the server reports". */ + val minecraftVersion: Property = objects.property(String::class.java) + + /** Minimum delay between two bot connects, to stay under anti-bot heuristics on a shared + * public server. Zero means "connect as fast as possible", same as today. */ + val joinThrottleMs: Property = objects.property(Long::class.java).convention(0L) + + internal var consoleSpec: ConsoleSpec? = null + internal val accountsSpec: AccountsSpec = AccountsSpec(objects) + internal val pluginsSpec: PluginsSpec = PluginsSpec() + + /** `console { rcon { ... } }`. */ + fun console(action: ConsoleSpec.() -> Unit) { + consoleSpec = ConsoleSpec(objects).apply(action) + } + + /** `accounts { pool { ... }; autoRegister { ... }; microsoft { ... } }`. */ + fun accounts(action: AccountsSpec.() -> Unit) { + accountsSpec.action() + } + + /** `plugins { npm(...); local(...) }`. */ + fun plugins(action: PluginsSpec.() -> Unit) { + pluginsSpec.action() + } +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt new file mode 100644 index 0000000..c5cf9a0 --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/ExternalMode.kt @@ -0,0 +1,123 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.api.ConfigNodeBuilder +import me.drownek.plugwright.api.PlugwrightMode +import me.drownek.plugwright.api.RunnerPackageRef +import me.drownek.plugwright.api.TaskRegistrationContext +import me.drownek.plugwright.api.ValidationContext +import org.gradle.api.model.ObjectFactory + +/** + * Built-in mode: attaches bots to a server that's already running somewhere, instead of + * spawning and owning one. No provisioning step, no deploy of the jar under test — the + * counterpart of everything [me.drownek.plugwright.local.LocalMode] does for a local Paper. + */ +object ExternalMode : PlugwrightMode { + override val id = "external" + override val specType = ExternalEnvironmentSpec::class.java + + override fun createSpec(name: String, objects: ObjectFactory): ExternalEnvironmentSpec = + ExternalEnvironmentSpec(name, objects) + + override fun runnerPackages(spec: ExternalEnvironmentSpec): List = listOf( + RunnerPackageRef("@plugwright/runner", export = "externalEnvironment") + ) + + override fun validate(spec: ExternalEnvironmentSpec, ctx: ValidationContext) { + if (!spec.host.isPresent || spec.host.get().isBlank()) { + ctx.error("host must be set") + } + if (!spec.minecraftVersion.isPresent || spec.minecraftVersion.get().isBlank()) { + ctx.error("minecraftVersion must be set (a proxy in front of the stand defeats automatic protocol detection)") + } + + spec.accountsSpec.autoRegister?.let { autoRegister -> + val pattern = autoRegister.usernamePattern.getOrElse("") + if (!pattern.startsWith("pw_")) { + ctx.error("accounts.autoRegister.usernamePattern must start with \"pw_\" (got \"$pattern\") — generated accounts must be recognizable as test accounts") + } + if (autoRegister.max.getOrElse(0) <= 0) { + ctx.error("accounts.autoRegister.max must be positive") + } + } + + for (channel in spec.consoleSpec?.channels ?: emptyList()) { + when (channel) { + is ConsoleChannelSpec.Rcon -> + if (!channel.password.isPresent) ctx.error("console.rcon.password must be set") + } + } + } + + override fun serialize(spec: ExternalEnvironmentSpec, node: ConfigNodeBuilder) { + node.put("host", spec.host.get()) + node.put("port", spec.port.get()) + node.put("minecraftVersion", spec.minecraftVersion.get()) + node.put("joinThrottleMs", spec.joinThrottleMs.get()) + + node.array("console") { + (spec.consoleSpec?.channels ?: emptyList()).forEach { channel -> + obj { + when (channel) { + is ConsoleChannelSpec.Rcon -> { + put("kind", "rcon") + put("port", channel.port.get()) + put("password", channel.password.get()) + } + } + } + } + } + + node.obj("accounts") { + array("pool") { + (spec.accountsSpec.pool?.accounts ?: emptyList()).forEach { account -> + obj { + put("username", account.username) + put("password", account.password.get()) + } + } + } + val autoRegister = spec.accountsSpec.autoRegister + if (autoRegister != null) { + obj("autoRegister") { + put("usernamePattern", autoRegister.usernamePattern.get()) + put("password", autoRegister.password.get()) + put("max", autoRegister.max.get()) + } + } else { + putNull("autoRegister") + } + val microsoft = spec.accountsSpec.microsoft + if (microsoft != null) { + obj("microsoft") { + putStrings("accounts", microsoft.accountNames) + if (microsoft.cacheDir.isPresent) { + put("cacheDir", microsoft.cacheDir.get().asFile.absolutePath) + } + } + } else { + putNull("microsoft") + } + } + } + + override fun registerTasks(spec: ExternalEnvironmentSpec, ctx: TaskRegistrationContext) { + val project = ctx.project + val envName = spec.name + + ctx.pluginConfigs(project.provider { spec.pluginsSpec.refs() }) + val configProvider = project.provider { ConfigNodeBuilder().also { serialize(spec, it) }.build() } + + ctx.register("Ping", PlugwrightPingTask::class.java) { + environmentName.set(envName) + modeId.set(id) + testsDir.set(ctx.testsDir) + configFile.set(project.layout.buildDirectory.file("tmp/plugwright/$envName-ping.json")) + environmentConfig.set(configProvider) + } + + // No prepareTask: unlike local, external doesn't provision anything before + // plugwrightTest — the stand is assumed to already be up. + } +} diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt new file mode 100644 index 0000000..85933e5 --- /dev/null +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt @@ -0,0 +1,63 @@ +package me.drownek.plugwright.external + +import me.drownek.plugwright.AbstractNodeTask +import me.drownek.plugwright.RunnerLauncher +import me.drownek.plugwright.api.ConfigNode +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.io.File + +/** + * `plugwrightPing`: connects to the environment, probes its declared console channel(s) + * in order, and verifies authentication — no test files are run. Meant as the first thing to + * run against a new external stand, before trusting it with the real matrix. + */ +abstract class PlugwrightPingTask : AbstractNodeTask() { + + @get:Internal + abstract val testsDir: Property + + @get:Input + abstract val environmentName: Property + + @get:Input + abstract val modeId: Property + + @get:Internal + abstract val environmentConfig: Property + + @get:OutputFile + abstract val configFile: RegularFileProperty + + init { + group = "verification" + description = "Checks that an external environment is reachable and authentication works, without running tests." + outputs.upToDateWhen { false } + } + + @TaskAction + fun ping() { + val nodePaths = resolveNode() + val userTestsDirectory = testsDir.get() + + val entry = RunnerLauncher.Entry( + environmentName = environmentName.get(), + modeId = modeId.get(), + environmentConfig = environmentConfig.get(), + workspaceDir = userTestsDirectory, + configFile = configFile.get().asFile, + testFiles = null, + testNames = null, + excludeTests = emptyList(), + ) + RunnerLauncher.writeConfig(entry) + logger.lifecycle("Runner config: ${entry.configFile.absolutePath}") + + val cliJsFile = RunnerLauncher.resolveCliJs(userTestsDirectory) + runCommand(userTestsDirectory, nodePaths.node, cliJsFile.absolutePath, "--config", entry.configFile.absolutePath, "--ping") + } +} diff --git a/gradle-plugin/plugwright-local/build.gradle.kts b/gradle-plugin/plugwright-local/build.gradle.kts new file mode 100644 index 0000000..d2c2352 --- /dev/null +++ b/gradle-plugin/plugwright-local/build.gradle.kts @@ -0,0 +1,10 @@ +dependencies { + implementation(gradleApi()) + implementation("com.google.code.gson:gson:2.10.1") + implementation("org.yaml:snakeyaml:2.0") + implementation(project(":plugwright-core")) + + // Compile-time only: its classes reach the runtime classpath through the bundle module's + // merged jar, which is what actually gets published. + compileOnly(project(":plugwright-api")) +} diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt new file mode 100644 index 0000000..287ffdf --- /dev/null +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalEnvironmentSpec.kt @@ -0,0 +1,112 @@ +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 +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import java.io.File + +/** Build-script description of a Paper server the runner downloads, patches, spawns and + * tears down itself, reachable at `localhost`. */ +class LocalEnvironmentSpec(private val environmentName: String, objects: ObjectFactory) : EnvironmentSpec { + + override fun getName(): String = environmentName + + override val includeInMatrix: Property = objects.property(Boolean::class.java).convention(true) + override val allowFailure: Property = objects.property(Boolean::class.java).convention(false) + override val excludeTests: ListProperty = objects.listProperty(String::class.java).convention(emptyList()) + + /** Minecraft version for the Paper server (e.g., "1.19.4", "1.20.4"). */ + val minecraftVersion: Property = objects.property(String::class.java).convention("1.19.4") + + /** JVM arguments to pass when starting the server. */ + val jvmArgs: ListProperty = objects.listProperty(String::class.java).convention(listOf("-Xmx2G")) + + /** Whether to accept the Minecraft EULA automatically. */ + val acceptEula: Property = objects.property(Boolean::class.java).convention(true) + + /** Directory where the server will be run from. Created automatically if missing. */ + val runDir: DirectoryProperty = objects.directoryProperty() + + /** Port bots connect on. Currently always bound on `localhost`. */ + val port: Property = objects.property(Int::class.java).convention(25565) + + /** RCON port for the local server. Defaults to 25575. */ + val rconPort: Property = objects.property(Int::class.java).convention(25575) + + /** RCON password for the local server. Static throwaway — the server only listens on localhost. */ + val rconPassword: Property = objects.property(String::class.java).convention("plugwright") + + /** URLs of plugins to download before running tests. */ + val pluginUrls: ListProperty = objects.listProperty(String::class.java).convention(emptyList()) + + /** Files to write into the run directory before the server starts. Populated via [writeFiles]. */ + val runDirFiles: ListProperty = objects.listProperty(RunDirFile::class.java).convention(emptyList()) + + /** Files/folders excluded from deletion during the clean task, relative to [runDir]. */ + val cleanExcludePatterns: ListProperty = objects.listProperty(String::class.java).convention( + listOf("server.jar", ".minecraft-version", "cache", "libraries") + ) + + /** 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. + * ``` + * downloadPlugins { + * url("https://example.com/plugin1.jar") + * } + * ``` + */ + fun downloadPlugins(action: PluginDownloadSpec.() -> Unit) { + val spec = PluginDownloadSpec() + action(spec) + pluginUrls.set(spec.urls) + } + + class PluginDownloadSpec { + internal val urls = mutableListOf() + fun url(pluginUrl: String) { + urls.add(pluginUrl) + } + } + + /** + * DSL method for staging files into the run directory before server start. Paths are + * relative to [runDir]. + */ + fun writeFiles(action: RunDirFileSpec.() -> Unit) { + val spec = RunDirFileSpec() + action(spec) + runDirFiles.set(spec.entries) + } + + class RunDirFileSpec { + internal val entries = mutableListOf() + + /** Write [content] (as UTF-8 text) to [path] relative to the run directory. */ + fun file(path: String, content: String) { + entries.add(RunDirFile(path, content, null)) + } + + /** Copy [sourceFile] to [path] relative to the run directory. */ + fun file(path: String, sourceFile: File) { + entries.add(RunDirFile(path, null, sourceFile)) + } + } +} diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt new file mode 100644 index 0000000..ccf611d --- /dev/null +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt @@ -0,0 +1,144 @@ +package me.drownek.plugwright.local + +import me.drownek.plugwright.api.ConfigNode +import me.drownek.plugwright.api.ConfigNodeBuilder +import me.drownek.plugwright.api.LegacyEnvironmentProperties +import me.drownek.plugwright.api.PlugwrightLayout +import me.drownek.plugwright.api.PlugwrightMode +import me.drownek.plugwright.api.RunnerPackageRef +import me.drownek.plugwright.api.TaskRegistrationContext +import me.drownek.plugwright.api.ValidationContext +import org.gradle.api.model.ObjectFactory +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.provider.Provider +import org.gradle.jvm.toolchain.JavaLauncher +import org.gradle.jvm.toolchain.JavaToolchainService +import java.io.File + +/** + * Built-in mode: downloads Paper, patches its configs, spawns it, and points bots at + * `localhost`. Registered by default wherever the `io.github.drownek.plugwright` id is + * applied. + */ +object LocalMode : PlugwrightMode { + override val id = "local" + override val specType = LocalEnvironmentSpec::class.java + + override fun createSpec(name: String, objects: ObjectFactory): LocalEnvironmentSpec = + LocalEnvironmentSpec(name, objects) + + override fun runnerPackages(spec: LocalEnvironmentSpec): List = listOf( + RunnerPackageRef("@plugwright/runner", export = "localEnvironment") + ) + + override fun validate(spec: LocalEnvironmentSpec, ctx: ValidationContext) { + if (spec.minecraftVersion.get().isBlank()) { + ctx.error("minecraftVersion must not be blank") + } + if (!spec.runDir.isPresent) { + ctx.error("runDir must be set") + } + } + + /** The server lives under the workspace, in this environment's own generated directory, + * unless the build script named a directory itself. */ + override fun applyLayoutDefaults(spec: LocalEnvironmentSpec, layout: PlugwrightLayout) { + if (!spec.runDir.isPresent) { + spec.runDir.set(File(layout.generatedDir(spec.name), "run")) + } + } + + override fun applyLegacyDefaults(spec: LocalEnvironmentSpec, legacy: LegacyEnvironmentProperties) { + spec.minecraftVersion.set(legacy.minecraftVersion) + spec.jvmArgs.set(legacy.jvmArgs) + spec.acceptEula.set(legacy.acceptEula) + spec.runDir.set(legacy.runDir) + spec.pluginUrls.set(legacy.pluginUrls) + spec.runDirFiles.set(legacy.runDirFiles) + spec.cleanExcludePatterns.set(legacy.cleanExcludePatterns) + spec.useExternalPluginsOnly.set(legacy.useExternalPluginsOnly) + } + + override fun serialize(spec: LocalEnvironmentSpec, node: ConfigNodeBuilder) { + // Never actually reached: registerTasks() below always overrides this through + // ctx.environmentConfig(...), since the real javaPath needs the toolchain service + // that only a task (not this configuration-time call) can reach. Implemented anyway + // so the fallback stays correct if that ever changes. + fillConfig(node, spec, resolveJavaPath(null)) + } + + override fun registerTasks(spec: LocalEnvironmentSpec, ctx: TaskRegistrationContext) { + val project = ctx.project + + val clean = ctx.register("Clean", PlugwrightCleanTask::class.java) { + runDir.set(spec.runDir) + cleanExcludePatterns.set(spec.cleanExcludePatterns) + } + + val provision = ctx.register("Provision", PaperProvisionTask::class.java) { + dependsOn(clean) + runDir.set(spec.runDir) + minecraftVersion.set(spec.minecraftVersion) + port.set(spec.port) + pluginJar.set(spec.useExternalPluginsOnly.flatMap { externalOnly -> + if (externalOnly) project.objects.property(File::class.java) + else ctx.projectPluginJar + }) + pluginUrls.set(spec.pluginUrls) + runDirFiles.set(spec.runDirFiles) + rconPort.set(spec.rconPort) + rconPassword.set(spec.rconPassword) + } + + val javaLauncherProvider: Provider? = run { + val javaExtension = project.extensions.findByType(JavaPluginExtension::class.java) + val toolchains = project.extensions.findByType(JavaToolchainService::class.java) + if (javaExtension != null && toolchains != null) toolchains.launcherFor(javaExtension.toolchain) else null + } + + ctx.register("RunServer", PlugwrightRunServerTask::class.java) { + dependsOn(provision) + runDir.set(spec.runDir) + serverJarPath.set(spec.runDir.file("server.jar").map { it.asFile.absolutePath }) + jvmArgs.set(spec.jvmArgs) + acceptEula.set(spec.acceptEula) + javaLauncherProvider?.let { javaLauncher.set(it) } + } + + ctx.prepareTask(provision) + + ctx.pluginConfigs(project.provider { spec.pluginsSpec.refs() }) + + ctx.environmentConfig(project.provider { + buildConfigNode(spec, resolveJavaPath(javaLauncherProvider)) + }) + } + + private fun buildConfigNode(spec: LocalEnvironmentSpec, javaPath: String): ConfigNode = + ConfigNodeBuilder().also { fillConfig(it, spec, javaPath) }.build() + + private fun fillConfig(builder: ConfigNodeBuilder, spec: LocalEnvironmentSpec, javaPath: String) { + val jvmArgs = spec.jvmArgs.get().toMutableList() + if (spec.acceptEula.get() && jvmArgs.none { it.contains("eula.agree") }) { + jvmArgs.add("-Dcom.mojang.eula.agree=true") + } + + builder.put("serverJar", spec.runDir.get().file("server.jar").asFile.absolutePath) + builder.put("serverDir", spec.runDir.get().asFile.absolutePath) + builder.put("javaPath", javaPath) + builder.putStrings("jvmArgs", jvmArgs) + builder.put("minecraftVersion", spec.minecraftVersion.get()) + builder.put("host", "localhost") + builder.put("port", spec.port.get()) + builder.put("rconPort", spec.rconPort.get()) + builder.put("rconPassword", spec.rconPassword.get()) + } + + private fun resolveJavaPath(javaLauncher: Provider?): String { + if (javaLauncher != null && javaLauncher.isPresent) { + return javaLauncher.get().executablePath.asFile.absolutePath + } + val isWindows = System.getProperty("os.name").lowercase().contains("win") + return File(System.getProperty("java.home"), "bin/java" + if (isWindows) ".exe" else "").absolutePath + } +} diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt similarity index 63% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt rename to gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt index 4c9ab9a..c566557 100644 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/AbstractPlugwrightTask.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PaperProvisionTask.kt @@ -1,13 +1,15 @@ -package me.drownek.plugwright +package me.drownek.plugwright.local import com.google.gson.JsonParser +import me.drownek.plugwright.api.RunDirFile import org.gradle.api.DefaultTask +import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* -import org.gradle.jvm.toolchain.JavaLauncher -import org.gradle.api.GradleException +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.Yaml import java.io.File import java.net.URI import java.net.http.HttpClient @@ -16,57 +18,47 @@ import java.net.http.HttpResponse import java.nio.file.Files import java.nio.file.StandardCopyOption import java.time.Duration -import org.yaml.snakeyaml.Yaml -import org.yaml.snakeyaml.DumperOptions -abstract class AbstractPlugwrightTask : DefaultTask() { +/** + * Downloads Paper, stages configured files, patches server/bukkit/spigot configs, and + * installs the plugin under test — everything the local server needs before it can start. + */ +abstract class PaperProvisionTask : DefaultTask() { - @get:Input - abstract val serverJarPath: Property + @get:OutputDirectory + abstract val runDir: DirectoryProperty @get:Input - abstract val serverDir: Property + abstract val minecraftVersion: Property @get:Input - abstract val minecraftVersion: Property + abstract val port: Property @get:Input - abstract val jvmArgs: ListProperty + abstract val rconPort: Property @get:Input - abstract val acceptEula: Property + abstract val rconPassword: Property @get:Input @get:Optional abstract val pluginJar: Property - @get:Nested - @get:Optional - abstract val javaLauncher: Property - @get:Input abstract val pluginUrls: ListProperty @get:Input @get:Optional - abstract val runDirFiles: ListProperty + abstract val runDirFiles: ListProperty - @get:Input - abstract val nodeVersion: Property + init { + group = "verification" + description = "Downloads Paper and prepares the local test server" + } - @get:Input - abstract val downloadNode: Property - - @get:Internal - abstract val nodeInstallDir: DirectoryProperty - - protected fun prepareServerEnvironment(): File { - val serverJar = serverJarPath.get() - val serverDirectory = serverDir.get() - val mcVersion = minecraftVersion.get() - - // Create run directory if it doesn't exist - val runDirectory = File(serverDirectory) + @TaskAction + fun provision() { + val runDirectory = runDir.get().asFile if (!runDirectory.exists() && !runDirectory.mkdirs()) { throw GradleException("Failed to create run directory at ${runDirectory.absolutePath}") } @@ -78,17 +70,19 @@ abstract class AbstractPlugwrightTask : DefaultTask() { filesToWrite.forEach { entry -> val destination = File(runDirectory, entry.path) destination.parentFile?.mkdirs() + val content = entry.content + val sourceFile = entry.sourceFile when { - entry.content != null -> { - destination.writeText(entry.content, Charsets.UTF_8) + content != null -> { + destination.writeText(content, Charsets.UTF_8) logger.lifecycle(" Wrote: ${entry.path}") } - entry.sourceFile != null -> { - if (!entry.sourceFile.exists()) { - throw GradleException("Staged file source does not exist: ${entry.sourceFile.absolutePath}") + sourceFile != null -> { + if (!sourceFile.exists()) { + throw GradleException("Staged file source does not exist: ${sourceFile.absolutePath}") } - Files.copy(entry.sourceFile.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) - logger.lifecycle(" Copied: ${entry.sourceFile.name} -> ${entry.path}") + Files.copy(sourceFile.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + logger.lifecycle(" Copied: ${sourceFile.name} -> ${entry.path}") } } } @@ -98,8 +92,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { val serverProperties = File(runDirectory, "server.properties") if (serverProperties.exists()) { var lines = Files.readAllLines(serverProperties.toPath()).toMutableList() - - // Update or add online-mode=false + val hasOnlineMode = lines.any { it.trim().startsWith("online-mode=") } if (hasOnlineMode) { lines = lines.map { line -> @@ -108,8 +101,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } else { lines.add("online-mode=false") } - - // Update or add connection-throttle=0 (required for E2E tests to prevent "Connection throttled" errors) + val hasConnectionThrottle = lines.any { it.trim().startsWith("connection-throttle=") } if (hasConnectionThrottle) { lines = lines.map { line -> @@ -119,6 +111,16 @@ abstract class AbstractPlugwrightTask : DefaultTask() { lines.add("connection-throttle=0") } + val portLine = "server-port=${port.get()}" + val hasPort = lines.any { it.trim().startsWith("server-port=") } + if (hasPort) { + lines = lines.map { line -> + if (line.trim().startsWith("server-port=")) portLine else line + }.toMutableList() + } else { + lines.add(portLine) + } + // Disable spawn protection so tests can damage players near spawn val hasSpawnProtection = lines.any { it.trim().startsWith("spawn-protection=") } if (hasSpawnProtection) { @@ -128,17 +130,34 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } else { lines.add("spawn-protection=0") } - + + // Enable RCON so the runner can send commands over a proper protocol + val rconProperties = mapOf( + "enable-rcon" to "true", + "rcon.port" to rconPort.get().toString(), + "rcon.password" to rconPassword.get() + ) + for ((key, value) in rconProperties) { + val hasKey = lines.any { it.trim().startsWith("$key=") } + if (hasKey) { + lines = lines.map { line -> + if (line.trim().startsWith("$key=")) "$key=$value" else line + }.toMutableList() + } else { + lines.add("$key=$value") + } + } + Files.write(serverProperties.toPath(), lines) } else { - logger.lifecycle("Creating server.properties with online-mode=false, connection-throttle=0 and spawn-protection=0") - Files.write(serverProperties.toPath(), listOf("online-mode=false", "connection-throttle=0", "spawn-protection=0")) + logger.lifecycle("Creating server.properties with online-mode=false, connection-throttle=0, spawn-protection=0, enable-rcon=true and server-port=${port.get()}") + Files.write( + serverProperties.toPath(), + listOf("online-mode=false", "connection-throttle=0", "spawn-protection=0", "server-port=${port.get()}", "enable-rcon=true", "rcon.port=${rconPort.get()}", "rcon.password=${rconPassword.get()}") + ) } - // Configure bukkit.yml settings configureBukkitSettings(runDirectory) - - // Configure spigot.yml settings configureSpigotSettings(runDirectory) // Create plugins directory if it doesn't exist @@ -146,7 +165,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { if (!pluginsDir.exists() && !pluginsDir.mkdirs()) { throw GradleException("Failed to create plugins directory at ${pluginsDir.absolutePath}") } - + // Copy the project plugin to the server if (pluginJar.isPresent) { val jarFile = pluginJar.get() @@ -172,55 +191,59 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } // Download Paper server if needed - val serverJarFile = File(serverJar) - if (!serverJarFile.exists()) { - logger.lifecycle("Server JAR not found. Downloading Paper server for Minecraft $mcVersion...") - downloadPaperServer(mcVersion, serverJarFile) + val serverJarFile = File(runDirectory, "server.jar") + val versionMarkerFile = File(runDirectory, ".minecraft-version") + val requestedVersion = minecraftVersion.get() + val currentVersion = if (versionMarkerFile.exists()) versionMarkerFile.readText().trim() else null + + if (!serverJarFile.exists() || currentVersion != requestedVersion) { + val reason = if (!serverJarFile.exists()) "not found" else "version mismatch (found $currentVersion, requested $requestedVersion)" + logger.lifecycle("Server JAR $reason. Downloading Paper server for Minecraft $requestedVersion...") + downloadPaperServer(requestedVersion, serverJarFile) + versionMarkerFile.writeText(requestedVersion) } - - return runDirectory } - protected fun downloadPaperServer(version: String, destination: File) { + private fun downloadPaperServer(version: String, destination: File) { val httpClient = HttpClient.newBuilder().build() - + try { logger.lifecycle("Fetching latest Paper build for Minecraft $version...") - + val versionInfoUrl = "https://fill.papermc.io/v3/projects/paper/versions/$version" val versionRequest = HttpRequest.newBuilder() .uri(URI.create(versionInfoUrl)) .GET() .build() - + val versionResponse = httpClient.send(versionRequest, HttpResponse.BodyHandlers.ofString()) - + if (versionResponse.statusCode() != 200) { throw GradleException("Failed to fetch Paper version info. Status: ${versionResponse.statusCode()}. Make sure Minecraft version '$version' is valid.") } - + val versionJson = JsonParser.parseString(versionResponse.body()).asJsonObject val buildsArray = versionJson.getAsJsonArray("builds") - + if (buildsArray.size() == 0) { throw GradleException("No builds found for Minecraft version $version") } - + val latestBuild = buildsArray.last().asInt logger.lifecycle("Found latest build: $latestBuild") - + val buildInfoUrl = "https://fill.papermc.io/v3/projects/paper/versions/$version/builds/$latestBuild" val buildRequest = HttpRequest.newBuilder() .uri(URI.create(buildInfoUrl)) .GET() .build() - + val buildResponse = httpClient.send(buildRequest, HttpResponse.BodyHandlers.ofString()) - + if (buildResponse.statusCode() != 200) { throw GradleException("Failed to fetch build info. Status: ${buildResponse.statusCode()}") } - + val buildJson = JsonParser.parseString(buildResponse.body()).asJsonObject val downloadsJson = buildJson.getAsJsonObject("downloads") val downloadEntry = when { @@ -232,7 +255,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { downloadsJson.getAsJsonObject(firstKey) } } - + val downloadUrl = if (downloadEntry.has("url")) { downloadEntry.get("url").asString } else { @@ -240,29 +263,29 @@ abstract class AbstractPlugwrightTask : DefaultTask() { "https://fill.papermc.io/v3/projects/paper/versions/$version/builds/$latestBuild/downloads/$downloadName" } logger.lifecycle("Downloading Paper server from: $downloadUrl") - + val downloadRequest = HttpRequest.newBuilder() .uri(URI.create(downloadUrl)) .GET() .build() - + val downloadResponse = httpClient.send(downloadRequest, HttpResponse.BodyHandlers.ofInputStream()) - + if (downloadResponse.statusCode() != 200) { throw GradleException("Failed to download Paper server. Status: ${downloadResponse.statusCode()}") } - + destination.parentFile?.mkdirs() Files.copy(downloadResponse.body(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) - + logger.lifecycle("Paper server downloaded successfully to: ${destination.absolutePath}") - + } catch (e: Exception) { throw GradleException("Failed to download Paper server: ${e.message}", e) } } - protected fun downloadPlugin(httpClient: HttpClient, url: String, pluginsDirectory: File) { + private fun downloadPlugin(httpClient: HttpClient, url: String, pluginsDirectory: File) { try { val uri = try { URI.create(url) @@ -306,7 +329,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } } - protected fun configureBukkitSettings(serverDirectory: File) { + private fun configureBukkitSettings(serverDirectory: File) { val bukkitYmlFile = File(serverDirectory, "bukkit.yml") try { @@ -336,7 +359,7 @@ abstract class AbstractPlugwrightTask : DefaultTask() { } } - protected fun configureSpigotSettings(serverDirectory: File) { + private fun configureSpigotSettings(serverDirectory: File) { val spigotYmlFile = File(serverDirectory, "spigot.yml") try { @@ -366,119 +389,4 @@ abstract class AbstractPlugwrightTask : DefaultTask() { logger.warn("Warning: Could not configure spigot.yml: ${e.message}") } } - - protected fun runCommand(dir: File, vararg command: String, env: Map = emptyMap(), interactive: Boolean = false, onStdoutLine: ((String) -> Unit)? = null) { - val isWindows = System.getProperty("os.name").lowercase().contains("win") - val cmdName = File(command[0]).nameWithoutExtension.lowercase() - val cmd = if (isWindows && (cmdName == "npm" || cmdName == "node")) { - listOf("cmd", "/c") + command - } else { - command.toList() - } - - val processBuilder = ProcessBuilder(cmd) - processBuilder.directory(dir) - processBuilder.environment().putAll(env) - - val process = processBuilder.start() - - val shutdownHook = Thread { - if (process.isAlive) killProcessTree(process) - } - Runtime.getRuntime().addShutdownHook(shutdownHook) - try { - runProcess(process, command, interactive, onStdoutLine) - } finally { - try { - Runtime.getRuntime().removeShutdownHook(shutdownHook) - } catch (_: IllegalStateException) {} - } - } - - protected fun runProcess(process: Process, command: Array, interactive: Boolean = false, onStdoutLine: ((String) -> Unit)? = null) { - val stdoutThread = Thread { - process.inputStream.bufferedReader(Charsets.UTF_8).useLines { lines -> - lines.forEach { line -> - logger.lifecycle(line) - onStdoutLine?.invoke(line) - } - } - } - stdoutThread.isDaemon = true - - val stderrThread = Thread { - process.errorStream.bufferedReader(Charsets.UTF_8).useLines { lines -> - lines.forEach { logger.error(it) } - } - } - stderrThread.isDaemon = true - - var stdinThread: Thread? = null - if (interactive) { - stdinThread = Thread { - try { - val reader = System.`in`.bufferedReader(Charsets.UTF_8) - val out = process.outputStream - while (true) { - val line = reader.readLine() ?: break - out.write((line + "\n").toByteArray(Charsets.UTF_8)) - out.flush() - } - } catch (_: Exception) {} - } - stdinThread.isDaemon = true - stdinThread.start() - } - - stdoutThread.start() - stderrThread.start() - - val exitCode = try { - process.waitFor() - } catch (e: InterruptedException) { - logger.lifecycle("[E2E] Build cancelled, gracefully terminating server process tree...") - - killProcessTree(process) - - // Re-interrupt the thread after doing the cleanup - Thread.currentThread().interrupt() - throw RuntimeException("E2E build cancelled; spawned server was terminated.", e) - } - - try { stdoutThread.join(2000) } catch (_: InterruptedException) {} - try { stderrThread.join(2000) } catch (_: InterruptedException) {} - - if (exitCode != 0) { - throw RuntimeException("Command '${command.joinToString(" ")}' failed with exit code: $exitCode") - } - } - - protected fun killProcessTree(process: Process) { - try { - val isJava = process.info().command().orElse("")?.contains("java") ?: false - if (isJava) { - try { - val out = process.outputStream - out.write("stop\n".toByteArray()) - out.flush() - } catch (_: Exception) {} - process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS) - } - - val handle = process.toHandle() - val descendants = handle.descendants().toList() - - // Kill parent first to prevent respawning - handle.destroyForcibly() - process.waitFor(2, java.util.concurrent.TimeUnit.SECONDS) - - // Then kill descendants - descendants.forEach { - try { it.destroyForcibly() } catch (_: Throwable) {} - } - - } catch (_: Throwable) { - // best effort - } - } } diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightCleanTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightCleanTask.kt new file mode 100644 index 0000000..376129d --- /dev/null +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightCleanTask.kt @@ -0,0 +1,57 @@ +package me.drownek.plugwright.local + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction + +/** Wipes the local run directory for a clean slate, keeping whatever [cleanExcludePatterns] names. */ +abstract class PlugwrightCleanTask : DefaultTask() { + + @get:Internal + abstract val runDir: DirectoryProperty + + @get:Input + abstract val cleanExcludePatterns: ListProperty + + init { + group = "verification" + description = "Wipes the test server data for a clean slate." + } + + @TaskAction + fun clean() { + val dir = runDir.get().asFile + val excludePatterns = cleanExcludePatterns.get() + + if (!dir.exists()) { + logger.lifecycle(" Run directory doesn't exist yet, nothing to clean") + return + } + + logger.lifecycle(" Cleaning run directory (excluding: ${excludePatterns.joinToString(", ")})") + + val allEntries = dir.listFiles() ?: emptyArray() + val deletedFiles = mutableListOf() + val keptFiles = mutableListOf() + + allEntries.forEach { entry -> + val shouldExclude = entry.name == ".minecraft-version" || excludePatterns.any { pattern -> entry.name == pattern } + if (!shouldExclude) { + deletedFiles.add(entry.name) + project.delete(entry) + } else { + keptFiles.add(entry.name) + } + } + + if (deletedFiles.isNotEmpty()) { + logger.lifecycle(" deleted: ${deletedFiles.joinToString(", ")}") + } + if (keptFiles.isNotEmpty()) { + logger.lifecycle(" preserved: ${keptFiles.joinToString(", ")}") + } + } +} diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightRunServerTask.kt similarity index 55% rename from gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt rename to gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightRunServerTask.kt index 4c06de1..8a6a5e0 100644 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightRunTask.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/PlugwrightRunServerTask.kt @@ -1,9 +1,31 @@ -package me.drownek.plugwright +package me.drownek.plugwright.local -import org.gradle.api.tasks.TaskAction +import me.drownek.plugwright.AbstractNodeTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* +import org.gradle.jvm.toolchain.JavaLauncher import java.io.File -abstract class PlugwrightRunTask : AbstractPlugwrightTask() { +/** Starts the local Paper server interactively, for manual poking outside a test run. */ +abstract class PlugwrightRunServerTask : AbstractNodeTask() { + + @get:InputDirectory + abstract val runDir: DirectoryProperty + + @get:Input + abstract val serverJarPath: Property + + @get:Input + abstract val jvmArgs: ListProperty + + @get:Input + abstract val acceptEula: Property + + @get:Nested + @get:Optional + abstract val javaLauncher: Property init { group = "verification" @@ -12,24 +34,19 @@ abstract class PlugwrightRunTask : AbstractPlugwrightTask() { @TaskAction fun runServer() { - val runDirectory = prepareServerEnvironment() - + val runDirectory = runDir.get().asFile val serverJar = serverJarPath.get() - val serverArgs = jvmArgs.get() - val shouldAcceptEula = acceptEula.get() - - // Build JVM arguments string - val finalJvmArgs = serverArgs.toMutableList() - - // Ensure EULA argument is present if acceptEula is true - if (shouldAcceptEula && !finalJvmArgs.any { it.contains("eula.agree") }) { + val finalJvmArgs = jvmArgs.get().toMutableList() + + if (acceptEula.get() && finalJvmArgs.none { it.contains("eula.agree") }) { finalJvmArgs.add("-Dcom.mojang.eula.agree=true") } - + val javaPath = if (javaLauncher.isPresent) { javaLauncher.get().executablePath.asFile.absolutePath } else { - File(System.getProperty("java.home"), "bin/java" + if (System.getProperty("os.name").lowercase().contains("win")) ".exe" else "").absolutePath + val isWindows = System.getProperty("os.name").lowercase().contains("win") + File(System.getProperty("java.home"), "bin/java" + if (isWindows) ".exe" else "").absolutePath } logger.lifecycle("Starting test server for debugging...") @@ -47,7 +64,7 @@ abstract class PlugwrightRunTask : AbstractPlugwrightTask() { logger.lifecycle("========================================================\n") } } - + logger.lifecycle("Test server stopped") } } diff --git a/gradle-plugin/settings.gradle.kts b/gradle-plugin/settings.gradle.kts index 065db97..d5455e2 100644 --- a/gradle-plugin/settings.gradle.kts +++ b/gradle-plugin/settings.gradle.kts @@ -1 +1,12 @@ -rootProject.name = "plugwright-gradle-plugin" +rootProject.name = "plugwright" + +// plugwright-api — stable contract third-party modes compile against +// plugwright-core — mode-agnostic engine: extension, mode registry, generic tasks +// plugwright-local — built-in "local" mode +// plugwright-external — built-in "external" mode: attaches to an already-running server +// plugwright-bundle — id "io.github.drownek.plugwright": applies core, registers local + external +include(":plugwright-api") +include(":plugwright-core") +include(":plugwright-local") +include(":plugwright-external") +include(":plugwright-bundle") diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt b/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt deleted file mode 100644 index 584fa11..0000000 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt +++ /dev/null @@ -1,169 +0,0 @@ -package me.drownek.plugwright - -import org.gradle.api.Project -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import java.io.File - -abstract class PlugwrightExtension(project: Project) { - /** - * Directory containing test files (.spec.js) - */ - val testsDir: DirectoryProperty = project.objects.directoryProperty().convention( - project.layout.projectDirectory.dir("src/test/e2e") - ) - - /** - * The Node.js version to download and use if downloadNode is true. - */ - val nodeVersion: Property = project.objects.property(String::class.java).convention("22.14.0") - - /** - * Whether to automatically download Node.js. Disabled by default: the system-installed - * node/npm on PATH is used, and the build fails with instructions if Node.js is missing. - * Set to true to download a verified Node.js distribution into a shared per-user cache. - */ - val downloadNode: Property = project.objects.property(Boolean::class.java).convention(false) - - /** - * Directory where the server will be run from. - * Will be created automatically if it doesn't exist. - */ - val runDir: DirectoryProperty = project.objects.directoryProperty().convention( - project.layout.projectDirectory.dir("run") - ) - - /** - * Minecraft version for the Paper server (e.g., "1.19.4", "1.20.4") - */ - val minecraftVersion: Property = project.objects.property(String::class.java).convention("1.19.4") - - /** - * JVM arguments to pass when starting the server. - */ - val jvmArgs: ListProperty = project.objects.listProperty(String::class.java).convention( - listOf( - "-Xmx2G" - ) - ) - - /** - * Whether to accept the Minecraft EULA automatically. - * When true, adds -Dcom.mojang.eula.agree=true to JVM args. - */ - val acceptEula: Property = project.objects.property(Boolean::class.java).convention(true) - - /** - * List of files/folders to exclude from deletion during plugwrightClean. - * By default, excludes server.jar, cache, and libraries folders. - * These paths are relative to the run directory. - */ - val cleanExcludePatterns: ListProperty = project.objects.listProperty(String::class.java).convention( - listOf( - "server.jar", - "cache", - "libraries" - ) - ) - - /** - * URLs of plugins to download before running tests. - * These plugins will be placed in the server's plugins directory. - */ - val pluginUrls: ListProperty = project.objects.listProperty(String::class.java).convention(emptyList()) - - /** - * Whether to use only externally downloaded plugins instead of building the project plugin. - * When true, the plugwrightTest task will not depend on jar/shadowJar/reobfJar tasks. - * Useful when running E2E tests with plugins downloaded from external sources only. - */ - val useExternalPluginsOnly: Property = project.objects.property(Boolean::class.java).convention(false) - - /** - * List of files to write into the run directory before the server starts. - * Internal storage — use the writeFiles { } DSL block to populate. - */ - val runDirFiles: ListProperty = project.objects.listProperty(RunDirFile::class.java).convention(emptyList()) - - /** - * DSL method for staging files into the run directory before server start. - * - * Paths are relative to the run directory. - * - * Example: - * ``` - * writeFiles { - * // inline text content - * file("plugins/SomePlugin/config.yml", """ - * key: "value" - * """.trimIndent()) - * - * // copy from a local source file - * file("plugins/MyPlugin/data.json", projectDir.resolve("test-fixtures/data.json")) - * } - * ``` - */ - fun writeFiles(action: RunDirFileSpec.() -> Unit) { - val spec = RunDirFileSpec() - action(spec) - runDirFiles.set(spec.entries) - } - - /** - * Specification for run-dir file staging. - */ - class RunDirFileSpec { - internal val entries = mutableListOf() - - /** Write [content] (as UTF-8 text) to [path] relative to the run directory. */ - fun file(path: String, content: String) { - entries.add(RunDirFile(path, content, null)) - } - - /** Copy [sourceFile] to [path] relative to the run directory. */ - fun file(path: String, sourceFile: File) { - entries.add(RunDirFile(path, null, sourceFile)) - } - } - - /** - * Represents a single file to be written into the run directory. - * Exactly one of [content] or [sourceFile] will be non-null. - */ - data class RunDirFile( - val path: String, - val content: String?, - val sourceFile: File? - ) - - /** - * DSL method for configuring plugin downloads. - * Example: - * ``` - * downloadPlugins { - * url("https://example.com/plugin1.jar") - * url("https://example.com/plugin2.jar") - * } - * ``` - */ - fun downloadPlugins(action: PluginDownloadSpec.() -> Unit) { - val spec = PluginDownloadSpec() - action(spec) - pluginUrls.set(spec.urls) - } - - /** - * Specification for plugin downloads. - */ - class PluginDownloadSpec { - internal val urls = mutableListOf() - - /** - * Add a plugin URL to download. - */ - fun url(pluginUrl: String) { - urls.add(pluginUrl) - } - } -} diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt b/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt deleted file mode 100644 index 32e2ace..0000000 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt +++ /dev/null @@ -1,363 +0,0 @@ -package me.drownek.plugwright - -import org.gradle.api.GradleException -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.plugins.ExtensionAware -import org.gradle.api.plugins.JavaPluginExtension -import org.gradle.jvm.toolchain.JavaToolchainService -import org.gradle.plugins.ide.idea.model.IdeaModel -import org.jetbrains.gradle.ext.ProjectSettings -import org.jetbrains.gradle.ext.TaskTriggersConfig -import java.io.File -import java.util.concurrent.atomic.AtomicBoolean -import javax.inject.Inject -import org.gradle.process.ExecOperations - -interface InjectedExecOps { - @get:Inject - val execOperations: ExecOperations -} - -object BannerState { - val printed = AtomicBoolean(false) -} - -private fun runNpmInstall(project: Project, targetDir: File, nodePaths: NodeManager.NodePaths) { - val isWin = System.getProperty("os.name").lowercase().contains("windows") - val cmd = if (isWin) listOf("cmd", "/c", nodePaths.npm, "install") else listOf(nodePaths.npm, "install") - val nodeDir = File(nodePaths.node).parent - - val execOps = project.objects.newInstance(InjectedExecOps::class.java) - val execResult = execOps.execOperations.exec { - workingDir = targetDir - commandLine = cmd - if (nodeDir != null) { - val pathKey = environment.keys.firstOrNull { it.equals("PATH", ignoreCase = true) } ?: "PATH" - environment[pathKey] = nodeDir + File.pathSeparator + (environment[pathKey] ?: "") - } - isIgnoreExitValue = true - } - - if (execResult.exitValue != 0) { - throw GradleException("EXEC ERROR: 'npm install' failed with exit code ${execResult.exitValue}.") - } - project.logger.lifecycle("Dependencies installed successfully.") -} - -private fun AbstractPlugwrightTask.configureCommon(project: Project, extension: PlugwrightExtension, defaultNodeInstallDir: File) { - doFirst { - if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) - } - - minecraftVersion.set(extension.minecraftVersion) - jvmArgs.set(extension.jvmArgs) - acceptEula.set(extension.acceptEula) - pluginUrls.set(extension.pluginUrls) - runDirFiles.set(extension.runDirFiles) - nodeVersion.set(extension.nodeVersion) - downloadNode.set(extension.downloadNode) - nodeInstallDir.set(defaultNodeInstallDir) - - serverJarPath.set( - extension.runDir.map { runDir -> - val serverJar = runDir.asFile.resolve("server.jar") - serverJar.absolutePath - } - ) - - serverDir.set( - extension.runDir.map { runDir -> - runDir.asFile.absolutePath - } - ) - - // Configure Java Toolchain if Java plugin is present - project.plugins.withId("java") { - val javaExtension = project.extensions.findByType(JavaPluginExtension::class.java) - val javaToolchains = project.extensions.findByType(JavaToolchainService::class.java) - - if (javaExtension != null && javaToolchains != null) { - javaLauncher.set(javaToolchains.launcherFor(javaExtension.toolchain)) - } - } -} - -class PlugwrightPlugin : Plugin { - override fun apply(project: Project) { - val extension = project.extensions.create("plugwright", PlugwrightExtension::class.java, project) - - // Shared per-user cache so Node.js is downloaded once for all projects - // and survives 'gradle clean'. Safe for concurrent builds thanks to the - // file lock in NodeManager. - val defaultNodeInstallDir = File(project.gradle.gradleUserHomeDir, "caches/plugwright/node") - - // Register plugwrightClean task - val plugwrightClean = project.tasks.register("plugwrightClean") { - group = "verification" - description = "Wipes the test server data for a clean slate." - - doFirst { - if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) - } - - doLast { - val runDir = extension.runDir.get().asFile - val excludePatterns = extension.cleanExcludePatterns.get() - - if (!runDir.exists()) { - project.logger.lifecycle(" Run directory doesn't exist yet, nothing to clean") - return@doLast - } - - project.logger.lifecycle(" Cleaning run directory (excluding: ${excludePatterns.joinToString(", ")})") - - // Get all files and directories in the run folder - val allEntries = runDir.listFiles() ?: emptyArray() - - // Separate entries into deleted and kept - val deletedFiles = mutableListOf() - val keptFiles = mutableListOf() - - // Delete everything except the excluded patterns - allEntries.forEach { entry -> - val shouldExclude = excludePatterns.any { pattern -> - entry.name == pattern - } - - if (!shouldExclude) { - deletedFiles.add(entry.name) - project.delete(entry) - } else { - keptFiles.add(entry.name) - } - } - - if (deletedFiles.isNotEmpty()) { - project.logger.lifecycle(" deleted: ${deletedFiles.joinToString(", ")}") - } - if (keptFiles.isNotEmpty()) { - project.logger.lifecycle(" preserved: ${keptFiles.joinToString(", ")}") - } - } - } - - val plugwrightNpmInstall = project.tasks.register("plugwrightNpmInstall") { - group = "verification" - description = "Installs Node.js dependencies for Plugwright tests." - - doFirst { - if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) - } - - // Define inputs and outputs for up-to-date checks - inputs.file(extension.testsDir.map { it.file("package.json") }).optional() - inputs.file(extension.testsDir.map { it.file("package-lock.json") }).optional() - outputs.dir(extension.testsDir.map { it.dir("node_modules") }) - outputs.upToDateWhen { File(extension.testsDir.get().asFile, "package.json").exists() } - - doLast { - val testsDir = extension.testsDir.get().asFile - if (!testsDir.exists() || !File(testsDir, "package.json").exists()) { - throw GradleException("Cannot run plugwrightNpmInstall: 'package.json' not found in ${testsDir.absolutePath}. Please run 'plugwrightInit' first.") - } - - val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get()) - project.logger.lifecycle("Installing Node.js dependencies in ${testsDir.absolutePath}...") - - try { - runNpmInstall(project, testsDir, nodePaths) - } catch (e: Exception) { - if (e is GradleException) throw e - throw GradleException("EXEC FATAL: Failed to launch npm process. Original error: ${e.message}", e) - } - } - } - - project.tasks.register("plugwrightTest", PlugwrightTestTask::class.java) { - // Ensure clean and setup runs before test - dependsOn(plugwrightClean) - dependsOn(plugwrightNpmInstall) - - configureCommon(project, extension, defaultNodeInstallDir) - - testsDir.set(extension.testsDir) - - // Support command line properties for filtering - if (project.hasProperty("testFiles")) { - testFiles.set(project.property("testFiles") as String) - } - - if (project.hasProperty("testNames")) { - testNames.set(project.property("testNames") as String) - } - } - - project.tasks.register("plugwrightRunServer", PlugwrightRunTask::class.java) { - // Ensure clean runs before starting the server - dependsOn(plugwrightClean) - - configureCommon(project, extension, defaultNodeInstallDir) - } - - project.tasks.register("plugwrightInit") { - group = "verification" - description = "Interactively initializes a plugwright-test environment with required configs and an initial test file." - - doFirst { - if (BannerState.printed.compareAndSet(false, true)) Banner.print(project.logger) - } - - doLast { - val defaultDir = "src/test/e2e" - val propertyDir = project.findProperty("plugwrightDir") as? String - - val inputDir = propertyDir ?: run { - if (System.console() != null) { - project.logger.lifecycle("Enter the test directory location [default: $defaultDir]:") - val consoleInput = readlnOrNull()?.trim() - if (consoleInput.isNullOrEmpty()) defaultDir else consoleInput - } else { - project.logger.lifecycle("Non-interactive environment detected. Using default test directory: $defaultDir") - defaultDir - } - } - - project.logger.lifecycle("Using directory: $inputDir") - - val projectRootDir = project.projectDir.canonicalFile - val targetDir = projectRootDir.resolve(inputDir).canonicalFile - - if (!targetDir.path.startsWith(projectRootDir.path)) { - throw GradleException("SECURITY ERROR: Target directory ($targetDir) resolves outside the project root directory. Path traversal aborted.") - } - - if (!targetDir.exists() && !targetDir.mkdirs()) { - throw GradleException("IO ERROR: Failed to create target directory: ${targetDir.absolutePath}. Check your file permissions.") - } - - val packageJson = targetDir.resolve("package.json") - if (!packageJson.exists()) { - packageJson.writeText( - """ - { - "type": "module", - "scripts": { - "build": "rimraf dist && tsc" - }, - "dependencies": { - "@drownek/plugwright": "^2.0.4" - }, - "devDependencies": { - "@types/node": "^22.10.5", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - } - } - """.trimIndent() - ) - project.logger.lifecycle("Created: ${packageJson.absolutePath}") - } - - val tsconfigJson = targetDir.resolve("tsconfig.json") - if (!tsconfigJson.exists()) { - tsconfigJson.writeText( - """ - { - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "node", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": ".", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": false, - "sourceMap": true - }, - "include": [ - "*.spec.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] - } - """.trimIndent() - ) - project.logger.lifecycle("Created: ${tsconfigJson.absolutePath}") - } - - val testFile = targetDir.resolve("example.spec.ts") - if (!testFile.exists()) { - testFile.writeText( - """ - import {expect, test} from '@drownek/plugwright'; - - test('help displays message', async ({ player, server }) => { - player.chat('/help'); - await expect(player).toHaveReceivedMessage('Help'); - }); - """.trimIndent() - ) - project.logger.lifecycle("Created: ${testFile.absolutePath}") - } - - project.logger.lifecycle("Executing 'npm install' in ${targetDir.absolutePath}...") - val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get()) - - try { - runNpmInstall(project, targetDir, nodePaths) - project.logger.lifecycle("\nYou're all set! Run tests with: ./gradlew plugwrightTest") - } catch (e: Exception) { - if (e is GradleException) throw e - throw GradleException("EXEC FATAL: Failed to launch npm process. Original error: ${e.message}", e) - } - } - } - - project.afterEvaluate { - // Only set up plugin jar dependency if not using external plugins only - if (!extension.useExternalPluginsOnly.get()) { - // Try to find the task that produces the plugin jar - val jarTask = when { - project.tasks.findByName("shadowJar") != null -> project.tasks.named("shadowJar") - project.tasks.findByName("reobfJar") != null -> project.tasks.named("reobfJar") - else -> project.tasks.named("jar") - } - - if (jarTask.isPresent) { - project.tasks.named("plugwrightTest", PlugwrightTestTask::class.java).configure { - pluginJar.set(jarTask.map { it.outputs.files.singleFile }) - } - project.tasks.named("plugwrightRunServer", PlugwrightRunTask::class.java).configure { - pluginJar.set(jarTask.map { it.outputs.files.singleFile }) - } - } - } - } - - // Auto-trigger npm install on IntelliJ IDEA sync if IDEA plugin is applied - project.plugins.withId("idea") { - project.pluginManager.apply("org.jetbrains.gradle.plugin.idea-ext") - val configureIdea = { - val ideaModel = project.extensions.findByType(IdeaModel::class.java) - if (ideaModel != null) { - val ideaProject = ideaModel.project as? ExtensionAware - val settings = ideaProject?.extensions?.findByType(ProjectSettings::class.java) as? ExtensionAware - val triggers = settings?.extensions?.findByType(TaskTriggersConfig::class.java) - triggers?.afterSync(plugwrightNpmInstall) - } - } - if (project.state.executed) { - configureIdea() - } else { - project.afterEvaluate { configureIdea() } - } - } - } -} diff --git a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt b/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt deleted file mode 100644 index b3d755c..0000000 --- a/gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt +++ /dev/null @@ -1,129 +0,0 @@ -package me.drownek.plugwright - -import org.gradle.api.GradleException -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.* -import java.io.File - -abstract class PlugwrightTestTask : AbstractPlugwrightTask() { - - @get:InputDirectory - @get:Optional - abstract val testsDir: DirectoryProperty - - @get:Input - @get:Optional - abstract val testFiles: Property - - @get:Input - @get:Optional - abstract val testNames: Property - - init { - group = "verification" - description = "Run E2E tests for Paper plugin" - } - - @TaskAction - fun runTests() { - val nodePaths = NodeManager.getOrDownloadNode(nodeInstallDir.get().asFile, nodeVersion.get(), downloadNode.get()) - prepareServerEnvironment() - - val serverJar = serverJarPath.get() - val serverDirectory = serverDir.get() - val mcVersion = minecraftVersion.get() - val serverArgs = jvmArgs.get() - val shouldAcceptEula = acceptEula.get() - - // Check tests directory - val userTestsDirectory = if (testsDir.isPresent) { - testsDir.get().asFile - } else { - logger.warn("Tests directory not configured") - return - } - - if (!userTestsDirectory.exists()) { - logger.warn("Tests directory does not exist: ${userTestsDirectory.absolutePath}") - return - } - - val nodeDir = File(nodePaths.node).parent - val npmEnv = if (nodeDir != null) { - val pathKey = System.getenv().keys.firstOrNull { it.equals("PATH", ignoreCase = true) } ?: "PATH" - mapOf(pathKey to nodeDir + File.pathSeparator + (System.getenv(pathKey) ?: "")) - } else emptyMap() - - // Build TypeScript tests if tsconfig.json exists - val tsconfigFile = File(userTestsDirectory, "tsconfig.json") - if (tsconfigFile.exists()) { - logger.lifecycle("TypeScript config found, compiling tests...") - runCommand(userTestsDirectory, nodePaths.npm, "run", "build", env = npmEnv) - } else { - logger.lifecycle("No TypeScript config found, running JavaScript tests directly") - } - - // Build JVM arguments string for the runner - val finalJvmArgs = serverArgs.toMutableList() - - // Ensure EULA argument is present if acceptEula is true - if (shouldAcceptEula && !finalJvmArgs.any { it.contains("eula.agree") }) { - finalJvmArgs.add("-Dcom.mojang.eula.agree=true") - } - - val jvmArgsString = finalJvmArgs.joinToString(" ") - - // Run Tests using the npm package - val javaPath = if (javaLauncher.isPresent) { - javaLauncher.get().executablePath.asFile.absolutePath - } else { - File(System.getProperty("java.home"), "bin/java" + if (System.getProperty("os.name").lowercase().contains("win")) ".exe" else "").absolutePath - } - - logger.lifecycle("Running E2E tests...") - logger.lifecycle("Server JAR: $serverJar") - logger.lifecycle("JVM Args: $jvmArgsString") - - val envMap = mutableMapOf( - "SERVER_JAR" to serverJar.trim(), - "SERVER_DIR" to serverDirectory.trim(), - "JAVA_PATH" to javaPath, - "JVM_ARGS" to jvmArgsString, - "MC_VERSION" to mcVersion - ) - - if (testFiles.isPresent) { - val fileFilter = testFiles.get() - envMap["TEST_FILES"] = fileFilter - logger.lifecycle("Test files filter: $fileFilter") - } - - if (testNames.isPresent) { - val nameFilter = testNames.get() - envMap["TEST_NAMES"] = nameFilter - logger.lifecycle("Test names filter: $nameFilter") - } - - val defaultCliJs = File(userTestsDirectory, "node_modules/@drownek/plugwright/dist/cli.js") - val cliJsFile = sequenceOf( - // Canonical path resolves npm symlink bugs on CI - defaultCliJs.canonicalFile, - defaultCliJs, - // Dev-environment fallback when running inside this repository - File(userTestsDirectory, "../../../../runner-package/dist/cli.js") - ).firstOrNull { it.exists() } - ?: throw GradleException( - "plugwright cli.js not found at ${defaultCliJs.absolutePath}. " + - "Did 'npm install' succeed in ${userTestsDirectory.absolutePath}?" - ) - - runCommand( - userTestsDirectory, - nodePaths.node, cliJsFile.absolutePath, - env = envMap - ) - - logger.lifecycle("E2E tests completed successfully") - } -} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1ce7483 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "plugwright", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json index 0de69f6..22eb671 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "private": true, "scripts": { - "bump": "node scripts/bump-version.js" + "bump": "node scripts/bump-version.js", + "install:packages": "npm ci --prefix runner-package && npm ci --prefix auth-authme-package", + "build:packages": "npm run build --prefix runner-package && npm run build --prefix auth-authme-package", + "publish:packages": "node scripts/publish.js" } -} \ No newline at end of file +} diff --git a/runner-package/README.md b/runner-package/README.md index a819a63..16feeef 100644 --- a/runner-package/README.md +++ b/runner-package/README.md @@ -1,17 +1,17 @@ -# @drownek/plugwright +# @plugwright/runner End-to-end testing runner for Paper/Spigot Minecraft plugins. ## Installation ```bash -npm install @drownek/plugwright +npm install @plugwright/runner ``` ## Quick Start ```javascript -import { test, expect } from '@drownek/plugwright'; +import { test, expect } from '@plugwright/runner'; test('player can join server', async ({ player }) => { player.chat('/help'); @@ -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. + ## 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/cli.ts b/runner-package/cli.ts index bf0ed79..4618230 100644 --- a/runner-package/cli.ts +++ b/runner-package/cli.ts @@ -1,8 +1,18 @@ #!/usr/bin/env node -import { runTestSession } from './runner.js'; +import { runTestSession, runPingSession } from './runner.js'; -runTestSession().catch((error: Error) => { +const argv = process.argv.slice(2); + +async function main(): Promise { + if (argv.includes('--ping')) { + await runPingSession(); + return; + } + await runTestSession(); +} + +main().catch((error: Error) => { console.error('\nTest run failed:', error); process.exit(1); }); diff --git a/runner-package/lib/account.ts b/runner-package/lib/account.ts new file mode 100644 index 0000000..b226255 --- /dev/null +++ b/runner-package/lib/account.ts @@ -0,0 +1,180 @@ +import { randomUUID } from 'node:crypto'; +import { resolveSecret } from './config.js'; +import type { SecretRef } from './config.js'; + +/** + * A bot's login identity as seen by an environment and its auth plugin. `justCreated` is + * the key field for authentication plugins: a fresh account needs to register, an existing + * one needs to log in. + */ +export interface Account { + username: string; + password?: string; + auth: 'offline' | 'microsoft'; + justCreated: boolean; + /** Set for `microsoft` accounts: where mineflayer should cache the device-code token. */ + microsoftCacheDir?: string; +} + +/** + * Stand-in used when an environment has no [AccountPool] of its own — a `local` bot is a + * fresh offline-mode connection under a name the server has never seen. + * + * [password] is for the other case: a bot the test names itself. That bypasses the pool, so + * nothing else knows a password for it, and the test has to bring one for the authentication + * plugin to use. + */ +export function syntheticAccount(username: string, password?: string): Account { + return { username, password, auth: 'offline', justCreated: true }; +} + +/** A short random identity suffix. Four hex digits: long enough that two names in a run + * colliding is not a realistic worry, short enough to leave room under Minecraft's 16-character + * username limit for whatever prefix the pattern puts in front of it. */ +export function randomSuffix(): string { + return randomUUID().slice(0, 4); +} + +/** An account as it sits in the pool: an [Account] whose password may still be a reference to + * a secret rather than the secret itself. Once leased, the resolved password stays on the + * entry, so a second lease of the same account doesn't re-read the environment. */ +type PooledEntry = Account & { secret?: SecretRef }; + +export interface AccountsConfig { + pool?: Array<{ username: string; password: SecretRef }>; + autoRegister?: { usernamePattern: string; password: SecretRef; max: number } | null; + microsoft?: { accounts: string[]; cacheDir?: string | null } | null; +} + +/** True for a pattern that asks for a random suffix (`pw_%s`) rather than a sequence number + * (`pw_%04d`). The difference is what the pool does with a name once a test is done with it — + * see [AccountPool.release]. */ +function isUniquePattern(pattern: string): boolean { + return pattern.includes('%s'); +} + +/** Formats an auto-register username. `%s` becomes a random suffix, `%d` (optionally + * zero-padded, `%04d`) the sequence number. No other printf feature is supported. */ +function formatUsername(pattern: string, n: number): string { + return pattern + .replace(/%s/, randomSuffix()) + .replace(/%(\d*)d/, (_match, width: string) => { + const digits = String(n); + return width ? digits.padStart(parseInt(width, 10), '0') : digits; + }); +} + +/** + * Leasable accounts for `external`, merged from three sources: a fixed `pool`, generated + * `autoRegister` names, and `microsoft` accounts for an online-mode server. Accounts are leased + * per test and returned in `finally` — see `test-runner.ts`. + * + * `autoRegister` has two shapes, told apart by the pattern. A numbered one (`pw_%04d`) is a + * fixed set of slots: a name comes back to the pool when the test that held it is done, and the + * next test gets that same account, already registered. A `%s` pattern generates a name per + * lease and never hands it out again, so a test starts on an account the server has never seen — + * at the price of a registration the server keeps. + * + * Exhausted when every pool/microsoft slot is checked out and `autoRegister` (if any) has + * reached its `max`: `lease()` then throws rather than silently handing out an identity two + * concurrently-connected bots would fight over. + */ +export class AccountPool { + /** Queue entries keep the secret *reference*: a run that never connects a bot — a + * cleanup pass, a console-only ping — must not demand that the passwords be set. They + * are resolved in [lease], where an unset variable is a real problem. */ + private readonly queue: PooledEntry[] = []; + private autoRegisterIssued = 0; + private readonly autoRegister: { usernamePattern: string; password: SecretRef; max: number; unique: boolean } | null; + /** Names handed out by a `%s` pattern and still checked out. Kept so [release] can tell a + * one-shot identity from a numbered slot without a flag on [Account] itself. */ + private readonly uniqueOut = new Set(); + /** Every declared `pool`/`microsoft` name, free or not — so a request for one by name can + * say whether it is taken or was never configured at all. */ + private readonly declaredNames = new Set(); + + constructor(config: AccountsConfig | null | undefined) { + for (const entry of config?.pool ?? []) { + this.declaredNames.add(entry.username); + this.queue.push({ username: entry.username, secret: entry.password, auth: 'offline', justCreated: false }); + } + for (const username of config?.microsoft?.accounts ?? []) { + this.declaredNames.add(username); + this.queue.push({ + username, + auth: 'microsoft', + justCreated: false, + microsoftCacheDir: config?.microsoft?.cacheDir ?? undefined, + }); + } + this.autoRegister = config?.autoRegister + ? { + usernamePattern: config.autoRegister.usernamePattern, + password: config.autoRegister.password, + max: config.autoRegister.max, + unique: isUniquePattern(config.autoRegister.usernamePattern), + } + : null; + } + + /** Total slots that can be checked out at once: pool + microsoft + `autoRegister`'s max. + * Not the number currently free. */ + capacity(): number { + return this.queue.length + (this.autoRegister?.max ?? 0); + } + + /** Leases the next free account, or the one named by [username] — a `describe.serial` block + * that has to run as one specific account. A name that is taken, or was never declared, + * throws: quietly substituting another account is how a test ends up asserting against + * state that belongs to somebody else. */ + async lease(username?: string): Promise { + if (username !== undefined) return this.leaseNamed(username); + + const entry = this.queue.shift(); + if (entry) { + const { secret, ...account } = entry; + return secret && account.password === undefined + ? { ...account, password: resolveSecret(secret) } + : account; + } + + // For a numbered pattern `autoRegisterIssued` counts names that exist; for a `%s` + // pattern it counts names currently checked out, since released ones are never + // handed back. Either way `max` is the number of bots that can be connected at once. + if (this.autoRegister && this.autoRegisterIssued < this.autoRegister.max) { + this.autoRegisterIssued++; + const username = formatUsername(this.autoRegister.usernamePattern, this.autoRegisterIssued); + if (this.autoRegister.unique) this.uniqueOut.add(username); + return { username, password: resolveSecret(this.autoRegister.password), auth: 'offline', justCreated: true }; + } + + throw new Error( + 'AccountPool exhausted: no pool/microsoft account is free and accounts.autoRegister has reached its max' + ); + } + + private leaseNamed(username: string): Account { + const idx = this.queue.findIndex(e => e.username === username); + if (idx === -1) { + throw new Error(this.declaredNames.has(username) + ? `Account "${username}" is already leased by another test` + : `Account "${username}" is not in this environment's accounts pool`); + } + const { secret, ...account } = this.queue.splice(idx, 1)[0]; + return secret && account.password === undefined + ? { ...account, password: resolveSecret(secret) } + : account; + } + + /** Returns a leased account, `finally`-style. A numbered `autoRegister` account comes back + * with `justCreated: false` — the server registered it on its first lease, so the auth + * plugin logs in on every lease after. A `%s` account is dropped instead: its name is spent, + * and what comes back is only the slot it occupied. */ + release(account: Account): void { + if (this.uniqueOut.delete(account.username)) { + this.autoRegisterIssued--; + return; + } + this.queue.push(account.justCreated ? { ...account, justCreated: false } : account); + } +} diff --git a/runner-package/lib/bot-utils.ts b/runner-package/lib/bot-utils.ts deleted file mode 100644 index 26b60e5..0000000 --- a/runner-package/lib/bot-utils.ts +++ /dev/null @@ -1,104 +0,0 @@ -import mineflayer, { Bot } from 'mineflayer'; -import pc from 'picocolors'; - -/** Shared mutable state for active bots and buffers. */ -export const activeBots: Bot[] = []; -export const serverConsoleBuffer: string[] = []; - -/** - * Disconnects a bot, waiting for the `end` event or a timeout. - * Cleans up all listeners BEFORE registering end handler so it isn't stripped. - * Skips the wait entirely if the client is already ended. - */ -export function disconnectBot(bot: Bot, label: string, timeoutMs: number = 3000): Promise { - const cleanupListeners = () => { - try { - bot.removeAllListeners(); - } catch (err) { - console.log(pc.dim(`[Bot] ${label} warning: failed to remove listeners: ${(err as Error).message}`)); - } - }; - - const isAlreadyEnded = !!(bot as any)._client?.ended; - if (isAlreadyEnded) { - cleanupListeners(); - return Promise.resolve(); - } - - return new Promise((resolve) => { - const timeout = setTimeout(() => { - console.log(pc.dim(`[Bot] ${label} disconnect timeout, continuing`)); - cleanupListeners(); - resolve(); - }, timeoutMs); - - try { - bot.once('end', () => { - clearTimeout(timeout); - cleanupListeners(); - resolve(); - }); - bot.quit(); - } catch (err) { - console.log(pc.dim(`[Bot] ${label} error during disconnect: ${(err as Error).message}`)); - clearTimeout(timeout); - cleanupListeners(); - resolve(); - } - }); -} - -/** - * Creates a new mineflayer bot and registers it in the activeBots list. - */ -export function createBot(options: { - host: string; - port: number; - username: string; - version: string | undefined; - auth: 'mojang' | 'microsoft' | 'offline'; -}): Bot { - const bot = mineflayer.createBot({ - host: options.host, - port: options.port, - username: options.username, - version: options.version, - auth: options.auth, - }); - - activeBots.push(bot); - - bot.once('end', (reason: string) => { - console.log(pc.dim(`[Bot] ${options.username} connection ended: ${reason}`)); - }); - - return bot; -} - -/** - * Disconnects all active bots and clears the list. - */ -export async function disconnectAllBots(): Promise { - await Promise.all( - activeBots.map((b, i) => disconnectBot(b, b.username ?? `bot-${i}`, 2000)) - ); - - activeBots.length = 0; -} - -/** - * Writes Minecraft server output to the console and appends to the server console buffer. - */ -export function writeMcOutput(data: Buffer): void { - const text = data.toString().replace(/\r\n/g, '\n'); - const lines = text.split('\n'); - for (const line of lines) { - if (line.length > 0) { - serverConsoleBuffer.push(line); - } - } - const prefixed = lines - .map(line => line.length > 0 ? `${pc.gray('[MC]')} ${line}` : '') - .join('\n'); - process.stdout.write(prefixed); -} \ No newline at end of file diff --git a/runner-package/lib/config.ts b/runner-package/lib/config.ts new file mode 100644 index 0000000..9665c66 --- /dev/null +++ b/runner-package/lib/config.ts @@ -0,0 +1,238 @@ +import { readFileSync } from 'fs'; +import { isAbsolute, resolve } from 'path'; + +/** Config layouts this runner understands. */ +export const SUPPORTED_CONFIG_VERSION = 1; + +/** Default file consulted when no --config flag is given. */ +export const DEFAULT_CONFIG_FILENAME = 'plugwright.config.json'; + +/** A secret is transported as a pointer; the value is read here, at run time. */ +export type SecretRef = + | { from: 'env'; name: string } + | { from: 'file'; path: string } + | { from: 'systemProperty'; name: string }; + +export interface RuntimeRef { + /** npm package exporting the environment factory. */ + package: string; + /** Named export holding the factory; the default export when omitted. */ + export?: string; +} + +export interface EnvironmentConfig { + /** Environment name, used in logs and report file names. */ + name: string; + /** Mode id: `local`, `external`, or one contributed by a third-party module. */ + mode: string; + /** Where to load a non-built-in environment implementation from. */ + runtime?: RuntimeRef | null; + /** Mode-specific settings; interpreted by the environment implementation. */ + config: Record; +} + +export interface TestsConfig { + /** Directory scanned for compiled spec files. Defaults to the working directory. */ + dir?: string | null; + /** Only run spec files matching these substrings. */ + include?: string[] | null; + /** Skip spec files matching these substrings. */ + exclude?: string[] | null; + /** Only run tests whose name contains one of these substrings. */ + names?: string[] | null; + /** Per-test timeout; falls back to TEST_TIMEOUT and then to 30s. */ + timeoutMs?: number | null; +} + +export interface ReportsConfig { + /** Path to write the machine-readable JSON report to. Omitted means "don't write one". */ + json?: string | null; + /** Path to write the JUnit XML report to. Omitted means "don't write one". */ + junit?: string | null; +} + +export interface PluginConfig { + /** npm package name, or a resolvable path to a local plugin module. The default export + * must implement `PlugwrightPlugin`. */ + specifier: string; + options?: Record; + /** Set false to load the plugin's hooks/matchers without pulling in its `tests`. */ + inheritTests?: boolean; +} + +export interface RunnerConfig { + version: number; + environment: EnvironmentConfig; + tests: TestsConfig; + reports?: ReportsConfig | null; + plugins?: PluginConfig[] | null; +} + +/** Settings of the built-in `local` mode, which spawns its own Paper server. */ +export interface LocalEnvironmentConfig { + serverJar: string; + serverDir: string; + javaPath: string; + jvmArgs: string[]; + minecraftVersion?: string | null; + host?: string | null; + port?: number | null; + rconPort?: number | null; + rconPassword?: string | null; +} + +/** + * Reads `--config ` / `--config=` from the given arguments. + */ +function readConfigFlag(argv: string[]): string | null { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--config') { + const value = argv[i + 1]; + if (!value || value.startsWith('-')) { + throw new Error('--config requires a path to a configuration file'); + } + return value; + } + if (arg.startsWith('--config=')) { + return arg.slice('--config='.length); + } + } + return null; +} + +function readConfigFile(path: string): RunnerConfig { + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch (error) { + throw new Error(`Cannot read plugwright config at ${path}: ${(error as Error).message}`); + } + + let parsed: RunnerConfig; + try { + parsed = JSON.parse(raw) as RunnerConfig; + } catch (error) { + throw new Error(`Invalid JSON in plugwright config at ${path}: ${(error as Error).message}`); + } + + if (typeof parsed.version !== 'number') { + throw new Error(`Plugwright config at ${path} has no "version" field`); + } + if (parsed.version > SUPPORTED_CONFIG_VERSION) { + throw new Error( + `Plugwright config at ${path} is version ${parsed.version}, this runner supports up to ` + + `${SUPPORTED_CONFIG_VERSION}. Update @plugwright/runner in your test project.` + ); + } + if (!parsed.environment || typeof parsed.environment.mode !== 'string') { + throw new Error(`Plugwright config at ${path} has no "environment.mode"`); + } + + parsed.tests = parsed.tests ?? {}; + parsed.reports = parsed.reports ?? {}; + parsed.plugins = parsed.plugins ?? []; + return parsed; +} + +function splitFilter(value: string | undefined): string[] | null { + if (!value) return null; + const parts = value.split(',').map(part => part.trim()).filter(part => part !== ''); + return parts.length > 0 ? parts : null; +} + +/** + * Pre-3.0 transport: five flat environment variables set by the Gradle plugin. + * Kept so an older plugin keeps working with a newer runner. + */ +function configFromEnvironment(): RunnerConfig { + const { SERVER_JAR, SERVER_DIR, JAVA_PATH, JVM_ARGS, MC_VERSION } = process.env; + + if (!SERVER_JAR || !SERVER_DIR || !JAVA_PATH) { + throw new Error( + 'No configuration found. Pass --config , or set SERVER_JAR, SERVER_DIR and JAVA_PATH.' + ); + } + + return { + version: SUPPORTED_CONFIG_VERSION, + environment: { + name: 'local', + mode: 'local', + config: { + serverJar: SERVER_JAR, + serverDir: SERVER_DIR, + javaPath: JAVA_PATH, + jvmArgs: (JVM_ARGS ?? '').split(' ').filter(arg => arg.trim() !== ''), + minecraftVersion: MC_VERSION ?? null, + host: 'localhost', + port: 25565, + }, + }, + tests: { + dir: null, + include: splitFilter(process.env.TEST_FILES), + names: splitFilter(process.env.TEST_NAMES), + exclude: null, + timeoutMs: null, + }, + }; +} + +/** + * Resolves the configuration for this run. + * + * Order: `--config `, then `plugwright.config.json` in the working directory, + * then the legacy environment variables. + */ +export function loadRunnerConfig(argv: string[] = process.argv.slice(2)): RunnerConfig { + const flagPath = readConfigFlag(argv); + return flagPath + ? readConfigFile(isAbsolute(flagPath) ? flagPath : resolve(process.cwd(), flagPath)) + : loadDefaultOrLegacyConfig(); +} + +function loadDefaultOrLegacyConfig(): RunnerConfig { + const defaultPath = resolve(process.cwd(), DEFAULT_CONFIG_FILENAME); + try { + readFileSync(defaultPath); + return readConfigFile(defaultPath); + } catch { + return configFromEnvironment(); + } +} + +/** True when [value] is a secret pointer rather than a plain value. */ +export function isSecretRef(value: unknown): value is SecretRef { + return typeof value === 'object' && value !== null && typeof (value as SecretRef).from === 'string'; +} + +/** + * Reads the value a [SecretRef] points at. Config files carry references, so a password + * never ends up in the Gradle configuration cache or in a build artifact. + */ +export function resolveSecret(ref: SecretRef): string { + switch (ref.from) { + case 'env': { + const value = process.env[ref.name]; + if (value === undefined) { + throw new Error(`Secret unavailable: environment variable ${ref.name} is not set`); + } + return value; + } + case 'file': { + try { + return readFileSync(ref.path, 'utf8').split(/\r?\n/)[0]; + } catch (error) { + throw new Error(`Secret unavailable: cannot read ${ref.path}: ${(error as Error).message}`); + } + } + case 'systemProperty': + throw new Error( + `Secret unavailable: "${ref.name}" is a JVM system property, which the runner cannot read. ` + + 'Use an environment variable or a file instead.' + ); + default: + throw new Error(`Unknown secret source: ${JSON.stringify(ref)}`); + } +} diff --git a/runner-package/lib/console.ts b/runner-package/lib/console.ts new file mode 100644 index 0000000..3b5db50 --- /dev/null +++ b/runner-package/lib/console.ts @@ -0,0 +1,11 @@ +/** + * A channel for sending admin commands to the server and reading its output. + */ +export interface ServerConsole { + /** How much of the server's output this channel can see. Matchers must check this, + * not just whether a console exists, or tests silently stop working on `'responses'`/`'none'`. */ + readonly output: 'full' | 'responses' | 'none'; + probe(): Promise; + execute(cmd: string, timeoutMs?: number): Promise; + close?(): void | Promise; +} diff --git a/runner-package/lib/environment.ts b/runner-package/lib/environment.ts new file mode 100644 index 0000000..e6df4f0 --- /dev/null +++ b/runner-package/lib/environment.ts @@ -0,0 +1,45 @@ +import type { ServerConsole } from './console.js'; +import type { Session } from './session.js'; +import type { AccountPool } from './account.js'; + +/** What an environment actually supports. Declared expectations in the DSL are checked + * against this after `setup()`; a mismatch is printed once in the run header. */ +export interface EnvironmentCapabilities { + console: boolean; + consoleOutput: 'full' | 'responses' | 'none'; + op: boolean; +} + +export interface BotConnectionOptions { + host: string; + port: number; + version?: string; + auth: 'offline' | 'microsoft' | 'mojang'; + /** Cache directory for a Microsoft device-code token, so a CI machine doesn't redo the + * interactive flow on every run. Only meaningful when `auth === 'microsoft'`. */ + profilesFolder?: string; +} + +/** + * A Minecraft server the runner can point bots at, plus however it needs to be + * prepared and torn down. `local` spawns and kills its own Paper process; + * `external` attaches to an already-running one instead. + */ +export interface Environment { + readonly id: string; + readonly capabilities: EnvironmentCapabilities; + /** Prepares the server. Receives the session so output/bot bookkeeping lands there + * instead of in module state. */ + setup(session: Session): Promise; + connection(): BotConnectionOptions; + console(): ServerConsole | null; + /** Leasable accounts for this environment. Absent means "generate a throwaway + * `Test_` per bot" — `local`'s only mode, unchanged from before `AccountPool` + * existed. */ + accounts?(): AccountPool | null; + /** Called immediately before each bot connects. Environments that must not hammer a + * shared server (e.g. `external`'s `joinThrottleMs`) rate-limit connects here; the + * default (no-op when absent) matches `local`'s always-immediate connect. */ + beforeJoin?(): Promise; + teardown(): Promise; +} diff --git a/runner-package/lib/environments/external.ts b/runner-package/lib/environments/external.ts new file mode 100644 index 0000000..4c617d4 --- /dev/null +++ b/runner-package/lib/environments/external.ts @@ -0,0 +1,139 @@ +import pc from 'picocolors'; +import type { Environment, EnvironmentCapabilities, BotConnectionOptions } from '../environment.js'; +import type { ServerConsole } from '../console.js'; +import type { Session } from '../session.js'; +import type { SecretRef } from '../config.js'; +import { resolveSecret } from '../config.js'; +import { AccountPool } from '../account.js'; +import type { AccountsConfig } from '../account.js'; +import { sleep } from '../utils.js'; +import { rconConsole } from '../rcon/index.js'; + +export interface ExternalConsoleChannelConfig { + kind: 'rcon'; + port?: number; + password?: SecretRef; +} + +export interface ExternalEnvironmentConfig { + host: string; + port: number; + minecraftVersion?: string | null; + joinThrottleMs?: number | null; + console?: ExternalConsoleChannelConfig[] | null; + accounts?: AccountsConfig | null; +} + +const BASE_CAPABILITIES: EnvironmentCapabilities = { + console: false, + consoleOutput: 'none', + // Never assumed true: nothing here proves the leased accounts actually have op rights + // on the stand. A mode that can prove it would override this after setup(). + op: false, +}; + +/** + * Attaches bots to a server this mode does not own: no spawn, no patch, no shutdown. What it + * does provide — a console channel (probed in declaration order), a merged account pool, and + * join throttling — exists because a shared, already-running stand can't offer the guarantees + * `local` gets for free from owning the whole process. + */ +class ExternalEnvironment implements Environment { + readonly id = 'external'; + + private readonly config: ExternalEnvironmentConfig; + private readonly accountPool: AccountPool; + private _capabilities: EnvironmentCapabilities = BASE_CAPABILITIES; + private _console: ServerConsole | null = null; + private lastJoinAt = 0; + + constructor(config: ExternalEnvironmentConfig) { + this.config = config; + this.accountPool = new AccountPool(config.accounts); + } + + get capabilities(): EnvironmentCapabilities { + return this._capabilities; + } + + accounts(): AccountPool { + return this.accountPool; + } + + async setup(_session: Session): Promise { + for (const channel of this.config.console ?? []) { + const candidate = await this.buildChannel(channel); + if (!candidate) continue; + try { + if (await candidate.probe()) { + this._console = candidate; + break; + } + console.log(pc.yellow(`[external] console channel "${channel.kind}" did not respond to probe()`)); + } catch (error) { + console.log(pc.yellow(`[external] console channel "${channel.kind}" failed to connect: ${(error as Error).message}`)); + } + } + + this._capabilities = { + ...BASE_CAPABILITIES, + console: this._console !== null, + consoleOutput: this._console?.output ?? 'none', + // 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 + ? pc.green(`[external] console channel reachable (output=${this._console.output})`) + : pc.dim('[external] no console channel reachable, running without one')); + } + + private async buildChannel( + channel: ExternalConsoleChannelConfig, + ): Promise { + if (channel.kind === 'rcon') { + return rconConsole({ + host: this.config.host, + port: channel.port ?? 25575, + password: channel.password ? resolveSecret(channel.password) : '', + }); + } + + return null; + } + + connection(): BotConnectionOptions { + return { + host: this.config.host, + port: this.config.port, + version: this.config.minecraftVersion ?? undefined, + // Per-bot auth is decided by the leased Account, not here — test-runner.ts + // overrides this default when the account is `microsoft`. + auth: 'offline', + }; + } + + console(): ServerConsole | null { + return this._console; + } + + async beforeJoin(): Promise { + const throttle = this.config.joinThrottleMs ?? 0; + if (throttle <= 0) return; + const wait = this.lastJoinAt + throttle - Date.now(); + if (wait > 0) await sleep(wait); + this.lastJoinAt = Date.now(); + } + + async teardown(): Promise { + // No lifecycle: the tested server isn't ours to stop. + if (this._console?.close) { + await this._console.close(); + } + } +} + +export function externalEnvironment(config: ExternalEnvironmentConfig): Environment { + return new ExternalEnvironment(config); +} diff --git a/runner-package/lib/environments/local.ts b/runner-package/lib/environments/local.ts new file mode 100644 index 0000000..0bcf489 --- /dev/null +++ b/runner-package/lib/environments/local.ts @@ -0,0 +1,248 @@ +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import pc from 'picocolors'; +import type { Environment, EnvironmentCapabilities, BotConnectionOptions } from '../environment.js'; +import type { ServerConsole } from '../console.js'; +import type { LocalEnvironmentConfig } from '../config.js'; +import type { Session } from '../session.js'; +import { rconConsole } from '../rcon/index.js'; + +const CAPABILITIES: EnvironmentCapabilities = { + console: true, + consoleOutput: 'full', + op: true, +}; + +/** + * The mode that's been here all along: download Paper, patch configs (Gradle side), + * spawn it, tear it down. + * + * Commands are sent over RCON (the same protocol external mode uses) for reliable + * command-response correlation. The full server log is still captured via stdout/stderr + * so `expect(server).toHaveReceivedMessage(...)` keeps working — that's what + * `consoleOutput: 'full'` means. + */ +export class LocalEnvironment implements Environment { + readonly id = 'local'; + readonly capabilities = CAPABILITIES; + + private readonly config: LocalEnvironmentConfig; + private serverProcess: ChildProcessWithoutNullStreams | null = null; + private session: Session | null = null; + private cleanupStarted = false; + private _rconConsole: ServerConsole | null = null; + + constructor(config: LocalEnvironmentConfig) { + this.config = config; + } + + async setup(session: Session): Promise { + this.session = session; + + const { serverJar, serverDir, javaPath } = this.config; + if (!serverJar || !serverDir || !javaPath) { + throw new Error('Environment config must provide serverJar, serverDir and javaPath'); + } + + console.log(`${pc.bold('Starting Paper server...')}`); + const jvmArgs = this.config.jvmArgs ?? []; + console.log(pc.dim(`JVM Arguments: ${jvmArgs.join(' ')}`)); + + const serverProcess = spawn(javaPath, [...jvmArgs, '-jar', serverJar, '--nogui'], { + cwd: serverDir, + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.serverProcess = serverProcess; + this._installProcessGuards(serverProcess); + + // stdout/stderr continuously feed the full console log — this is what makes + // `consoleOutput: 'full'` true and `expect(server).toHaveReceivedMessage` work. + serverProcess.stdout.on('data', (data: Buffer) => session.writeConsoleOutput(data)); + serverProcess.stderr.on('data', (data: Buffer) => session.writeConsoleOutput(data)); + // Ignore EPIPE if server process terminates before/during teardown stdin writes. + serverProcess.stdin.on('error', () => { /* ignore */ }); + + await this._waitForServerStart(serverProcess); + console.log(`${pc.green(pc.bold('Server started successfully'))}\n`); + + // Connect to the local server's RCON for sending commands. RCON gives a proper + // synchronous response per command, unlike the old stdin `/say ` trick. + await this._connectRcon(); + } + + /** + * Connects to the local server via RCON. + */ + private async _connectRcon(): Promise { + const consoleInstance: ServerConsole = rconConsole({ + host: this.config.host ?? 'localhost', + port: this.config.rconPort ?? 25575, + password: this.config.rconPassword ?? 'plugwright', + }); + + // RCON may need a moment after the server logs "Done" — retry a few times. + const maxAttempts = 5; + let lastError: Error | null = null; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + if (await consoleInstance.probe()) { + this._rconConsole = consoleInstance; + console.log(pc.green(`[local] RCON connected (port ${this.config.rconPort ?? 25575})`)); + return; + } + } catch (error) { + lastError = error as Error; + } + // Wait before retrying — RCON listener may start slightly after the game loop. + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + throw new Error( + `RCON failed to connect to the local server after ${maxAttempts} attempts. ` + + 'Make sure enable-rcon=true is set in server.properties (the Gradle plugin does ' + + `this automatically). ${lastError ? `(${lastError.message})` : ''}` + ); + } + + connection(): BotConnectionOptions { + return { + host: this.config.host ?? 'localhost', + port: this.config.port ?? 25565, + version: this.config.minecraftVersion ?? undefined, + auth: 'offline', + }; + } + + console(): ServerConsole | null { + return this._rconConsole; + } + + async teardown(): Promise { + if (this._rconConsole?.close) { + await this._rconConsole.close(); + } + + const serverProcess = this.serverProcess; + if (!serverProcess) return; + + // Send `stop` through stdin — reliable even if RCON has already disconnected. + if (serverProcess.exitCode === null && !serverProcess.killed) { + try { + serverProcess.stdin.write('stop\n'); + } catch (err) { + console.log(pc.yellow(`[WARNING] Failed to send stop command to server: ${(err as Error).message}`)); + } + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + console.log(pc.yellow('[WARNING] Server did not stop gracefully, forcing shutdown...')); + serverProcess.kill(); + resolve(); + }, 30000); + + serverProcess.once('exit', (code) => { + clearTimeout(timeout); + if (code !== 0) { + console.log(pc.yellow(`[WARNING] Server exited with code: ${code}`)); + } + resolve(); + }); + }); + + serverProcess.removeAllListeners(); + serverProcess.stdin.end(); + serverProcess.stdout.destroy(); + serverProcess.stderr.destroy(); + } + + private _waitForServerStart(serverProcess: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('Server failed to start within 120 seconds')); + }, 120000); + + const dataHandler = (data: Buffer): void => { + const output = data.toString(); + if (output.includes('Done (')) { + cleanup(); + setTimeout(resolve, 3000); + } + }; + + const errorHandler = (err: Error): void => { + cleanup(); + reject(new Error(`Failed to start server: ${err.message}`)); + }; + + const exitHandler = (code: number | null): void => { + if (code !== null && code !== 0) { + cleanup(); + reject(new Error(`Server exited with code ${code} before becoming ready`)); + } + }; + + const cleanup = (): void => { + clearTimeout(timeout); + serverProcess.stdout.removeListener('data', dataHandler); + serverProcess.removeListener('error', errorHandler); + serverProcess.removeListener('exit', exitHandler); + }; + + serverProcess.stdout.on('data', dataHandler); + serverProcess.on('error', errorHandler); + serverProcess.on('exit', exitHandler); + }); + } + + /** + * Kills the Paper process tree if our own process dies unexpectedly — Gradle task + * cancelled from the IDE, SIGKILL from upstream, etc. Otherwise java.exe keeps + * running and holds run/logs/latest.log open, breaking the next clean on Windows. + */ + private _installProcessGuards(serverProcess: ChildProcessWithoutNullStreams): void { + const killServerTree = (): void => { + if (!serverProcess.pid || serverProcess.killed || serverProcess.exitCode !== null) return; + try { + if (process.platform === 'win32') { + // taskkill recursively kills the whole java process tree. + spawn('taskkill', ['/F', '/T', '/PID', String(serverProcess.pid)], { + stdio: 'ignore', + windowsHide: true, + }).on('error', () => { /* best effort */ }); + } else { + serverProcess.kill('SIGKILL'); + } + } catch { + /* best effort */ + } + }; + + const emergencyShutdown = (signal: string): void => { + if (this.cleanupStarted) return; + this.cleanupStarted = true; + console.log(pc.yellow(`\n[runner] Received ${signal}, killing Paper server...`)); + killServerTree(); + // Give taskkill a moment, then exit. + setTimeout(() => process.exit(1), 500).unref(); + }; + + process.on('SIGINT', () => emergencyShutdown('SIGINT')); + process.on('SIGTERM', () => emergencyShutdown('SIGTERM')); + process.on('SIGHUP', () => emergencyShutdown('SIGHUP')); + if (process.platform === 'win32') { + process.on('SIGBREAK', () => emergencyShutdown('SIGBREAK')); + } + // Last-resort safety net: if this node process exits for any reason while + // the server is still alive, try to take it down with us. + process.on('exit', () => killServerTree()); + // On Windows, when the parent (Gradle) is killed abruptly, signals are not + // delivered but our stdin pipe closes. Use that as a death signal. + if (process.stdin && typeof process.stdin.on === 'function') { + process.stdin.on('close', () => emergencyShutdown('stdin-close')); + process.stdin.on('end', () => emergencyShutdown('stdin-end')); + // stdin must be resumed for 'end'/'close' to fire on a piped stdin. + try { process.stdin.resume(); } catch { /* ignore */ } + } + } +} diff --git a/runner-package/lib/matchers.ts b/runner-package/lib/matchers.ts index cfbc3f3..0dac074 100644 --- a/runner-package/lib/matchers.ts +++ b/runner-package/lib/matchers.ts @@ -2,7 +2,6 @@ import { Matchers } from './expect.js'; import { PlayerWrapper } from './player.js'; import { ServerWrapper } from './server.js'; import { GuiItemLocator } from './wrappers.js'; -import { serverConsoleBuffer } from './bot-utils.js'; import { sleep } from './utils.js'; export class RunnerMatchers extends Matchers { @@ -62,6 +61,20 @@ export class RunnerMatchers extends Matchers { throw new Error(this.isNot ? passMessage() : failMessage()); } + /** + * Waits for the player or server console to receive a message matching the expected text or pattern. + * + * ### Concurrency & Isolation Guidance: + * - **`expect(player)`**: Checks `player.messageBuffer`, which is strictly isolated per bot. + * Always prefer `expect(player)` when asserting on chat, notifications, or feedback addressed to a player. + * It is completely safe from race conditions in concurrent tests (`concurrency: N`). + * - **`expect(server)`**: Reads `session.consoleLog`, which is a single shared stream for the entire server. + * In concurrent test execution (`concurrency: N`), lines from other bots appear in this log simultaneously. + * When asserting on server logs under concurrency, always qualify patterns with `${player.username}` + * (e.g. `new RegExp(`Gave 100 to ${player.username}`)`) or narrow the search window with `options.since`. + * For global assertions without a player identifier (e.g. `[Plugin] Reload complete`), run the test without + * the `concurrency` option as a standard, single-runner test. + */ async toHaveReceivedMessage( this: RunnerMatchers, expectedMessage: string | RegExp, @@ -73,8 +86,28 @@ export class RunnerMatchers extends Matchers { return strict ? msg === expectedMessage : msg.includes(expectedMessage); }; - const buffer = this.actual instanceof PlayerWrapper ? this.actual.messageBuffer : serverConsoleBuffer; - const view = (): string[] => since !== undefined ? buffer.slice(since) : buffer; + 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 — and never cleared, so a test that + // doesn't pass `since` defaults to its own `ServerWrapper.startIndex` instead of 0. + const buffer = this.actual instanceof PlayerWrapper + ? this.actual.messageBuffer + : session.consoleLog; + const effectiveSince = since ?? (this.actual instanceof PlayerWrapper ? undefined : this.actual.startIndex); + const view = (): string[] => buffer.slice(effectiveSince); await this.pollAssertion( () => view().some(isMatch), @@ -179,9 +212,9 @@ interface PollOptions { } export class PollMatchers { - private fn: () => T | Promise; - private options: Required> & { message?: string }; - private isNot: boolean; + private readonly fn: () => T | Promise; + private readonly options: Required> & { message?: string }; + private readonly isNot: boolean; constructor(fn: () => T | Promise, options: PollOptions = {}, isNot: boolean = false) { this.fn = fn; diff --git a/runner-package/lib/microsoft-auth.ts b/runner-package/lib/microsoft-auth.ts new file mode 100644 index 0000000..67e25ee --- /dev/null +++ b/runner-package/lib/microsoft-auth.ts @@ -0,0 +1,94 @@ +import path from 'node:path'; +import os from 'node:os'; +import type { Authflow as AuthflowInstance } from 'prismarine-auth'; +import prismarineAuth from 'prismarine-auth'; +// prismarine-auth is CJS; named imports aren't reliable under Node's ESM interop +// (cjs-module-lexer missed `Titles` here at runtime — "does not provide an export named +// 'Titles'" — even though both are plain properties of module.exports). Destructure the +// default import instead. +const { Authflow, Titles } = prismarineAuth; + +/** Same default `minecraft-protocol` itself falls back to (via the `minecraft-folder-path` + * package) when `profilesFolder` isn't set — kept in sync here since our custom `auth` function + * replaces its whole dispatch, defaults included. */ +function defaultMinecraftFolder(): string { + switch (os.type()) { + case 'Darwin': + return path.join(os.homedir(), 'Library', 'Application Support', 'minecraft'); + case 'Windows_NT': + return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), '.minecraft'); + default: + return path.join(os.homedir(), '.minecraft'); + } +} + +/** What we keep from a Microsoft account's login response across connections: everything + * `minecraft-protocol`'s own `microsoftAuth.authenticate` fetches but never caches itself. */ +interface CachedProfile { + profile: Record; + certificates: Record | undefined; +} + +/** + * In-memory cache of `fetchProfile`/`fetchCertificates` results, keyed by Microsoft account + * username, kept for the life of this process. + * + * Every test gets its own bot connection (`Session.createBot` → `mineflayer.createBot`), so a + * `microsoft`-auth account redoes the full Microsoft handshake on every single test. The MS/Xbox + * *token* is already disk-cached by `prismarine-auth` (`Authflow.getMinecraftJavaToken`'s own + * `verifyTokens()` check) and stays cheap, but `fetchProfile`/`fetchCertificates` run + * unconditionally on every connect with no caching of their own — enough tests in one run and one + * of those calls eventually hits a rate limit, failing a test whose account is perfectly fine. + * See issue #69. + */ +const cache = new Map(); + +/** + * Builds a `minecraft-protocol` custom `auth` function for one Microsoft account, backed by + * [cache]. Mirrors `minecraft-protocol`'s own `microsoftAuth.authenticate` (same defaults, same + * session/error shape) but only fetches profile/certificates once per `username` per process — + * every connection still gets a fresh access token, since that part is cheap already. + */ +export function microsoftAuthWithCache(username: string) { + return async (client: any, options: any): Promise => { + if (!options.profilesFolder) options.profilesFolder = path.join(defaultMinecraftFolder(), 'nmp-cache'); + if (options.authTitle === undefined) { + options.authTitle = Titles.MinecraftNintendoSwitch; + options.deviceType = 'Nintendo'; + options.flow = 'live'; + } + + const authflow: AuthflowInstance = client.authflow ?? new Authflow(options.username, options.profilesFolder, options, options.onMsaCode); + client.authflow = authflow; + + const cached = cache.get(username); + const { token, profile, certificates } = await authflow.getMinecraftJavaToken({ + fetchProfile: !cached, + fetchCertificates: !cached && !options.disableChatSigning, + }).catch((err: Error) => { + if (options.password) console.warn('Sign in failed, try removing the password field\n'); + if (err.toString().includes('Not Found')) console.warn(`Please verify that the account ${options.username} owns Minecraft\n`); + throw err; + }); + + let entry = cached; + if (!entry) { + if (!profile || (profile as any).error) throw new Error(`Failed to obtain profile data for ${options.username}, does the account own minecraft?`); + entry = { profile, certificates }; + cache.set(username, entry); + } + + options.haveCredentials = token !== null; + const session = { + accessToken: token, + selectedProfile: entry.profile, + availableProfile: [entry.profile], + }; + Object.assign(client, entry.certificates); + client.session = session; + client.username = entry.profile.name; + options.accessToken = token; + client.emit('session', session); + options.connect(client); + }; +} diff --git a/runner-package/lib/player.ts b/runner-package/lib/player.ts index 9d4a7c5..aec15b2 100644 --- a/runner-package/lib/player.ts +++ b/runner-package/lib/player.ts @@ -1,14 +1,21 @@ import { Bot } from 'mineflayer'; import { ItemWrapper, GuiWrapper, createPlayerExtensions, Window, LiveGuiHandle } from './wrappers.js'; import { ServerWrapper } from './server.js'; -import { activeBots, disconnectBot, createBot } from './bot-utils.js'; -import { poll } from './utils.js'; +import type { Session } from './session.js'; +import { MessageBuffer } from './session.js'; +import type { BotConnectionOptions } from './environment.js'; +import type { Account } from './account.js'; +import { poll, waitUntil } from './utils.js'; import { randomUUID } from 'node:crypto'; import pc from 'picocolors'; export class PlayerWrapper { bot: Bot; - public readonly messageBuffer: string[] = []; + readonly session: Session; + /** This player's own received-chat log. Kept per player, not per session, so one bot's + * chat can't satisfy — or pollute — an assertion made against another bot in the same + * test run. */ + readonly messageBuffer = new MessageBuffer(); get inventory() { return this.bot.inventory; @@ -18,37 +25,21 @@ export class PlayerWrapper { return this.bot.username; } - /** - * @deprecated Use `player.gui({ title })` instead. - */ - waitForGui!: (guiMatcher: (gui: GuiWrapper) => boolean, options?: { timeout?: number }) => Promise; - - /** - * @deprecated Use `gui.locator(predicate)` with expectations instead. - */ - waitForGuiItem!: (itemMatcher: (item: ItemWrapper) => boolean, options?: { timeout?: number, pollingRate?: number }) => Promise; - - /** - * @deprecated Use `gui.locator(predicate).click()` instead. - */ - clickGuiItem!: (itemMatcher: (item: ItemWrapper) => boolean, options?: { timeout?: number, pollingRate?: number }) => Promise; - gui!: (options: { title: string | RegExp; timeout?: number }) => Promise; private serverWrapper?: ServerWrapper; - private _botOptions?: { host: string; port: number; version: string | undefined; auth: 'mojang' | 'microsoft' | 'offline' }; + private _botOptions?: BotConnectionOptions; private _spawnPromise: Promise | null = null; private _listenersBot: Bot | null = null; + private _account?: Account; - constructor(bot: Bot) { + constructor(bot: Bot, session: Session) { this.bot = bot; + this.session = session; this._bindExtensions(bot); } private _bindExtensions(bot: Bot): void { const extensions = createPlayerExtensions(bot); - this.waitForGui = extensions.waitForGui.bind(this); - this.waitForGuiItem = extensions.waitForGuiItem.bind(this); - this.clickGuiItem = extensions.clickGuiItem.bind(this); this.gui = extensions.gui.bind(this); } @@ -67,19 +58,19 @@ export class PlayerWrapper { const onSpawn = () => { cleanup(); - console.log(`${pc.cyan('[Bot]')} ${pc.dim(`${name()} spawned successfully`)}`); + console.log(`${pc.cyan(`[Bot ${name()}]`)} Spawned successfully`); resolve(); }; const onError = (err: Error) => { cleanup(); - console.log(pc.red(`[Bot] ${name()} connection error: ${err.message}`)); + console.log(`${pc.cyan(`[Bot ${name()}]`)} ${pc.red(`Connection error: ${err.message}`)}`); reject(err); }; const onKicked = (reason: string) => { cleanup(); - console.log(pc.red(`[Bot] ${name()} kicked: ${reason}`)); + console.log(`${pc.cyan(`[Bot ${name()}]`)} ${pc.red(`Kicked: ${reason}`)}`); reject(new Error(`Bot ${name()} was kicked: ${reason}`)); }; @@ -105,10 +96,53 @@ export class PlayerWrapper { this._captureSpawnPromise(timeout); } + // 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; + } - this._registerPersistentListeners(); + /** 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 */ + _setAccount(account: Account): void { + this._account = account; + } + + /** The account this player connected with. Set for every player the runner creates + * (`createPlayer` always calls `_setAccount`); undefined only if constructed by hand. */ + get account(): Account | undefined { + return this._account; } private _registerPersistentListeners(): void { @@ -125,20 +159,20 @@ export class PlayerWrapper { bot.on('message', (jsonMsg: unknown) => { const message = String(jsonMsg); - console.log(pc.dim(`[Bot ${botUsername()}] Received message: "${message}"`)); + console.log(`${pc.cyan(`[Bot ${botUsername()}]`)} ${pc.dim(`Received message: "${message}"`)}`); this.messageBuffer.push(message); }); bot.on('windowOpen', (window: unknown) => { if (process.env.PLUGWRIGHT_DEBUG !== '1') return; const win = window as { title?: string; type?: string | number; slots?: unknown[] }; - console.log(pc.gray(`[DEBUG] [Bot ${botUsername()}] Global windowOpen event - Title: "${win.title}", Type: ${win.type}, SlotCount: ${win.slots?.length}`)); + console.log(`${pc.gray('[DEBUG]')} ${pc.cyan(`[Bot ${botUsername()}]`)} ${pc.gray(`Global windowOpen event - Title: "${win.title}", Type: ${win.type}, SlotCount: ${win.slots?.length}`)}`); }); bot.on('windowClose', (window: unknown) => { if (process.env.PLUGWRIGHT_DEBUG !== '1') return; const win = window as { title?: string }; - console.log(pc.gray(`[DEBUG] [Bot ${botUsername()}] windowClose event - Window: ${win?.title || 'unknown'}`)); + console.log(`${pc.gray('[DEBUG]')} ${pc.cyan(`[Bot ${botUsername()}]`)} ${pc.gray(`windowClose event - Window: ${win?.title || 'unknown'}`)}`); }); } @@ -151,8 +185,28 @@ 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, + ); + const name = this.bot?.username ?? this.username; + const tag = name ? `[Bot ${name}]` : '[Bot]'; + console.log(`${pc.cyan(tag)} ${pc.dim(`Chatting: ${logged}`)}`); this.bot.chat(message); } @@ -160,7 +214,7 @@ export class PlayerWrapper { * Clears the received message history for this player. */ clearMessages(): void { - this.messageBuffer.length = 0; + this.messageBuffer.clear(); } getMessageBufferIndex(): number { @@ -187,22 +241,23 @@ export class PlayerWrapper { async makeOp(): Promise { this.requireServer(); - this.serverWrapper!.execute(`minecraft:op ${this.username}`); - - await poll( - () => this.messageBuffer.find(m => m.includes(`Made ${this.username} a server operator`)), - { message: `Player ${this.username} was not opped` } - ); + const response = await this.serverWrapper!.execute(`minecraft:op ${this.username}`); + if (!/operator/i.test(response) && !/nothing changed/i.test(response)) { + throw new Error(`Player ${this.username} was not opped: ${response.trim() || 'no response from the console'}`); + } } async deOp(): Promise { - await this.executeAndSync(`minecraft:deop ${this.username}`); + this.requireServer(); + await this.serverWrapper!.execute(`minecraft:deop ${this.username}`); } async setGameMode(mode: 'survival' | 'creative' | 'adventure' | 'spectator'): Promise { - if (this.bot.game.gameMode === mode) return; + if (this.bot.game.gameMode === mode) { + return; + } this.requireServer(); - this.serverWrapper!.execute(`minecraft:gamemode ${mode} ${this.username}`); + await this.serverWrapper!.execute(`minecraft:gamemode ${mode} ${this.username}`); await poll( () => this.bot.game.gameMode === mode ? true : undefined, @@ -212,11 +267,12 @@ export class PlayerWrapper { async teleport(x: number, y: number, z: number): Promise { this.requireServer(); - this.serverWrapper!.execute(`minecraft:tp ${this.username} ${x} ${y} ${z}`); + await this.serverWrapper!.execute(`minecraft:tp ${this.username} ${x} ${y} ${z}`); await poll( () => { - const pos = this.bot.entity.position; + const pos = this.bot.entity?.position; + if (!pos) return undefined; const close = Math.abs(pos.x - x) < 1 && Math.abs(pos.y - y) < 1 && @@ -228,7 +284,7 @@ export class PlayerWrapper { } /** @internal */ - _setBotOptions(opts: { host: string; port: number; version: string | undefined; auth: 'mojang' | 'microsoft' | 'offline' }): void { + _setBotOptions(opts: BotConnectionOptions): void { this._botOptions = opts; } @@ -245,17 +301,12 @@ export class PlayerWrapper { const botUsername = this.username; const oldBot = this.bot; - await disconnectBot(oldBot, botUsername); + await this.session.disconnectBot(oldBot, botUsername); + this.session.removeBot(oldBot); - const idx = activeBots.indexOf(oldBot); - if (idx !== -1) activeBots.splice(idx, 1); - - const newBot = createBot({ - host: this._botOptions.host, - port: this._botOptions.port, + const newBot = this.session.createBot({ + ...this._botOptions, username: botUsername, - version: this._botOptions.version, - auth: this._botOptions.auth, }); this.bot = newBot; @@ -267,15 +318,14 @@ export class PlayerWrapper { try { await this.join(options); } catch (err) { - const idx = activeBots.indexOf(this.bot); - if (idx !== -1) activeBots.splice(idx, 1); + this.session.removeBot(this.bot); throw err; } } async giveItem(item: string, count: number = 1): Promise { this.requireServer(); - this.serverWrapper!.execute(`minecraft:give ${this.username} ${item} ${count}`); + await this.serverWrapper!.execute(`minecraft:give ${this.username} ${item} ${count}`); await poll( () => { @@ -288,21 +338,45 @@ export class PlayerWrapper { ); } + /** + * Clears the player's inventory using `minecraft:clear` and waits until the + * bot's client-side inventory reflects the empty state. + * + * If `item` is provided, clears only items matching that name. + */ + async clearInventory( + itemOrOptions?: string | { timeout?: number }, + options: { timeout?: number } = {} + ): Promise { + this.requireServer(); + const item = typeof itemOrOptions === 'string' ? itemOrOptions : undefined; + const opts = typeof itemOrOptions === 'object' ? itemOrOptions : options; + const timeout = opts.timeout ?? 5000; + + if (item) { + await this.serverWrapper!.execute(`minecraft:clear ${this.username} ${item}`); + await waitUntil( + () => !this.bot.inventory.items().some(i => i.name.includes(item)), + { + message: `Inventory item "${item}" for ${this.username} was not cleared`, + timeout, + } + ); + } else { + await this.serverWrapper!.execute(`minecraft:clear ${this.username}`); + await waitUntil( + () => this.bot.inventory.items().length === 0, + { + message: `Inventory for ${this.username} was not cleared`, + timeout, + } + ); + } + } + private requireServer(): void { if (!this.serverWrapper) { throw new Error('ServerWrapper not set on PlayerWrapper'); } } - - private async executeAndSync(cmd: string): Promise { - this.requireServer(); - const syncId = `sync_${randomUUID().split('-')[0]}`; - this.serverWrapper!.execute(cmd); - this.serverWrapper!.execute(`minecraft:say ${syncId}`); - - await poll( - () => this.messageBuffer.find(m => m.includes(syncId)), - { message: `Server command sync timed out for: ${cmd}` } - ); - } } \ No newline at end of file diff --git a/runner-package/lib/plugin-host.ts b/runner-package/lib/plugin-host.ts new file mode 100644 index 0000000..b8780c4 --- /dev/null +++ b/runner-package/lib/plugin-host.ts @@ -0,0 +1,137 @@ +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'; +import type { Environment } from './environment.js'; +import type { Account } from './account.js'; +import type { TestContext } from './types.js'; +import type { PluginConfig } from './config.js'; + +interface LoadedPlugin { + plugin: PlugwrightPlugin; + options: Record; + inheritTests: boolean; +} + +/** + * Owns every loaded `PlugwrightPlugin`: hooks fired around each test, matchers merged into + * `RunnerMatchers`, fixtures merged into `TestContext`, and inherited test files. One + * instance per session. + */ +export class PluginHost { + private readonly plugins: LoadedPlugin[] = []; + + async load(configs: PluginConfig[]): Promise { + for (const cfg of configs) { + let mod: any; + try { + mod = await importOptionalPackage(cfg.specifier); + } catch (error) { + throw new Error(`Failed to load plugin "${cfg.specifier}": ${(error as Error).message}`); + } + + const plugin = (mod.default ?? mod) as PlugwrightPlugin; + if (!plugin || typeof plugin.name !== 'string') { + throw new Error(`Plugin "${cfg.specifier}" has no default export implementing PlugwrightPlugin (missing "name")`); + } + if (plugin.apiVersion !== undefined && plugin.apiVersion > PLUGIN_API_VERSION) { + throw new Error( + `Plugin "${plugin.name}" was built against plugin API v${plugin.apiVersion}, ` + + `this runner supports up to v${PLUGIN_API_VERSION}. Update @plugwright/runner.` + ); + } + + this.plugins.push({ plugin, options: cfg.options ?? {}, inheritTests: cfg.inheritTests ?? true }); + console.log(pc.dim(`[plugin] loaded "${plugin.name}" (${cfg.specifier})`)); + } + } + + get names(): string[] { + return this.plugins.map(p => p.plugin.name); + } + + /** Merges declared matchers into the shared `RunnerMatchers` prototype. Must run before + * the first spec file is imported — `expect(x).foo()` looks the matcher up on the + * prototype at call time, not at registration time. */ + registerMatchers(): void { + for (const { plugin } of this.plugins) { + for (const [matcherName, fn] of Object.entries(plugin.matchers ?? {})) { + (RunnerMatchers.prototype as any)[matcherName] = fn; + } + } + } + + async setup(session: Session): Promise { + for (const { plugin, options } of this.plugins) { + await plugin.setup?.({ session, env: session.env, options }); + } + } + + async onPlayerCreate(player: PlayerWrapper, ctx: { account: Account; env: Environment }): Promise { + for (const { plugin } of this.plugins) { + await plugin.onPlayerCreate?.(player, ctx); + } + } + + async beforeEach(ctx: TestContext): Promise { + for (const { plugin } of this.plugins) { + await plugin.beforeEach?.(ctx); + } + } + + /** Runs in reverse plugin order, mirroring the LIFO shape of afterEach hooks elsewhere. + * Errors are logged, not thrown — a plugin's own afterEach hiccup shouldn't flip an + * otherwise-passing test's result. */ + async afterEach(ctx: TestContext): Promise { + for (const { plugin } of [...this.plugins].reverse()) { + try { + await plugin.afterEach?.(ctx); + } catch (error) { + console.error(pc.red(`[plugin ${plugin.name}] afterEach error: ${(error as Error).message}`)); + } + } + } + + extendContext(ctx: TestContext): void { + for (const { plugin } of this.plugins) { + const extra = plugin.extendContext?.(ctx); + if (extra) Object.assign(ctx, extra); + } + } + + /** Inherited test files for the given mode, across every plugin with `inheritTests` + * enabled. `findSpecFiles` never sees these — it skips `node_modules` — so this is the + * only way a plugin's own tests run. */ + testFiles(mode: PluginTestRef['mode']): { file: string; pluginName: string }[] { + return this.plugins + .filter(p => p.inheritTests) + .flatMap(({ plugin }) => + (plugin.tests ?? []) + .filter(t => t.mode === mode) + .map(t => ({ file: t.file, pluginName: plugin.name })) + ); + } + + async runCleanup(session: Session): Promise { + for (const { plugin } of [...this.plugins].reverse()) { + try { + await plugin.cleanup?.({ session }); + } catch (error) { + console.error(pc.red(`[plugin ${plugin.name}] cleanup error: ${(error as Error).message}`)); + } + } + } + + async teardown(): Promise { + for (const { plugin } of [...this.plugins].reverse()) { + try { + await plugin.teardown?.(); + } catch (error) { + console.error(pc.red(`[plugin ${plugin.name}] teardown error: ${(error as Error).message}`)); + } + } + } +} diff --git a/runner-package/lib/plugin.ts b/runner-package/lib/plugin.ts new file mode 100644 index 0000000..fe58c4e --- /dev/null +++ b/runner-package/lib/plugin.ts @@ -0,0 +1,59 @@ +import type { Session } from './session.js'; +import type { Environment } from './environment.js'; +import type { PlayerWrapper } from './player.js'; +import type { TestContext } from './types.js'; +import type { Account } from './account.js'; + +/** Bumped when a breaking change lands in the plugin contract. Checked against a loaded + * plugin's own `apiVersion` so a stale plugin fails with a clear message instead of a + * confusing runtime error. */ +export const PLUGIN_API_VERSION = 1; + +export interface SessionContext { + session: Session; + env: Environment; + options: O; +} + +export interface CleanupContext { + session: Session; +} + +export interface PluginTestRef { + /** Path to a compiled spec file, same format the runner's own `test()`/`describe()` + * files use. */ + file: string; + /** `preflight` runs first, before user specs, and aborts the session on failure. + * `suite` runs alongside user specs as regular tests, tagged with the plugin's name + * in reports. */ + mode: 'preflight' | 'suite'; +} + +export type MatcherFn = (this: any, ...args: any[]) => unknown; + +/** + * Extends the test engine without the engine knowing about it: fixtures, matchers, + * authentication hooks, inherited tests, cleanup. + */ +export interface PlugwrightPlugin { + name: string; + apiVersion?: number; + setup?(session: SessionContext): Promise | void; + /** Fired on every bot connection — initial join and every `player.rejoin()` — not just + * the first. A one-shot "first test" can't cover a second bot or a rejoin, which is + * why this is a hook rather than a `preflight` test. */ + onPlayerCreate?(player: PlayerWrapper, ctx: { account: Account; env: Environment }): Promise | void; + beforeEach?(ctx: TestContext): Promise | void; + afterEach?(ctx: TestContext): Promise | void; + extendContext?(ctx: TestContext): Record | void; + matchers?: Record; + tests?: PluginTestRef[]; + cleanup?(ctx: CleanupContext): Promise | void; + teardown?(): Promise | void; +} + +/** Identity function — exists for type inference at the plugin's definition site, the same + * role `defineConfig()` plays in other tools. */ +export function definePlugin(plugin: PlugwrightPlugin): PlugwrightPlugin { + return plugin; +} diff --git a/runner-package/lib/rcon/connection.ts b/runner-package/lib/rcon/connection.ts new file mode 100644 index 0000000..59e0b17 --- /dev/null +++ b/runner-package/lib/rcon/connection.ts @@ -0,0 +1,182 @@ +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; + + let hasConnected = false; + const connectTimer = setTimeout(() => { + if (!hasConnected) { + socket.destroy(new Error(`RCON connection to ${this.host}:${this.port} timed out after 10000ms`)); + } + }, 10000); + + socket.once('connect', () => { + clearTimeout(connectTimer); + hasConnected = true; + socket.setNoDelay(true); + this.pendingAuth = { + resolve: () => resolve(), + reject: (err) => reject(err), + }; + const id = this.nextId++; + if (this.nextId > 0x7fffffff) this.nextId = 1; + socket.write(encodePacket(id, PacketType.AUTH, this.password)); + }); + + socket.on('data', (chunk) => this.onData(chunk)); + + socket.on('error', (err) => { + clearTimeout(connectTimer); + if (this.socket === socket) { + this.connectPromise = null; + } + if (!hasConnected) { + reject(err); + } + if (this.pendingAuth) { + this.pendingAuth.reject(err); + this.pendingAuth = null; + } + for (const waiter of this.pending.values()) waiter.reject(err); + this.pending.clear(); + }); + + socket.once('close', () => { + clearTimeout(connectTimer); + if (this.socket === socket) { + this.connectPromise = null; + this.socket = null; + this.inbound = Buffer.alloc(0); + } + const closedError = new Error('RCON connection closed'); + this.pendingAuth?.reject(closedError); + this.pendingAuth = null; + 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 (size < 10 || size > 1024 * 1024) { + // Invalid packet size: minimum RCON packet size is 10 (4 id + 4 type + 1 body null + 1 pad null). + this.inbound = Buffer.alloc(0); + break; + } + if (this.inbound.length < 4 + size) break; + + const body = this.inbound.subarray(4, 4 + size); + this.inbound = this.inbound.subarray(4 + size); + try { + this.handlePacket(decodePacketBody(body)); + } catch (err) { + console.error(`[rcon] Failed to decode packet: ${(err as Error).message}`); + } + } + } + + 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) { + this.socket?.destroy(); + this.socket = null; + this.connectPromise = null; + this.inbound = Buffer.alloc(0); + waiter.reject(new Error('RCON authentication failed: wrong password')); + } else { + waiter.resolve(''); + } + return; + } + + const waiter = this.pending.get(packet.id); + if (waiter) { + waiter.resolve(packet.payload); + } + } + + private _commandQueue = Promise.resolve(); + + async executeAndWait(cmd: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + this._commandQueue = this._commandQueue.then(async () => { + try { + await this.ensureConnected(); + const socket = this.socket; + if (!socket) throw new Error('RCON connection is not open'); + + const id = this.nextId++; + if (this.nextId > 0x7fffffff) this.nextId = 1; + + const result = await new Promise((innerResolve, innerReject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + innerReject(new Error(`RCON command timed out after ${timeoutMs}ms: ${cmd}`)); + }, timeoutMs); + + this.pending.set(id, { + resolve: (payload) => { clearTimeout(timer); this.pending.delete(id); innerResolve(payload); }, + reject: (err) => { clearTimeout(timer); this.pending.delete(id); innerReject(err); }, + }); + + socket.write(encodePacket(id, PacketType.EXECCOMMAND, cmd)); + }); + resolve(result); + } catch (err) { + reject(err); + } + }).catch(() => {}); + }); + } + + execute(cmd: string, timeoutMs: number = 5000): Promise { + return this.executeAndWait(cmd, timeoutMs); + } + + disconnect(): void { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + this.connectPromise = null; + this.inbound = Buffer.alloc(0); + } +} diff --git a/runner-package/lib/rcon/index.ts b/runner-package/lib/rcon/index.ts new file mode 100644 index 0000000..46dfb7d --- /dev/null +++ b/runner-package/lib/rcon/index.ts @@ -0,0 +1,38 @@ +import type { ServerConsole } from '../console.js'; +import { RconConnection } from './connection.js'; + +export interface RconConsoleConfig { + host: string; + port: number; + password: string; +} + +/** + * `ServerConsole` over RCON. + */ +export function rconConsole(config: RconConsoleConfig): ServerConsole { + const connection = new RconConnection(config.host, config.port, config.password); + + return { + output: 'responses', + + async probe(): Promise { + try { + await connection.ensureConnected(); + return true; + } catch (err) { + throw err; + } + }, + + async execute(cmd: string, timeoutMs: number = 5000): Promise { + return connection.executeAndWait(cmd, timeoutMs); + }, + + close(): void { + connection.disconnect(); + } + }; +} + +export { RconConnection }; diff --git a/runner-package/lib/rcon/protocol.ts b/runner-package/lib/rcon/protocol.ts new file mode 100644 index 0000000..61d497a --- /dev/null +++ b/runner-package/lib/rcon/protocol.ts @@ -0,0 +1,41 @@ +/** + * 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 { + if (body.length < 10) throw new Error('Packet body too short'); + const id = body.readInt32LE(0); + const type = body.readInt32LE(4); + const payload = body.toString('utf8', 8, body.length - 2); + return { id, type, payload }; +} diff --git a/runner-package/lib/reporter.ts b/runner-package/lib/reporter.ts index 44edb65..dfa8a8c 100644 --- a/runner-package/lib/reporter.ts +++ b/runner-package/lib/reporter.ts @@ -1,3 +1,5 @@ +import { mkdirSync, writeFileSync } from 'fs'; +import { dirname } from 'path'; import pc from 'picocolors'; import { extractSpecLocation } from './stack-trace.js'; import type { TestResult } from './types.js'; @@ -8,25 +10,43 @@ export function formatDuration(ms: number): string { return `${seconds.toFixed(1)}s`; } +function statusOf(result: TestResult): 'PASS' | 'FAIL' | 'SKIP' { + if (result.skipped) return 'SKIP'; + return result.passed ? 'PASS' : 'FAIL'; +} + +/** min/avg/max duration across a `concurrency > 1` result's instances. */ +function instanceStats(instances: NonNullable): { min: number; avg: number; max: number } { + const durations = instances.map(i => i.durationMs); + return { + min: Math.min(...durations), + avg: Math.round(durations.reduce((sum, d) => sum + d, 0) / durations.length), + max: Math.max(...durations), + }; +} + export function printTestSummary(testResults: TestResult[]): number { console.log(`\n${pc.bold("=".repeat(40))}`); console.log(pc.bold(' Test Summary')); console.log(pc.bold("=".repeat(40))); - const passed = testResults.filter(r => r.passed); - const failed = testResults.filter(r => !r.passed); + const skipped = testResults.filter(r => r.skipped); + const executed = testResults.filter(r => !r.skipped); + const passed = executed.filter(r => r.passed); + const failed = executed.filter(r => !r.passed); const totalDuration = testResults.reduce((sum, r) => sum + r.durationMs, 0); console.log(` Total: ${pc.bold(String(testResults.length))}`); console.log(` Passed: ${pc.green(pc.bold(String(passed.length)))}`); console.log(` Failed: ${failed.length > 0 ? pc.red(pc.bold(String(failed.length))) : pc.dim(String(failed.length))}`); + console.log(` Skipped: ${skipped.length > 0 ? pc.yellow(pc.bold(String(skipped.length))) : pc.dim(String(skipped.length))}`); console.log(` Duration: ${pc.dim(formatDuration(totalDuration))}`); const statusCol = 'Status'; const testCol = 'Test'; const durationCol = 'Duration'; - const statusWidth = Math.max(statusCol.length, ...(testResults.map(r => r.passed ? 'PASS' : 'FAIL').map(s => s.length))); + const statusWidth = Math.max(statusCol.length, ...testResults.map(r => statusOf(r).length)); const durationWidth = Math.max(durationCol.length, ...testResults.map(r => formatDuration(r.durationMs).length)); const testWidth = Math.max(testCol.length, ...testResults.map(r => r.testName.length)); @@ -37,18 +57,33 @@ export function printTestSummary(testResults: TestResult[]): number { console.log(separator); for (const result of testResults) { - const status = result.passed ? 'PASS' : 'FAIL'; + const status = statusOf(result); const statusPadded = status.padEnd(statusWidth); - const coloredStatus = result.passed + const coloredStatus = status === 'PASS' ? pc.green(pc.bold(statusPadded)) - : pc.red(pc.bold(statusPadded)); + : status === 'SKIP' + ? pc.yellow(pc.bold(statusPadded)) + : pc.red(pc.bold(statusPadded)); const duration = formatDuration(result.durationMs); - console.log(` ${coloredStatus} ${result.testName.padEnd(testWidth)} ${pc.dim(duration.padStart(durationWidth))}`); + // A concurrent test/block's row is one aggregate over N instances — say how many + // passed right in the table, not just in the failed-tests detail below. + const instanceTag = result.instances + ? pc.dim(` [${result.instances.filter(i => i.passed).length}/${result.instances.length}]`) + : ''; + console.log(` ${coloredStatus} ${result.testName.padEnd(testWidth)} ${pc.dim(duration.padStart(durationWidth))}${instanceTag}`); } console.log(separator); console.log(` ${''.padEnd(statusWidth)} ${pc.bold('Total'.padEnd(testWidth))} ${pc.dim(formatDuration(totalDuration).padStart(durationWidth))}`); + if (skipped.length > 0) { + console.log(`\n${pc.yellow(pc.bold('Skipped Tests:'))}\n`); + for (const result of skipped) { + console.log(` ${pc.yellow(`- ${result.testName}`)}`); + if (result.skipReason) console.log(` ${pc.dim(result.skipReason)}`); + } + } + if (failed.length > 0) { console.log(`\n${pc.red(pc.bold('Failed Tests:'))}\n`); @@ -63,6 +98,18 @@ export function printTestSummary(testResults: TestResult[]): number { } } + if (result.instances) { + const { min, avg, max } = instanceStats(result.instances); + console.log(` ${pc.dim(`${result.instances.length} instances: min ${formatDuration(min)} / avg ${formatDuration(avg)} / max ${formatDuration(max)}`)}`); + for (const instance of result.instances) { + const tag = `[${instance.index}/${result.instances.length}]`; + const label = instance.botUsername ?? '?'; + const status = instance.passed ? pc.green('OK') : pc.red('FAIL'); + const detail = instance.error ? pc.red(` ${instance.error.message}`) : ''; + console.log(` ${pc.dim(`- ${tag} ${label}:`)} ${status} ${pc.dim(`(${formatDuration(instance.durationMs)})`)}${detail}`); + } + } + console.log(''); } @@ -71,4 +118,88 @@ export function printTestSummary(testResults: TestResult[]): number { console.log(`\n${pc.green(pc.bold('All tests passed!'))}`); return 0; } +} + +function xmlEscape(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Writes the machine-readable report a matrix run aggregates across environments. */ +export function writeJsonReport(path: string, environmentName: string, testResults: TestResult[]): void { + const skipped = testResults.filter(r => r.skipped); + const executed = testResults.filter(r => !r.skipped); + const passed = executed.filter(r => r.passed); + const failed = executed.filter(r => !r.passed); + const durationMs = testResults.reduce((sum, r) => sum + r.durationMs, 0); + + const report = { + environment: environmentName, + summary: { + total: testResults.length, + passed: passed.length, + failed: failed.length, + skipped: skipped.length, + durationMs, + }, + tests: testResults.map(r => ({ + file: r.file, + name: r.testName, + status: statusOf(r).toLowerCase(), + durationMs: r.durationMs, + error: r.error ? r.error.message : null, + skipReason: r.skipReason ?? null, + plugin: r.plugin ?? null, + botUsername: r.botUsername ?? null, + // Present when this row aggregates a `concurrency > 1` test/block: every instance's + // own outcome, so a failure names which bot lost the race instead of just that one did. + instances: r.instances + ? r.instances.map(i => ({ + index: i.index, + botUsername: i.botUsername ?? null, + passed: i.passed, + durationMs: i.durationMs, + error: i.error ? i.error.message : null, + })) + : null, + })), + }; + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(report, null, 2), 'utf8'); +} + +/** Writes a JUnit XML report: `testsuite name="plugwright."`, one `testcase` per test, + * spec file as `classname`, full `describe`-chain name as `name`. */ +export function writeJUnitReport(path: string, environmentName: string, testResults: TestResult[]): void { + const skipped = testResults.filter(r => r.skipped).length; + const failed = testResults.filter(r => !r.skipped && !r.passed).length; + const totalTimeSeconds = (testResults.reduce((sum, r) => sum + r.durationMs, 0) / 1000).toFixed(3); + + const cases = testResults.map(r => { + const timeSeconds = (r.durationMs / 1000).toFixed(3); + const classname = xmlEscape(r.file); + const name = xmlEscape(r.testName); + const pluginAttr = r.plugin ? ` plugin="${xmlEscape(r.plugin)}"` : ''; + const inner = r.skipped + ? `\n \n ` + : !r.passed + ? `\n ${xmlEscape(r.error?.stack ?? r.error?.message ?? '')}\n ` + : ''; + return ` ${inner}`; + }); + + const xml = [ + '', + ``, + ...cases, + '', + '', + ].join('\n'); + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, xml, 'utf8'); } \ No newline at end of file diff --git a/runner-package/lib/server.ts b/runner-package/lib/server.ts index 2a327a3..4a75fb5 100644 --- a/runner-package/lib/server.ts +++ b/runner-package/lib/server.ts @@ -1,7 +1,31 @@ +import type { Session } from './session.js'; + export class ServerWrapper { - execute: (cmd: string) => void; + readonly session: Session; + /** Default read cursor for `toHaveReceivedMessage` when no `since` is given — the log index + * at construction time, so a fresh test only sees lines from its own start. Non-destructive + * replacement for the old `session.consoleLog.clear()`: the log itself is never wiped, so + * concurrent tests reading it don't race. */ + startIndex: number; + + constructor(session: Session) { + this.session = session; + this.startIndex = session.consoleLog.length; + } + + /** Moves the default read cursor to "now". Used by a `describe.serial` block between its + * tests, which share one `ServerWrapper` — the block-level equivalent of a fresh one. */ + resetCursor(): void { + this.startIndex = this.session.consoleLog.length; + } - constructor(executeFn: (cmd: string) => void) { - this.execute = executeFn; + /** Executes a console command and resolves with the server's response. Note: when running under `concurrency: N`, + * console output is shared across all concurrent tests. Prefer player actions or qualify + * commands with `player.username`. */ + execute(cmd: string, timeoutMs?: number): Promise { + if (!this.session.console) { + throw new Error('No server console available for this environment'); + } + return this.session.console.execute(cmd, timeoutMs); } -} \ No newline at end of file +} diff --git a/runner-package/lib/session.ts b/runner-package/lib/session.ts new file mode 100644 index 0000000..91679f6 --- /dev/null +++ b/runner-package/lib/session.ts @@ -0,0 +1,216 @@ +import mineflayer, { Bot } from 'mineflayer'; +import pc from 'picocolors'; +import type { Environment, BotConnectionOptions } from './environment.js'; +import type { ServerConsole } from './console.js'; +import type { PlayerWrapper } from './player.js'; +import type { Account } from './account.js'; +import { microsoftAuthWithCache } from './microsoft-auth.js'; + +/** + * Append-only line buffer. Replaces the old module-level `string[]` singletons + * (`messageBuffer`, `serverConsoleBuffer`) that a session's buffers used to be. + */ +export class MessageBuffer { + private lines: string[] = []; + + push(line: string): void { + this.lines.push(line); + } + + get length(): number { + return this.lines.length; + } + + clear(): void { + this.lines.length = 0; + } + + slice(start?: number, end?: number): string[] { + return this.lines.slice(start, end); + } + + find(predicate: (line: string) => boolean): string | undefined { + return this.lines.find(predicate); + } + + some(predicate: (line: string) => boolean): boolean { + return this.lines.some(predicate); + } +} + +/** + * Everything scoped to one test run against one environment: active bots, the + * message/console-log buffers matchers poll, and the console channel. Replaces + * the module-level singletons that made it impossible to run two environments + * in one process. + * + * `testRegistry`/`scopeStack` (test-registry.ts) stay module-level with a + * per-file reset — correct only as long as one process runs one environment + * and files run sequentially. Don't reach for this class to parallelize spec + * files without revisiting that too. + */ +export class Session { + readonly env: Environment; + console: ServerConsole | null = null; + readonly bots: Bot[] = []; + readonly consoleLog = new MessageBuffer(); + + /** Set once by the runner after loading plugins. Fired by `PlayerWrapper.join()` on + * every connection (initial join and every `rejoin()`), not called directly by + * `Session` itself. */ + onPlayerCreate: ((player: PlayerWrapper, ctx: { account: Account; env: Environment }) => Promise | void) | null = null; + + constructor(env: Environment) { + this.env = env; + } + + /** Pulls the console channel from the environment. Called once `env.setup()` has produced one. */ + refreshConsole(): void { + this.console = this.env.console(); + } + + createBot(options: BotConnectionOptions & { username: string }): Bot { + let version = options.version; + if (version && version.startsWith('26.1.')) { + version = '26.1'; + } + + const bot = mineflayer.createBot({ + host: options.host, + port: options.port, + username: options.username, + version, + // A custom function here (instead of the 'microsoft' string) so profile/certificate + // fetches are cached across bots for the same account — see microsoft-auth.ts. + auth: options.auth === 'microsoft' ? microsoftAuthWithCache(options.username) : options.auth, + // mineflayer's own default (logErrors: true) does `bot.on('error', e => + // console.log(e))` unconditionally — fine for an occasional bad packet, but a + // server sending something outside the client's protocol data (e.g. a particle + // type minecraft-data doesn't recognise for this version) can emit that error + // hundreds of times a second. Full exceptions logged synchronously at that rate + // starve the event loop and the piped stdout, so timers that would otherwise + // fail the test fast stop firing in any useful time. Handled below instead, with + // logging throttled so the connection survives being spammed by a packet type it + // can't decode. + logErrors: false, + ...(options.profilesFolder ? { profilesFolder: options.profilesFolder } : {}), + }); + + this.bots.push(bot); + + let errorCount = 0; + let lastLoggedAt = 0; + bot.on('error', (err: Error) => { + errorCount++; + const now = Date.now(); + if (now - lastLoggedAt > 1000) { + console.log(`${pc.cyan(`[Bot ${options.username}]`)} ${pc.dim(`Error (${errorCount} so far): ${err.message}`)}`); + lastLoggedAt = now; + } + }); + + bot.once('end', (reason: string) => { + console.log(`${pc.cyan(`[Bot ${options.username}]`)} ${pc.dim(`Connection ended: ${reason}`)}`); + }); + + return bot; + } + + removeBot(bot: Bot): void { + const idx = this.bots.indexOf(bot); + if (idx !== -1) this.bots.splice(idx, 1); + } + + /** + * Disconnects a bot, waiting for the `end` event or a timeout. + * Skips the wait entirely if the client is already ended. + * + * Every exit path removes the bot's listeners: a disconnected client isn't reused, so + * nothing should still be reacting to its events (mineflayer keeps the client object + * alive briefly after `end`, and a stale listener firing during that window is how a + * message meant for a torn-down player used to reach the wrong place). + */ + disconnectBot(bot: Bot, label: string, timeoutMs: number = 3000): Promise { + const cleanupListeners = () => { + try { + bot.removeAllListeners(); + } catch (err) { + console.log(`${pc.cyan(`[Bot ${label}]`)} ${pc.dim(`Warning: failed to remove listeners: ${(err as Error).message}`)}`); + } + }; + + const isAlreadyEnded = !!(bot as any)._client?.ended; + if (isAlreadyEnded) { + cleanupListeners(); + return Promise.resolve(); + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + console.log(`${pc.cyan(`[Bot ${label}]`)} ${pc.dim('Disconnect timeout, continuing')}`); + cleanupListeners(); + resolve(); + }, timeoutMs); + + try { + bot.once('end', () => { + clearTimeout(timeout); + cleanupListeners(); + resolve(); + }); + bot.quit(); + } catch (err) { + console.log(`${pc.cyan(`[Bot ${label}]`)} ${pc.dim(`Error during disconnect: ${(err as Error).message}`)}`); + clearTimeout(timeout); + cleanupListeners(); + resolve(); + } + }); + } + + /** Disconnects every bot except those in `keep`. Called with no argument, this is a full + * teardown, which is what the end of a test does. + * + * Each bot goes through `disconnectBot`, which is also what strips its listeners: a kept + * bot is still connected and still listening, so tearing the others down must not be a + * second implementation that forgets to. + * + * Snapshots which bots to disconnect before the `await`, then removes exactly those from + * `this.bots` afterward — not "whatever isn't in `keep`" recomputed after the fact. A + * concurrent caller can push a new bot onto `this.bots` while this call is awaiting; that + * bot is in neither snapshot, so it survives here untouched instead of being silently + * dropped from tracking without ever being disconnected. */ + async disconnectAllBots(keep: Bot[] = []): Promise { + const keepSet = new Set(keep); + const toDisconnect = this.bots.filter(b => !keepSet.has(b)); + + await Promise.all( + toDisconnect.map((b, i) => this.disconnectBot(b, b.username ?? `bot-${i}`, 2000)) + ); + + const disconnectedSet = new Set(toDisconnect); + for (let i = this.bots.length - 1; i >= 0; i--) { + if (disconnectedSet.has(this.bots[i])) this.bots.splice(i, 1); + } + } + + private _remainder = ''; + + /** Feeds raw environment output (e.g. Minecraft server stdout/stderr) into the console log buffer. */ + writeConsoleOutput(data: Buffer): void { + const text = (this._remainder + data.toString()).replace(/\r\n/g, '\n'); + const lines = text.split('\n'); + this._remainder = lines.pop() || ''; + for (const line of lines) { + if (line.length > 0) { + this.consoleLog.push(line); + } + } + const prefixed = lines + .map(line => line.length > 0 ? `${pc.gray('[MC]')} ${line}\n` : '\n') + .join(''); + if (prefixed.length > 0) { + process.stdout.write(prefixed); + } + } +} diff --git a/runner-package/lib/skip-reason.ts b/runner-package/lib/skip-reason.ts new file mode 100644 index 0000000..35f90bf --- /dev/null +++ b/runner-package/lib/skip-reason.ts @@ -0,0 +1,54 @@ +import type { Environment } from './environment.js'; +import type { RequiresMap } from './test-registry.js'; + +/** Capability keys from a `requires` map that `env` does not actually satisfy. A value of + * `false`, `'none'`, or an absent key all count as unmet. + * + * `{ consoleOutput: 'full' }` demands one specific value instead — for a test that + * reads the server log, which a console answering only its own commands cannot provide even + * though it satisfies plain `console: true`. */ +export function missingCapabilities(env: Environment, required: RequiresMap): string[] { + if (Array.isArray(required)) { + throw new Error('Test "requires" must be an object map (e.g. { requires: { console: true } }), not an array.'); + } + const capabilities = (env?.capabilities ?? {}) as unknown as Record; + const missing: string[] = []; + for (const [key, expectedValue] of Object.entries(required ?? {})) { + if (expectedValue === undefined) continue; + const actualValue = capabilities[key]; + + if (expectedValue === true) { + if (actualValue === false || actualValue === 'none' || actualValue == null) { + missing.push(key); + } + } else if (expectedValue === false) { + if (actualValue !== false && actualValue !== 'none' && actualValue != null) { + missing.push(`!${key}`); + } + } else { + if (String(actualValue) !== String(expectedValue)) { + missing.push(`${key}:${expectedValue}`); + } + } + } + return missing; +} + +/** The two `TestOptions` fields a test itself declares — `environments` and `requires` — + * checked against the running environment. Name filters (`tests.names`/`exclude`) stay local + * to `runFile`: they're a run-level concern, not part of what a test declares. */ +export function skipReasonForOptions( + env: Environment, + environmentName: string, + requires: RequiresMap, + environments: string[] | null, +): string | null { + if (environments && !environments.includes(environmentName)) { + return `requires environment in [${environments.join(', ')}], running "${environmentName}"`; + } + const missing = missingCapabilities(env, requires); + if (missing.length > 0) { + return `requires capability [${missing.join(', ')}], unavailable on "${environmentName}"`; + } + return null; +} diff --git a/runner-package/lib/test-registry.ts b/runner-package/lib/test-registry.ts index ac9a259..e124c14 100644 --- a/runner-package/lib/test-registry.ts +++ b/runner-package/lib/test-registry.ts @@ -1,6 +1,29 @@ import type { TestContext } from './types.js'; -type Hook = (context: TestContext) => Promise; +export type Hook = (context: TestContext) => Promise | void; +type TestFn = (context: TestContext) => Promise; + +import type { EnvironmentCapabilities } from './environment.js'; + +/** + * Filters usable from a spec file, independent of environment names. + * + * `requires` checks capability flags on `env.capabilities` (e.g. `console: true`, `op: true`) — + * a value of `false` or `'none'` fails the check. `environments` checks the running + * environment's name directly, for cases that aren't about capability but about the + * content of a specific stand. + */ +export type RequiresMap = Partial; + +export interface TestOptions { + requires?: RequiresMap; + environments?: string[]; + /** Runs this many independent instances of the test concurrently, each with its own bot + * leased from the account pool, to exercise races between players hitting the same feature + * at once. One failing instance fails the whole test. Defaults to 1 (sequential, no pool + * requirement). Validated against the pool's capacity before any test runs. */ + concurrency?: number; +} interface DescribeScope { label: string; @@ -8,52 +31,113 @@ interface DescribeScope { afterHooks: Hook[]; } -interface TestCase { +export interface TestCase { + name: string; + fn: TestFn; + /** Spec-level `beforeEach` hooks in run order (outermost `describe` first). */ + beforeHooks: Hook[]; + /** Spec-level `afterEach` hooks in run order (innermost `describe` first) — already + * reversed at registration time, see `registerTest`. */ + afterHooks: Hook[]; + requires: RequiresMap; + environments: string[] | null; + concurrency: number; +} + +/** What a `describe.serial` block accepts beyond the usual filters. */ +export interface SerialOptions extends TestOptions { + /** Run the whole block on this pool account instead of whichever one is free. For a stand + * where one specific account is the one carrying the state a test needs — a permission + * group, a starting balance. Fails the block on an environment with no account pool. */ + account?: string; +} + +/** A `describe.serial` block: its tests run in declaration order, on one player, and the block + * is what the runner schedules and filters — not the tests inside it. */ +export interface SerialBlock { name: string; - fn: (context: TestContext) => Promise; + account: string | null; + tests: TestCase[]; + requires: RequiresMap; + environments: string[] | null; + concurrency: number; } -export const testRegistry: TestCase[] = []; +export type RegistryItem = + | { kind: 'test'; testCase: TestCase } + | { kind: 'serial'; block: SerialBlock }; + +export const testRegistry: RegistryItem[] = []; export const scopeStack: DescribeScope[] = [{ label: '', beforeHooks: [], afterHooks: [] }]; -export function test(name: string, fn: (context: TestContext) => Promise): void { +/** The `describe.serial` block being registered, if any. Tests declared while it is set go + * into it instead of straight into `testRegistry`. */ +let currentBlock: SerialBlock | null = null; + +/** Discards whatever a previously-imported spec file registered, ready for the next one. + * `testRegistry`/`scopeStack` stay module-level with this per-file reset — correct only + * as long as one process runs one environment and files run sequentially. */ +export function resetRegistry(): void { + testRegistry.length = 0; + scopeStack.length = 0; + scopeStack.push({ label: '', beforeHooks: [], afterHooks: [] }); + currentBlock = null; +} + +/** Everything a registered test needs from the current `describe` scope. */ +function scopedEntry(name: string, options: TestOptions) { const labels = scopeStack.map(s => s.label).filter(l => l); - const fullName = [...labels, name].join(' > '); - - const beforeHooks = scopeStack.flatMap(s => s.beforeHooks); - const afterHooks = [...scopeStack].reverse().flatMap(s => s.afterHooks); - - const wrappedFn = async (ctx: TestContext) => { - let testError: unknown; - try { - for (const hook of beforeHooks) await hook(ctx); - await fn(ctx); - } catch (e) { - testError = e; - } finally { - for (const hook of afterHooks) { - try { - await hook(ctx); - } catch (e) { - testError ??= e; - console.error('[afterEach] Hook error:', (e as Error).message); - } - } - } - if (testError) throw testError; + return { + name: [...labels, name].join(' > '), + beforeHooks: scopeStack.flatMap(s => s.beforeHooks), + afterHooks: [...scopeStack].reverse().flatMap(s => s.afterHooks), + requires: options.requires ?? {}, + environments: options.environments ?? null, + concurrency: normalizeConcurrency(options.concurrency), }; +} - testRegistry.push({ name: fullName, fn: wrappedFn }); +/** `concurrency` must be a whole number of at least 1 — anything else can't be turned into a + * bot count. Checked at registration time so a typo fails on import, not mid-run. */ +function normalizeConcurrency(concurrency: number | undefined): number { + if (concurrency === undefined) return 1; + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error(`concurrency must be a whole number >= 1, got ${concurrency}`); + } + return concurrency; } -export function opTest(name: string, fn: (context: TestContext) => Promise): void { - test(name, async (context: TestContext) => { - await context.player.makeOp(); - await fn(context); - }); +function registerTest(name: string, options: TestOptions, fn: TestFn): void { + const testCase = { ...scopedEntry(name, options), fn }; + if (currentBlock) { + // A serial block always runs its tests one after another on the same player — fanning + // one of them out into concurrent instances makes no sense and would otherwise be + // silently ignored (the block runner never reads a per-test concurrency), leaving + // whoever set it wondering why nothing ran concurrently. + if (testCase.concurrency > 1) { + throw new Error( + `test "${name}": concurrency is not supported on tests inside describe.serial ` + + `(block "${currentBlock.name}") — set concurrency on the describe.serial block itself instead.` + ); + } + currentBlock.tests.push(testCase); + } else { + testRegistry.push({ kind: 'test', testCase }); + } } -export function describe(label: string, fn: () => void): void { +export function test(name: string, fn: TestFn): void; +export function test(name: string, options: TestOptions, fn: TestFn): void; +export function test(name: string, fnOrOptions: TestFn | TestOptions, maybeFn?: TestFn): void { + if (typeof fnOrOptions === 'function') { + registerTest(name, {}, fnOrOptions); + } else { + registerTest(name, fnOrOptions, maybeFn!); + } +} + + +function describeImpl(label: string, fn: () => void): void { scopeStack.push({ label, beforeHooks: [], afterHooks: [] }); try { fn(); @@ -62,10 +146,63 @@ export function describe(label: string, fn: () => void): void { } } +/** + * Registers a block whose tests run in the order they are declared, against one player that + * stays connected for the whole block — the shape a scenario needs when one step only means + * something after the one before it ("claim a kit, see it on cooldown, see the cooldown + * expire"). Everything outside such a block is still an independent test with its own bot. + * + * The block, not the test, is what filters apply to: `requires`, `environments` and the run's + * name filters are checked against every test in it, and one exclusion skips the whole block + * rather than leaving a broken chain behind. A failing test skips the rest of its block for the + * same reason — the tests after it were written to run on what it was supposed to leave. + * + * Plugin `beforeEach`/`afterEach` run once around the block, not around each test in it: a + * plugin that resets an account between tests would undo exactly what the block is built on. + * `beforeEach`/`afterEach` declared in the spec still run for every test. + */ +function serialImpl(label: string, optionsOrFn: SerialOptions | (() => void), maybeFn?: () => void): void { + const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn; + const fn = typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn!; + + if (currentBlock) { + throw new Error(`describe.serial: "${label}" is nested inside serial block "${currentBlock.name}" — a block cannot contain another`); + } + + const labels = scopeStack.map(s => s.label).filter(l => l); + const block: SerialBlock = { + name: [...labels, label].join(' > '), + account: options.account ?? null, + tests: [], + requires: options.requires ?? {}, + environments: options.environments ?? null, + concurrency: normalizeConcurrency(options.concurrency), + }; + + currentBlock = block; + scopeStack.push({ label, beforeHooks: [], afterHooks: [] }); + try { + fn(); + } finally { + scopeStack.pop(); + currentBlock = null; + } + + testRegistry.push({ kind: 'serial', block }); +} + +interface DescribeApi { + (label: string, fn: () => void): void; + serial(label: string, fn: () => void): void; + serial(label: string, options: SerialOptions, fn: () => void): void; +} + +export const describe: DescribeApi = Object.assign(describeImpl, { serial: serialImpl }); + export function beforeEach(hook: Hook): void { scopeStack[scopeStack.length - 1].beforeHooks.push(hook); } export function afterEach(hook: Hook): void { scopeStack[scopeStack.length - 1].afterHooks.push(hook); -} \ No newline at end of file +} diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts new file mode 100644 index 0000000..050d750 --- /dev/null +++ b/runner-package/lib/test-runner.ts @@ -0,0 +1,449 @@ +import pc from 'picocolors'; +import type { Bot } from 'mineflayer'; +import { PlayerWrapper } from './player.js'; +import { ServerWrapper } from './server.js'; +import { formatDuration } from './reporter.js'; +import { randomSuffix, syntheticAccount } from './account.js'; +import type { Account, AccountPool } from './account.js'; +import type { Session } from './session.js'; +import type { PluginHost } from './plugin-host.js'; +import type { BotConnectionOptions } from './environment.js'; +import type { SerialBlock, TestCase } from './test-registry.js'; +import type { TestContext, TestResult } from './types.js'; + +/** Which of a `concurrency > 1` run's N instances this is, for console log labeling — without + * it, several instances logging the same test name at the same time is unreadable. */ +export interface InstanceTag { + index: number; + total: number; +} + +function formatInstanceTag(instance?: InstanceTag): string { + return instance ? pc.dim(` [${instance.index}/${instance.total}]`) : ''; +} + +export interface RunTestCaseParams { + file: string; + testCase: TestCase; + session: Session; + plugins: PluginHost; + connOpts: BotConnectionOptions; + timeoutMs: number; + /** Set when this test came from a plugin's inherited `tests`, for report labeling. */ + pluginName?: string | null; + /** Set by `runConcurrentTestCase` on each fanned-out instance, for console log labeling. */ + instance?: InstanceTag; +} + +export interface RunSerialBlockParams { + file: string; + block: SerialBlock; + session: Session; + plugins: PluginHost; + connOpts: BotConnectionOptions; + timeoutMs: number; + pluginName?: string | null; + /** Set by `runConcurrentSerialBlock` on each fanned-out instance, for console log labeling. */ + instance?: InstanceTag; +} + +/** Bots created while one test, or one `describe.serial` block, is running: who leased what, + * which player answers to which `as` name, and how to give it all back. */ +interface BotScope { + connect(options?: { username?: string; account?: string; password?: string }): Promise; + /** `ctx.createPlayer`. `as` names the player so a later call — a later test, inside a block — + * gets the same bot back instead of connecting a second one. `password` belongs with + * `username`: a named bot is not a pool account, so the test is the only thing that can + * say how it logs in. */ + createPlayer(options?: { username?: string; as?: string; password?: string }): Promise; + /** Every player connected in this scope, in the order they joined. */ + players(): PlayerWrapper[]; + /** Disconnects every bot in the scope and returns the accounts they held. */ + close(): Promise; +} + +function createBotScope(session: Session, server: ServerWrapper, connOpts: BotConnectionOptions, instance?: InstanceTag): BotScope { + const leased: Array<{ account: Account; pool: AccountPool }> = []; + const named = new Map(); + const connected: PlayerWrapper[] = []; + // Every bot this scope created, whether or not `player.join()` went on to succeed — unlike + // `connected`, which only gains an entry after a successful join. `close()` needs this one: + // a bot whose join failed still opened a real connection and must still be torn down. + const ownBots: Bot[] = []; + + const connect = async (options?: { username?: string; account?: string; password?: string }): Promise => { + const pool = options?.username ? null : session.env.accounts?.() ?? null; + if (options?.account && !pool) { + throw new Error( + `account "${options.account}" was requested, but environment "${session.env.id}" has no accounts pool ` + + 'to take it from — a named account needs one the build script declares.' + ); + } + // A pooled account brings its own password, so a password with no username would be + // read by nothing. Say so rather than connect as somebody else's account and ignore it. + if (options?.password && pool) { + throw new Error( + `a password was passed without a username, but environment "${session.env.id}" leases its ` + + 'accounts from a pool and those carry their own. Name the bot too, or drop the password.' + ); + } + const account: Account = pool + ? await pool.lease(options?.account) + : syntheticAccount(options?.username || `pw_${randomSuffix()}`, options?.password); + + try { + const botUsername = account.username; + console.log(`${pc.cyan(`[Bot ${botUsername}]`)} Creating bot...${formatInstanceTag(instance)}`); + + await session.env.beforeJoin?.(); + + const botOptions: BotConnectionOptions = { + ...connOpts, + auth: account.auth, + profilesFolder: account.microsoftCacheDir, + }; + const bot = session.createBot({ ...botOptions, username: botUsername }); + ownBots.push(bot); + const player = new PlayerWrapper(bot, session); + player._captureSpawnPromise(); + player.setServerWrapper(server); + player._setBotOptions(botOptions); + player._setAccount(account); + + await player.join(); + if (pool) leased.push({ account, pool }); + connected.push(player); + return player; + } catch (error) { + if (pool) pool.release(account); + throw error; + } + }; + + return { + connect, + async createPlayer(options): Promise { + const handle = options?.as; + if (handle) { + const existing = named.get(handle); + if (existing) return existing; + } + const player = await connect({ username: options?.username, password: options?.password }); + if (handle) named.set(handle, player); + return player; + }, + players: () => [...connected], + async close(): Promise { + // Only this scope's own bots — a concurrent sibling instance's bots are still + // running and must not be torn down by this one finishing first. Union of two + // sources: `ownBots` catches a bot whose join() failed and never made it into + // `connected`; reading `p.bot` fresh off `connected` catches the opposite case — + // `player.rejoin()` swaps a player's `.bot` for a new connection under the same + // scope, and that swapped-in bot isn't the one `ownBots` captured at connect time. + const ownBotsSet = new Set([...ownBots, ...connected.map(p => p.bot)]); + await session.disconnectAllBots(session.bots.filter(b => !ownBotsSet.has(b))); + for (const { account, pool } of leased) pool.release(account); + leased.length = 0; + named.clear(); + connected.length = 0; + ownBots.length = 0; + }, + }; +} + +interface ExecuteParams { + testCase: TestCase; + ctx: TestContext; + finalizers: Array<() => void | Promise>; + abort: AbortController; + plugins: PluginHost; + timeoutMs: number; + /** Plugin `beforeEach`/`afterEach` wrap a whole `describe.serial` block rather than each of + * its tests, so a block runs them around its first and last test only. */ + pluginBeforeEach: boolean; + pluginAfterEach: boolean; +} + +/** + * Runs one test body with its hooks, and throws whatever failed it. Order is plugin beforeEach → + * spec beforeEach → body → cleanup finalizers → spec afterEach → plugin afterEach. Finalizer + * errors are logged but never flip the result; spec afterEach errors do, matching the runner's + * pre-plugin-host behavior. + */ +async function executeTest(params: ExecuteParams): Promise { + const { testCase, ctx, finalizers, abort, plugins, timeoutMs, pluginBeforeEach, pluginAfterEach } = params; + + let timeoutHandle: ReturnType; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + abort.abort(); + reject(new Error(`Test timed out after ${timeoutMs}ms. You can increase this by setting the TEST_TIMEOUT environment variable.`)); + }, timeoutMs); + }); + + const body = async (): Promise => { + if (pluginBeforeEach) await plugins.beforeEach(ctx); + for (const hook of testCase.beforeHooks) await hook(ctx); + + let testError: unknown; + try { + await testCase.fn(ctx); + } catch (e) { + testError = e; + } finally { + for (const finalizer of [...finalizers].reverse()) { + try { + await finalizer(); + } catch (e) { + console.error(pc.red(`[cleanup] finalizer error: ${(e as Error).message}`)); + } + } + for (const hook of testCase.afterHooks) { + try { + await hook(ctx); + } catch (e) { + testError ??= e; + console.error(pc.red(`[afterEach] Hook error: ${(e as Error).message}`)); + } + } + if (pluginAfterEach) await plugins.afterEach(ctx); + } + if (testError) throw testError; + }; + + await Promise.race([body().finally(() => clearTimeout(timeoutHandle)), timeoutPromise]); +} + +function reportPassed(durationMs: number, instance?: InstanceTag): void { + console.log(` ${pc.green(pc.bold('PASSED'))}${formatInstanceTag(instance)} ${pc.dim(`(${formatDuration(durationMs)})`)}\n`); +} + +function reportFailed(durationMs: number, error: Error, instance?: InstanceTag): void { + console.log(` ${pc.red(pc.bold('FAILED'))}${formatInstanceTag(instance)} ${pc.dim(`(${formatDuration(durationMs)})`)}: ${pc.red(error.message)}\n`); +} + +/** + * Runs one standalone test case end to end: connects its own bot, builds `TestContext`, runs the + * body, and disconnects everything it created on the way out. + */ +export async function runTestCase(params: RunTestCaseParams): Promise { + const { file, testCase, session, plugins, connOpts, timeoutMs, pluginName = null, instance } = params; + + console.log(` ${pc.bold(`Test: ${testCase.name}`)}${formatInstanceTag(instance)}`); + + const server = new ServerWrapper(session); + const bots = createBotScope(session, server, connOpts, instance); + const finalizers: Array<() => void | Promise> = []; + const startedAt = Date.now(); + + let player: PlayerWrapper; + try { + player = await bots.connect(); + } catch (error) { + const durationMs = Date.now() - startedAt; + reportFailed(durationMs, error as Error, instance); + await bots.close(); + return { file, testName: testCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName }; + } + + const abort = new AbortController(); + const ctx: TestContext = { + player, + server, + env: session.env, + createPlayer: options => bots.createPlayer(options), + invalidatePlayer: () => { /* nothing follows this test — see the serial-block runner */ }, + signal: abort.signal, + cleanup: (fn: () => void | Promise) => { finalizers.push(fn); }, + }; + plugins.extendContext(ctx); + + try { + await executeTest({ + testCase, ctx, finalizers, abort, plugins, timeoutMs, + pluginBeforeEach: true, pluginAfterEach: true, + }); + const durationMs = Date.now() - startedAt; + reportPassed(durationMs, instance); + return { file, testName: testCase.name, passed: true, durationMs, plugin: pluginName, botUsername: player.username }; + } catch (error) { + const durationMs = Date.now() - startedAt; + reportFailed(durationMs, error as Error, instance); + return { file, testName: testCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName, botUsername: player.username }; + } finally { + await bots.close(); + } +} + +/** + * Runs `concurrency` independent instances of a test at once, each with its own bot, for the + * race conditions a single bot can never trigger. One failing instance fails the whole result; + * the aggregate `TestResult` carries every instance's own outcome in `instances`. + */ +export async function runConcurrentTestCase(params: RunTestCaseParams & { concurrency: number }): Promise { + const { concurrency, ...rest } = params; + if (concurrency <= 1) return runTestCase(rest); + + const instanceResults = await Promise.all( + Array.from({ length: concurrency }, (_, i) => runTestCase({ ...rest, instance: { index: i + 1, total: concurrency } })) + ); + return aggregateInstances(instanceResults); +} + +/** + * Runs a `describe.serial` block: one player, one connection, its tests in declaration order. + * + * The block stops at the first test that fails, times out, or calls `invalidatePlayer` — every + * test after it is reported skipped rather than failed, because what they were written against + * is a state the block never reached. Plugin `beforeEach`/`afterEach` wrap the block, not each + * test: a plugin that resets an account between tests would undo what the block is built on. + */ +export async function runSerialBlock(params: RunSerialBlockParams): Promise { + const { file, block, session, plugins, connOpts, timeoutMs, pluginName = null, instance } = params; + + console.log(` ${pc.bold(`Serial block: ${block.name}`)}${block.account ? pc.dim(` (account ${block.account})`) : ''}${formatInstanceTag(instance)}`); + + const server = new ServerWrapper(session); + const bots = createBotScope(session, server, connOpts, instance); + const results: TestResult[] = []; + + let player: PlayerWrapper; + try { + player = await bots.connect({ account: block.account ?? undefined }); + } catch (error) { + // Nothing in the block ever ran: the first test carries the failure, the rest are + // skipped the same way they would be after a failure further in. + reportFailed(0, error as Error, instance); + await bots.close(); + return block.tests.map((testCase, index) => index === 0 + ? { file, testName: testCase.name, passed: false, durationMs: 0, error: error as Error, plugin: pluginName } + : { + file, testName: testCase.name, passed: true, durationMs: 0, skipped: true, + skipReason: `serial block "${block.name}" never got its player: ${(error as Error).message}`, + plugin: pluginName, + }); + } + + let stopReason: string | null = null; + // The context object is rebuilt per test — `cleanup` and `signal` are per-test — but every + // one of them carries the same player and the same bot scope. + let lastCtx: TestContext | null = null; + + try { + for (const [index, testCase] of block.tests.entries()) { + if (stopReason) { + console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (${stopReason})`) + formatInstanceTag(instance)); + results.push({ + file, testName: testCase.name, passed: true, durationMs: 0, skipped: true, + skipReason: stopReason, plugin: pluginName, + }); + continue; + } + + console.log(` ${pc.bold(`Test: ${testCase.name}`)}${formatInstanceTag(instance)}`); + // What the block shares is server state, not chat history: a message from the step + // before would otherwise satisfy an assertion about this one. + server.resetCursor(); + for (const p of bots.players()) p.clearMessages(); + + const finalizers: Array<() => void | Promise> = []; + const abort = new AbortController(); + let invalidatedBy: string | null = null; + + const ctx: TestContext = { + player, + server, + env: session.env, + createPlayer: options => bots.createPlayer(options), + invalidatePlayer: (p, reason) => { + if (p === player) invalidatedBy = reason ?? `invalidated by "${testCase.name}"`; + }, + signal: abort.signal, + cleanup: (fn: () => void | Promise) => { finalizers.push(fn); }, + }; + plugins.extendContext(ctx); + lastCtx = ctx; + + const startedAt = Date.now(); + try { + await executeTest({ + testCase, ctx, finalizers, abort, plugins, timeoutMs, + pluginBeforeEach: index === 0, + // Once around the block: see the `finally` below, which runs it whether the + // block finished its tests or stopped partway. + pluginAfterEach: false, + }); + const durationMs = Date.now() - startedAt; + reportPassed(durationMs, instance); + results.push({ file, testName: testCase.name, passed: true, durationMs, plugin: pluginName, botUsername: player.username }); + } catch (error) { + const durationMs = Date.now() - startedAt; + reportFailed(durationMs, error as Error, instance); + results.push({ file, testName: testCase.name, passed: false, durationMs, error: error as Error, plugin: pluginName, botUsername: player.username }); + stopReason = `serial block "${block.name}" stopped at "${testCase.name}"`; + continue; + } + + const dead = !!(player.bot as any)._client?.ended; + if (invalidatedBy) { + stopReason = `serial block "${block.name}" stopped: ${invalidatedBy}`; + } else if (dead) { + stopReason = `serial block "${block.name}" stopped: ${player.username} lost its connection`; + } + } + } finally { + if (lastCtx) await plugins.afterEach(lastCtx); + await bots.close(); + } + + return results; +} + +/** + * Runs `concurrency` independent instances of a `describe.serial` block at once, each with its + * own player. Each block instance still runs its own tests in order and stops on the same + * failure/timeout/`invalidatePlayer` rules as a solo block; the N instances' results are then + * aggregated per test position, so the report still has one row per test in the block. + */ +export async function runConcurrentSerialBlock(params: RunSerialBlockParams & { concurrency: number }): Promise { + const { concurrency, ...rest } = params; + if (concurrency <= 1) return runSerialBlock(rest); + + const instanceRuns = await Promise.all( + Array.from({ length: concurrency }, (_, i) => runSerialBlock({ ...rest, instance: { index: i + 1, total: concurrency } })) + ); + return instanceRuns[0].map((_, index) => aggregateInstances(instanceRuns.map(run => run[index]))); +} + +/** + * Rolls up N concurrent runs of the same test/test-position into one `TestResult`. `durationMs` + * is the slowest instance (roughly the wall-clock cost of the `Promise.all`). + * + * Only meaningful for a serial block: its instances can diverge mid-block (one instance's race + * loses and it stops early, skipping the rest, while another keeps going) so a position isn't + * uniformly pass/fail/skip the way a plain concurrent test's instances are. An actual failure in + * any instance fails the whole result; short of that, a position only counts as skipped if every + * instance skipped it — one instance actually exercising it is enough to call it run. + */ +function aggregateInstances(results: TestResult[]): TestResult { + const first = results[0]; + const failed = results.find(r => !r.skipped && !r.passed); + const allSkipped = results.every(r => r.skipped); + return { + file: first.file, + testName: first.testName, + plugin: first.plugin, + passed: !failed, + durationMs: Math.max(...results.map(r => r.durationMs)), + error: failed?.error, + skipped: !failed && allSkipped, + skipReason: !failed && allSkipped ? first.skipReason : undefined, + instances: results.map((r, i) => ({ + index: i + 1, + botUsername: r.botUsername, + passed: r.passed, + durationMs: r.durationMs, + error: r.error, + })), + }; +} diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 2c70f5b..1eec0c1 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -1,11 +1,42 @@ import type { PlayerWrapper } from './player.js'; import type { ServerWrapper } from './server.js'; +import type { Environment } from './environment.js'; export interface TestContext { player: PlayerWrapper; server: ServerWrapper; - createPlayer: (options?: { username?: string }) => Promise; + /** The environment this test is running against. Use `env.id` to check whether + * you're on `'local'` or `'external'`, and `env.capabilities` to inspect what + * the environment supports. */ + env: Environment; + /** Connects an extra bot. Inside a `describe.serial` block, `as` names it: the same name in + * a later test of that block returns the same bot instead of connecting another. Outside a + * block the name is scoped to the one test, which is as long as the bot lives anyway. + * + * `username` asks for one specific identity instead of whatever the pool has free, and + * `password` is what an authentication plugin logs that identity in with. Read it from the + * environment rather than writing it in the spec — spec files go to git. */ + createPlayer: (options?: { username?: string; as?: string; password?: string }) => Promise; + /** Says the player is in a state the tests after this one were not written for. Inside a + * `describe.serial` block that stops the block: the rest is reported skipped. Outside one + * it does nothing — the bot is disconnected at the end of the test either way. */ + invalidatePlayer: (player: PlayerWrapper, reason?: string) => void; signal: AbortSignal; + /** Registers a LIFO finalizer that always runs after the test body, before afterEach. + * Errors are logged but never override the test result. */ + cleanup: (fn: () => void | Promise) => void; +} + +/** One concurrent instance's own outcome, rolled up into the `instances` array of the + * aggregate `TestResult` for a `concurrency > 1` test/block. */ +export interface TestInstanceResult { + /** 1-based position among the N concurrent instances — matches the `[i/N]` tag in the + * console log for this same run. */ + index: number; + botUsername?: string; + passed: boolean; + durationMs: number; + error?: Error; } export interface TestResult { @@ -14,4 +45,16 @@ export interface TestResult { passed: boolean; durationMs: number; error?: Error; -} \ No newline at end of file + /** Set when the test was never run — a filter excluded it rather than it failing. */ + skipped?: boolean; + /** Human-readable reason shown in reports; required whenever `skipped` is true. */ + skipReason?: string; + /** Name of the plugin this test was inherited from, or null for a user spec. */ + plugin?: string | null; + /** The bot that ran this test, when one connected. Absent for a skip, or a test that failed + * before it got as far as leasing a bot. */ + botUsername?: string; + /** Set when this result aggregates `concurrency > 1` concurrent instances: `passed` is AND + * across all of them, `durationMs` is the slowest, `error` is the first failure. */ + instances?: TestInstanceResult[]; +} diff --git a/runner-package/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/lib/wrappers.ts b/runner-package/lib/wrappers.ts index c33091a..270831c 100644 --- a/runner-package/lib/wrappers.ts +++ b/runner-package/lib/wrappers.ts @@ -37,23 +37,47 @@ export class GuiItemLocator { } /** - * Gets the lore text of the located item. + * Gets the display name of the located item. * Re-queries the GUI each time it's called. */ - loreText(): string { + displayName(): string { const item = this._tryFind(); if (!item) return ''; - return item.getLore().join(' '); + return item.displayName; } /** - * Gets the display name of the located item. + * Alias for `displayName()`. + */ + getDisplayName(): string { + return this.displayName(); + } + + /** + * Gets the lore lines of the located item. * Re-queries the GUI each time it's called. */ - displayName(): string { + lore(): string[] { + const item = this._tryFind(); + if (!item) return []; + return item.lore; + } + + /** + * Alias for `lore()`. + */ + getLore(): string[] { + return this.lore(); + } + + /** + * Gets the lore text of the located item (joined by space). + * Re-queries the GUI each time it's called. + */ + loreText(): string { const item = this._tryFind(); if (!item) return ''; - return item.getDisplayName(); + return item.lore.join(' '); } /** @@ -89,8 +113,8 @@ export class GuiItemLocator { const rows = items.map(item => ({ slot: item.slot, name: item.name, - displayName: item.getDisplayName(), - lore: item.getLore().join(' | ') + displayName: item.displayName, + lore: item.lore.join(' | ') })); if (rows.length === 0) { @@ -277,6 +301,14 @@ export class ItemWrapper { return String(raw); } + get displayName(): string { + return this.getDisplayName(); + } + + get lore(): string[] { + return this.getLore(); + } + getDisplayName(): string { const components = (this.raw as any).components; if (Array.isArray(components)) { @@ -378,8 +410,8 @@ export class GuiWrapper { throw new Error(`[GUI] Failed to click: Item not found matching criteria in "${this.title}"`); } - const lore = item.getLore(); - console.log(`[GUI] Clicking item: ${item.getDisplayName()}`); + const lore = item.lore; + console.log(`[GUI] Clicking item: ${item.displayName}`); console.log(` Material: ${item.name}`); console.log(` Slot: ${item.slot}`); if (lore.length > 0) { @@ -388,175 +420,10 @@ export class GuiWrapper { await this.bot.clickWindow(item.slot, 0, 0); } - - // ----------------------------------------------------------------------- - // Public deprecated methods — warn once then delegate to internal methods. - // ----------------------------------------------------------------------- - - /** - * @deprecated Use gui.locator() with expectations instead. This method will be removed in a future version. - * @internal This class is primarily for internal use. Use LiveGuiHandle and GuiItemLocator instead. - */ - hasItem(predicate: (item: ItemWrapper) => boolean): boolean { - console.warn('[DEPRECATED] GuiWrapper.hasItem() is deprecated. Use gui.locator() instead.'); - return this._hasItemInternal(predicate); - } - - /** - * @deprecated Use gui.locator() to get items. This method will be removed in a future version. - * @internal This class is primarily for internal use. Use LiveGuiHandle and GuiItemLocator instead. - */ - findItem(predicate: (item: ItemWrapper) => boolean): ItemWrapper | undefined { - console.warn('[DEPRECATED] GuiWrapper.findItem() is deprecated. Use gui.locator() instead.'); - return this._findItemInternal(predicate); - } - - /** - * @deprecated Use multiple gui.locator() calls if needed. This method will be removed in a future version. - * @internal This class is primarily for internal use. Use LiveGuiHandle and GuiItemLocator instead. - */ - findAllItems(predicate: (item: ItemWrapper) => boolean): ItemWrapper[] { - console.warn('[DEPRECATED] GuiWrapper.findAllItems() is deprecated. Use gui.locator() instead.'); - return this._findAllItemsInternal(predicate); - } - - /** - * @deprecated Use gui.locator().click() instead. This method will be removed in a future version. - * @internal This class is primarily for internal use. Use LiveGuiHandle and GuiItemLocator instead. - */ - async clickItem(predicate: (item: ItemWrapper) => boolean): Promise { - console.warn('[DEPRECATED] GuiWrapper.clickItem() is deprecated. Use gui.locator().click() instead.'); - return this._clickItemInternal(predicate); - } } export function createPlayerExtensions(bot: Bot) { return { - async waitForGuiItem( - itemMatcher: (item: ItemWrapper) => boolean, - options: { timeout?: number; pollingRate?: number } = {} - ): Promise { - console.warn('[DEPRECATED] player.waitForGuiItem() is deprecated. Use gui.locator() with expectations instead. See documentation for migration guide.'); - - const { timeout = 5000, pollingRate = 100 } = options; - const startTime = Date.now(); - - for (;;) { - if (bot.currentWindow) { - const window = bot.currentWindow as Window; - const items = window.slots - .filter((item): item is RawItem => item != null) - .map(item => new ItemWrapper(item)); - - const matchedItem = items.find(itemMatcher); - - if (matchedItem) { - console.log(`[Player] Found GUI item: ${matchedItem.getDisplayName()} at slot ${matchedItem.slot}`); - return matchedItem; - } - } - - if (Date.now() - startTime >= timeout) { - throw new Error(`[Player] Timeout waiting for GUI item (${timeout}ms)`); - } - - await new Promise(resolve => setTimeout(resolve, pollingRate)); - } - }, - - async clickGuiItem( - itemMatcher: (item: ItemWrapper) => boolean, - options: { timeout?: number; pollingRate?: number } = {} - ): Promise { - console.warn('[DEPRECATED] player.clickGuiItem() is deprecated. Use gui.locator().click() instead. See documentation for migration guide.'); - - const { timeout = 5000, pollingRate = 100 } = options; - const startTime = Date.now(); - - for (;;) { - if (bot.currentWindow) { - const window = bot.currentWindow as Window; - const items = window.slots - .filter((item): item is RawItem => item != null) - .map(item => new ItemWrapper(item)); - - const matchedItem = items.find(itemMatcher); - - if (matchedItem) { - const lore = matchedItem.getLore(); - console.log(`[Player] Clicking GUI item: ${matchedItem.getDisplayName()}`); - console.log(` Material: ${matchedItem.name}`); - console.log(` Slot: ${matchedItem.slot}`); - if (lore.length > 0) { - console.log(` Lore: ${lore.join(' | ')}`); - } - - await bot.clickWindow(matchedItem.slot, 0, 0); - return; - } - } - - if (Date.now() - startTime >= timeout) { - throw new Error(`[Player] Timeout waiting for GUI item to click (${timeout}ms)`); - } - - await new Promise(resolve => setTimeout(resolve, pollingRate)); - } - }, - - async waitForGui( - guiMatcher: (gui: GuiWrapper) => boolean, - options: { timeout?: number } = {} - ): Promise { - console.warn('[DEPRECATED] player.waitForGui() is deprecated. Use player.gui({ title }) instead. See documentation for migration guide.'); - - const { timeout = 5000 } = options; - - return new Promise((resolve, reject) => { - let settled = false; - - const tryMatch = (): GuiWrapper | null => { - if (!bot.currentWindow) return null; - const gui = new GuiWrapper(bot, bot.currentWindow as Window); - return guiMatcher(gui) ? gui : null; - }; - - const settle = (gui: GuiWrapper) => { - if (settled) return; - settled = true; - cleanup(); - console.log(`[Player] GUI matched: "${gui.title}"`); - resolve(gui); - }; - - const attempt = () => { - if (settled) return; - const matched = tryMatch(); - if (matched) settle(matched); - }; - - const deadline = setTimeout(() => { - if (settled) return; - settled = true; - cleanup(); - reject(new Error(`[Player] Timeout waiting for GUI matching predicate (${timeout}ms)`)); - }, timeout); - - const onWindowOpen = () => { - setImmediate(attempt); - }; - - const cleanup = () => { - clearTimeout(deadline); - bot.removeListener('windowOpen', onWindowOpen); - }; - - bot.on('windowOpen', onWindowOpen); - - setImmediate(attempt); - }); - }, - /** * Get a live handle to a GUI matching the title. * It waits ONLY until a GUI with matching title exists. diff --git a/runner-package/package-lock.json b/runner-package/package-lock.json index d95c278..fc1fcd2 100644 --- a/runner-package/package-lock.json +++ b/runner-package/package-lock.json @@ -1,19 +1,23 @@ { - "name": "@drownek/plugwright", - "version": "2.0.4", + "name": "@plugwright/runner", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@drownek/plugwright", - "version": "2.0.4", + "name": "@plugwright/runner", + "version": "3.0.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", - "mineflayer": "^4.38.0", + "mineflayer": "^4.39.0", "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" }, + "bin": { + "plugwright": "dist/cli.js" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.5", @@ -482,9 +486,9 @@ "license": "MIT" }, "node_modules/minecraft-data": { - "version": "3.114.0", - "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.114.0.tgz", - "integrity": "sha512-7V3rl5XQTMa8D8nsVl3L/UYBB+2Kn9eFR8d1MKyge6ygjJC94RExq3anHdiGnrQ65SqtmpEc999SEihSU0drIA==", + "version": "3.116.0", + "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.116.0.tgz", + "integrity": "sha512-2XwQjnAqCdCdhEts6cm1nnmzeeCCbsiAhhuOa6rQOTKceGkwZTE8ZJwO3UeJLwvjEAh8S0/irXN1Xqv/lhceNg==", "license": "MIT" }, "node_modules/minecraft-folder-path": { @@ -524,12 +528,12 @@ } }, "node_modules/mineflayer": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-4.38.0.tgz", - "integrity": "sha512-uaHLRK/Vg9xTTx8WH57WmWdu4AYqDpQW1fqMHhyrlNQhb9JFgjfSWcr2DENK7meIKmsl64oF4gr8L73aK8zhfQ==", + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-4.39.0.tgz", + "integrity": "sha512-HptE++dIG55aRmT7GIj3QComS6MhEXstE3RJSuYMFhLtobsQhJJsqWmY9ObfiLp84MrS6Q4Em+wUH0rVQMXLJw==", "license": "MIT", "dependencies": { - "minecraft-data": "^3.112.0", + "minecraft-data": "^3.114.0", "minecraft-protocol": "^1.67.0", "mojangson": "^2.0.4", "prismarine-biome": "^1.1.1", @@ -997,9 +1001,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/runner-package/package.json b/runner-package/package.json index 5d5e406..4650b27 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -1,13 +1,15 @@ { - "name": "@drownek/plugwright", - "version": "2.0.4", + "name": "@plugwright/runner", + "version": "3.0.0", "description": "End-to-end testing framework for Paper/Spigot Minecraft plugins", "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", "prepublishOnly": "npm run build", "watch": "tsc --watch", "typecheck": "tsc --noEmit" @@ -36,8 +38,9 @@ }, "dependencies": { "js-yaml": "^4.1.0", - "mineflayer": "^4.38.0", + "mineflayer": "^4.39.0", "picocolors": "^1.1.1", + "prismarine-auth": "^3.1.1", "source-map-support": "^0.5.21" }, "devDependencies": { diff --git a/runner-package/runner.ts b/runner-package/runner.ts index 06d4c4b..7a09452 100644 --- a/runner-package/runner.ts +++ b/runner-package/runner.ts @@ -1,66 +1,82 @@ -import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; import { readdir } from 'fs/promises'; import { join, basename } from 'path'; import { pathToFileURL } from 'url'; -import { randomUUID } from 'node:crypto'; import { install as installSourceMapSupport } from 'source-map-support'; import pc from 'picocolors'; import { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator } from './lib/wrappers.js'; +import { testRegistry, resetRegistry } from './lib/test-registry.js'; +import { Session } from './lib/session.js'; +import { PluginHost } from './lib/plugin-host.js'; +import { runSerialBlock, runTestCase, runConcurrentSerialBlock, runConcurrentTestCase } from './lib/test-runner.js'; +import { skipReasonForOptions } from './lib/skip-reason.js'; +import { LocalEnvironment } from './lib/environments/local.js'; +import { externalEnvironment } from './lib/environments/external.js'; import { PlayerWrapper } from './lib/player.js'; -import { ServerWrapper } from './lib/server.js'; -import { testRegistry, scopeStack } from './lib/test-registry.js'; -import { serverConsoleBuffer, createBot, disconnectAllBots, writeMcOutput } from './lib/bot-utils.js'; -import { formatDuration, printTestSummary } from './lib/reporter.js'; +import { printTestSummary, writeJsonReport, writeJUnitReport } from './lib/reporter.js'; +import { loadRunnerConfig } from './lib/config.js'; +import { 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'; import type { TestResult } from './lib/types.js'; +import type { SerialBlock, TestCase, RegistryItem } from './lib/test-registry.js'; +import type { Account, AccountPool } from './lib/account.js'; // Enable source map support for accurate TypeScript stack traces installSourceMapSupport(); // Re-export public API export { ItemWrapper, GuiWrapper, LiveGuiHandle, GuiItemLocator }; -export { PlayerWrapper } from './lib/player.js'; +export { PlayerWrapper }; export { ServerWrapper } from './lib/server.js'; -export { test, opTest, describe, beforeEach, afterEach } from './lib/test-registry.js'; +export { test, describe, beforeEach, afterEach } from './lib/test-registry.js'; +export type { TestOptions, TestCase, SerialOptions, SerialBlock, RequiresMap } from './lib/test-registry.js'; export { expect } from './lib/matchers.js'; -export type { TestContext } from './lib/types.js'; - -async function waitForServerStart(serverProcess: ChildProcessWithoutNullStreams): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Server failed to start within 120 seconds')); - }, 120000); - - const dataHandler = (data: Buffer): void => { - const output = data.toString(); - writeMcOutput(data); - - if (output.includes('Done (')) { - clearTimeout(timeout); - serverProcess.stdout.removeListener('data', dataHandler); - serverProcess.stderr.removeListener('data', stderrHandler); - setTimeout(resolve, 3000); - } - }; - - const stderrHandler = (data: Buffer): void => { - writeMcOutput(data); - }; - - serverProcess.stdout.on('data', dataHandler); - serverProcess.stderr.on('data', stderrHandler); - - serverProcess.on('error', (err: Error) => { - clearTimeout(timeout); - reject(new Error(`Failed to start server: ${err.message}`)); - }); - - serverProcess.on('exit', (code: number | null) => { - if (code !== null && code !== 0) { - clearTimeout(timeout); - reject(new Error(`Server exited with code ${code} before becoming ready`)); - } - }); - }); +export { loadRunnerConfig, resolveSecret, isSecretRef } from './lib/config.js'; +export type { RunnerConfig, EnvironmentConfig, TestsConfig, LocalEnvironmentConfig, SecretRef, PluginConfig } from './lib/config.js'; +export type { TestContext, TestResult } from './lib/types.js'; +export type { Environment, EnvironmentCapabilities, BotConnectionOptions } from './lib/environment.js'; +export type { ServerConsole } from './lib/console.js'; +export { Session } from './lib/session.js'; +export { PluginHost } from './lib/plugin-host.js'; +export { definePlugin, PLUGIN_API_VERSION } from './lib/plugin.js'; +export type { PlugwrightPlugin, SessionContext, CleanupContext, PluginTestRef, MatcherFn } from './lib/plugin.js'; +export { AccountPool } from './lib/account.js'; +export type { Account, AccountsConfig } from './lib/account.js'; +export { externalEnvironment }; +export type { ExternalEnvironmentConfig, ExternalConsoleChannelConfig } from './lib/environments/external.js'; +export { rconConsole, RconConnection } from './lib/rcon/index.js'; +export type { RconConsoleConfig } from './lib/rcon/index.js'; + +/** + * `local` and `external` are built into this package; anything else is a third-party mode, + * loaded through the `runtime` reference the Gradle plugin wrote into the config. + */ +async function resolveEnvironment(cfg: EnvironmentConfig): Promise { + if (cfg.mode === 'local') { + return new LocalEnvironment(cfg.config as unknown as LocalEnvironmentConfig); + } + if (cfg.mode === 'external') { + return externalEnvironment(cfg.config as unknown as ExternalEnvironmentConfig); + } + if (cfg.runtime) { + let mod: any; + try { + mod = await importOptionalPackage(cfg.runtime.package); + } catch (error) { + throw new Error( + `Environment "${cfg.name}" needs package "${cfg.runtime.package}", which failed to load: ` + + `${(error as Error).message}` + ); + } + const exportName = cfg.runtime.export ?? 'default'; + const factory = mod[exportName]; + if (typeof factory !== 'function') { + throw new Error(`Package "${cfg.runtime.package}" has no export "${exportName}" for environment "${cfg.name}"`); + } + return factory(cfg.config) as Environment; + } + throw new Error(`Environment "${cfg.name}" uses mode "${cfg.mode}", which this runner cannot run yet.`); } async function findSpecFiles(dir: string): Promise { @@ -75,245 +91,307 @@ async function findSpecFiles(dir: string): Promise { return results; } -export async function runTestSession(): Promise { - const serverJar = process.env.SERVER_JAR; - const serverDir = process.env.SERVER_DIR; - const javaPath = process.env.JAVA_PATH; - const testFileFilter = process.env.TEST_FILES; - const testNameFilter = process.env.TEST_NAMES; +export async function runTestSession(config: RunnerConfig = loadRunnerConfig()): Promise { + const testFileFilters = config.tests.include ?? null; + const testNameFilters = config.tests.names ?? null; + const testNameExcludes = config.tests.exclude ?? null; + const timeoutMs = config.tests.timeoutMs + ?? (process.env.TEST_TIMEOUT ? parseInt(process.env.TEST_TIMEOUT, 10) : 30000); const testResults: TestResult[] = []; - if (!serverJar || !serverDir || !javaPath) { - throw new Error('SERVER_JAR, JAVA_PATH and SERVER_DIR environment variables must be set'); - } + const env = await resolveEnvironment(config.environment); + const session = new Session(env); + const plugins = new PluginHost(); + await plugins.load(config.plugins ?? []); + // Must happen before the first spec file is imported — see PluginHost.registerMatchers. + plugins.registerMatchers(); + // Wired before env.setup(): an environment's own console channel can be a bot that needs + // to authenticate during setup(), which goes through this same hook. + session.onPlayerCreate = (player, ctx) => plugins.onPlayerCreate(player, ctx); let exitCode = 0; - console.log(`${pc.bold('Starting Paper server...')}`); + try { + await env.setup(session); + session.refreshConsole(); + await plugins.setup(session); + + const connOpts = env.connection(); + + /** Why a test should not run, or null to run it. Checked in order: name exclude, + * name filter, declared `environments`, declared `requires`. A skip always lands + * in the report with its reason — a silent skip on an external stand would look + * like coverage that isn't really there. */ + function skipReasonFor(testCase: TestCase): string | null { + if (testNameExcludes?.some(pattern => testCase.name.includes(pattern))) { + return `excluded by tests.exclude (matches "${testNameExcludes.join(',')}")`; + } + if (testNameFilters && !testNameFilters.some(pattern => testCase.name.includes(pattern))) { + return `filtered out by tests.names (${testNameFilters.join(',')})`; + } + return skipReasonForOptions(env, config.environment.name, testCase.requires, testCase.environments); + } - const jvmArgsString = process.env.JVM_ARGS || ''; - const jvmArgs = jvmArgsString.split(' ').filter(arg => arg.trim() !== ''); + /** Why a whole `describe.serial` block should not run. The block's own `requires` / + * `environments` come first, then the tests inside it: a filter that takes out one step + * of a chain leaves the rest asserting against state nothing produced, so it takes out + * the block instead. */ + function blockSkipReason(block: SerialBlock): string | null { + const own = skipReasonForOptions(env, config.environment.name, block.requires, block.environments); + if (own) return own; + for (const testCase of block.tests) { + const reason = skipReasonFor(testCase); + if (reason) return `"${testCase.name}" ${reason}, and a serial block runs whole or not at all`; + } + return null; + } - console.log(pc.dim(`JVM Arguments: ${jvmArgs.join(' ')}`)); + /** One imported spec file's registered tests, snapshotted right after import so its + * concurrency values can be validated before any file's tests run — re-importing later + * to re-check wouldn't work anyway: ESM caches the module, so a second `import()` of + * the same file wouldn't re-run its top-level `test()`/`describe()` calls. */ + interface LoadedFile { + file: string; + pluginName: string | null; + items: RegistryItem[]; + } - const serverProcess = spawn(javaPath!, [...jvmArgs, '-jar', serverJar, '--nogui'], { - cwd: serverDir, - stdio: ['pipe', 'pipe', 'pipe'] - }); + async function loadFile(file: string, pluginName: string | null): Promise { + resetRegistry(); + await import(pathToFileURL(file).href); + return { file, pluginName, items: [...testRegistry] }; + } - // Ensure the Paper server dies if our runner is killed (e.g. Gradle task - // cancelled from the IDE). Otherwise the java.exe keeps running and holds - // run/logs/latest.log open, breaking the next plugwrightClean on Windows. - const killServerTree = (): void => { - if (!serverProcess.pid || serverProcess.killed || serverProcess.exitCode !== null) return; - try { - if (process.platform === 'win32') { - // taskkill recursively kills the whole java process tree. - spawn('taskkill', ['/F', '/T', '/PID', String(serverProcess.pid)], { - stdio: 'ignore', - windowsHide: true, - }).on('error', () => { /* best effort */ }); - } else { - serverProcess.kill('SIGKILL'); + /** Fails fast, before any test in the session runs, on a `concurrency` the account pool + * here can't satisfy — rather than the test itself blocking on its Nth `pool.lease()`. An + * environment with no pool (e.g. `LocalMode`) mints a synthetic throwaway account per + * connection instead of leasing one, so there's no pool capacity to check against; its + * ceiling is the server's own `max-players`, which is on the operator, not this check. */ + function validateConcurrency(loaded: LoadedFile[]): void { + const pool = env.accounts?.() ?? null; + if (!pool) return; + const capacity = pool.capacity(); + + for (const { file, items } of loaded) { + for (const item of items) { + const [kind, name, concurrency] = item.kind === 'serial' + ? ['describe.serial', item.block.name, item.block.concurrency] as const + : ['test', item.testCase.name, item.testCase.concurrency] as const; + if (concurrency <= 1) continue; + if (concurrency > capacity) { + throw new Error( + `${kind} "${name}" (${file}) declares concurrency: ${concurrency}, exceeding the ` + + `account pool's capacity (${capacity}). Reduce concurrency or grow the pool.` + ); + } + } } - } catch { - /* best effort */ } - }; - - let cleanupStarted = false; - const emergencyShutdown = (signal: string): void => { - if (cleanupStarted) return; - cleanupStarted = true; - console.log(pc.yellow(`\n[runner] Received ${signal}, killing Paper server...`)); - killServerTree(); - // Give taskkill a moment, then exit. - setTimeout(() => process.exit(1), 500).unref(); - }; - - process.on('SIGINT', () => emergencyShutdown('SIGINT')); - process.on('SIGTERM', () => emergencyShutdown('SIGTERM')); - process.on('SIGHUP', () => emergencyShutdown('SIGHUP')); - if (process.platform === 'win32') { - process.on('SIGBREAK', () => emergencyShutdown('SIGBREAK')); - } - // Last-resort safety net: if this node process exits for any reason while - // the server is still alive, try to take it down with us. - process.on('exit', () => killServerTree()); - // On Windows, when the parent (Gradle) is killed abruptly, signals are not - // delivered but our stdin pipe closes. Use that as a death signal. - if (process.stdin && typeof process.stdin.on === 'function') { - process.stdin.on('close', () => emergencyShutdown('stdin-close')); - process.stdin.on('end', () => emergencyShutdown('stdin-end')); - // stdin must be resumed for 'end'/'close' to fire on a piped stdin. - try { process.stdin.resume(); } catch { /* ignore */ } - } - try { - await waitForServerStart(serverProcess); - console.log(`${pc.green(pc.bold('Server started successfully'))}\n`); + /** Runs everything one loaded file registered, appending results to `testResults`. + * Shared by user specs and every plugin-inherited test file. */ + async function runLoadedFile(loaded: LoadedFile): Promise { + const { file, pluginName, items } = loaded; + + for (const item of items) { + if (item.kind === 'serial') { + const { block } = item; + const skipReason = blockSkipReason(block); + if (skipReason) { + console.log(pc.dim(` Serial block: ${block.name} - SKIPPED (${skipReason})`)); + for (const testCase of block.tests) { + testResults.push({ file, testName: testCase.name, passed: true, durationMs: 0, skipped: true, skipReason, plugin: pluginName }); + } + continue; + } - serverProcess.stdout.on('data', writeMcOutput); - serverProcess.stderr.on('data', writeMcOutput); + const results = block.concurrency > 1 + ? await runConcurrentSerialBlock({ file, block, session, plugins, connOpts, timeoutMs, pluginName, concurrency: block.concurrency }) + : await runSerialBlock({ file, block, session, plugins, connOpts, timeoutMs, pluginName }); + testResults.push(...results); + continue; + } - let testFiles = await findSpecFiles(process.cwd()); - if (testFileFilter) { - const patterns = testFileFilter.split(',').map(p => p.trim()); + const { testCase } = item; + const skipReason = skipReasonFor(testCase); + if (skipReason) { + console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (${skipReason})`)); + testResults.push({ file, testName: testCase.name, passed: true, durationMs: 0, skipped: true, skipReason, plugin: pluginName }); + continue; + } + + const result = testCase.concurrency > 1 + ? await runConcurrentTestCase({ file, testCase, session, plugins, connOpts, timeoutMs, pluginName, concurrency: testCase.concurrency }) + : await runTestCase({ file, testCase, session, plugins, connOpts, timeoutMs, pluginName }); + testResults.push(result); + } + } + + const preflightEntries = [...plugins.testFiles('preflight')]; + const loadedPreflight: LoadedFile[] = []; + for (const { file, pluginName } of preflightEntries) loadedPreflight.push(await loadFile(file, pluginName)); + + // Preflight files are loaded and validated on their own, before any main/suite spec + // file is imported — importing those here would run their top-level code ahead of + // preflight, against whatever state preflight was going to set up during execution. + validateConcurrency(loadedPreflight); + + // Preflight: plugin auth/setup tests, run before anything else. A failure aborts the + // whole session. + for (const loaded of loadedPreflight) { + console.log(`\n${pc.blue(pc.bold(`Running preflight tests from: ${loaded.file} ${pc.dim(`(plugin ${loaded.pluginName})`)}`))}`); + const before = testResults.length; + await runLoadedFile(loaded); + const failed = testResults.slice(before).find(r => !r.skipped && !r.passed); + if (failed) { + throw new Error(`Preflight test "${failed.testName}" failed (plugin ${loaded.pluginName}): ${failed.error?.message ?? 'unknown error'}`); + } + } + + let testFiles = await findSpecFiles(config.tests.dir || process.cwd()); + if (testFileFilters) { + const patterns = testFileFilters; console.log(`${pc.dim(`Filtering test files with patterns: ${JSON.stringify(patterns)}`)}\n`); testFiles = testFiles.filter(file => patterns.some(pattern => { const fileName = basename(file).replace(/\.spec\.js$/, ''); - const matches = fileName.includes(pattern) || file.includes(pattern); + const matches = fileName.includes(pattern); console.log(pc.dim(` Testing ${file} (basename: ${fileName}) against pattern "${pattern}": ${matches}`)); return matches; }) ); } + const loadedMain: LoadedFile[] = []; + for (const file of testFiles) loadedMain.push(await loadFile(file, null)); - console.log(`${pc.bold(`Found ${testFiles.length} test file(s)${testFileFilter ? ` matching filter: ${testFileFilter}` : ''}`)}\n`); + const suiteEntries = [...plugins.testFiles('suite')]; + const loadedSuite: LoadedFile[] = []; + for (const { file, pluginName } of suiteEntries) loadedSuite.push(await loadFile(file, pluginName)); - for (const file of testFiles) { - console.log(`\n${pc.blue(pc.bold(`Running tests from: ${file}`))}`); + // Main and suite files are loaded (imported once, registrations snapshotted) before any + // of them runs, so a misconfigured `concurrency` aborts here instead of after burning + // time on earlier tests. Preflight has already run by this point, so this no longer + // imports them ahead of the state preflight sets up. + validateConcurrency([...loadedMain, ...loadedSuite]); - testRegistry.length = 0; - scopeStack.length = 0; - scopeStack.push({ label: '', beforeHooks: [], afterHooks: [] }); - await import(pathToFileURL(file).href); + console.log(`${pc.bold(`Found ${loadedMain.length} test file(s)${testFileFilters ? ` matching filter: ${testFileFilters.join(',')}` : ''}`)}\n`); - for (const testCase of testRegistry) { - if (testNameFilter) { - const patterns = testNameFilter.split(',').map(p => p.trim()); - const matches = patterns.some(pattern => testCase.name.includes(pattern)); - if (!matches) { - console.log(pc.dim(` Test: ${testCase.name} - SKIPPED (filter: ${testNameFilter})`)); - continue; - } - } + for (const loaded of loadedMain) { + console.log(`\n${pc.blue(pc.bold(`Running tests from: ${loaded.file}`))}`); + await runLoadedFile(loaded); + } - console.log(` ${pc.bold(`Test: ${testCase.name}`)}`); + // Suite: plugin tests that run alongside user specs, tagged with the plugin's name. + for (const loaded of loadedSuite) { + console.log(`\n${pc.blue(pc.bold(`Running tests from: ${loaded.file} ${pc.dim(`(plugin ${loaded.pluginName})`)}`))}`); + await runLoadedFile(loaded); + } - serverConsoleBuffer.length = 0; + } finally { + await plugins.runCleanup(session); + await plugins.teardown(); + await session.disconnectAllBots(); + await env.teardown(); + + if (config.reports?.json) { + writeJsonReport(config.reports.json, config.environment.name, testResults); + console.log(pc.dim(`JSON report: ${config.reports.json}`)); + } + if (config.reports?.junit) { + writeJUnitReport(config.reports.junit, config.environment.name, testResults); + console.log(pc.dim(`JUnit report: ${config.reports.junit}`)); + } - const server = new ServerWrapper((cmd: string) => { - console.log(`${pc.yellow('[Server]')} ${pc.dim(`Executing: ${cmd}`)}`); - serverProcess.stdin.write(cmd + '\n', (err) => { - if (err) console.error(`[Server] Write error: ${err}`); - }); - }); + exitCode = printTestSummary(testResults); - const createPlayer = async (options?: { username?: string }): Promise => { - const uniqueId = randomUUID().split('-')[0]; - const botUsername = options?.username || `Test_${uniqueId}`; - console.log(`${pc.cyan('[Bot]')} Creating bot: ${pc.bold(botUsername)}`); + process.exitCode = exitCode; + setTimeout(() => { + process.exit(exitCode); + }, 1000).unref(); + } +} - let mineflayerVersion = process.env.MC_VERSION; - if (mineflayerVersion && mineflayerVersion.startsWith('26.1.')) { - mineflayerVersion = '26.1'; - } +export { sleep, poll, waitForAssertion, waitUntil, waitForStable } from './lib/utils.js'; - const bot = createBot({ - host: 'localhost', - port: 25565, - username: botUsername, - version: mineflayerVersion, - auth: 'offline', - }); - - const player = new PlayerWrapper(bot); - player._captureSpawnPromise(); - player.setServerWrapper(server); - player._setBotOptions({ - host: 'localhost', - port: 25565, - version: mineflayerVersion, - auth: 'offline', - }); - - await player.join(); - return player; - }; +/** + * `--ping`: connects to the environment, probes its declared console channel(s), and — if the + * environment has an account pool — leases one account and checks that it authenticates. No + * spec files run. Exits non-zero (after a readable diagnosis) on any problem, so it's safe to + * gate a build on. + */ +export async function runPingSession(config: RunnerConfig = loadRunnerConfig()): Promise { + console.log(pc.bold(`plugwright ping: environment "${config.environment.name}" (${config.environment.mode})`)); + + const env = await resolveEnvironment(config.environment); + const session = new Session(env); + const plugins = new PluginHost(); + await plugins.load(config.plugins ?? []); + plugins.registerMatchers(); + session.onPlayerCreate = (player, ctx) => plugins.onPlayerCreate(player, ctx); + + const problems: string[] = []; + let account: Account | undefined; + let pool: AccountPool | null = null; - const player = await createPlayer(); - - const testStartTime = Date.now(); - - try { - const abortController = new AbortController(); - const timeoutMs = process.env.TEST_TIMEOUT ? parseInt(process.env.TEST_TIMEOUT, 10) : 30000; - let timeoutHandle: ReturnType; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => { - abortController.abort(); - reject(new Error(`Test timed out after ${timeoutMs}ms. You can increase this by setting the TEST_TIMEOUT environment variable.`)); - }, timeoutMs); - }); - - await Promise.race([ - testCase.fn({ player, server, createPlayer, signal: abortController.signal }).finally(() => clearTimeout(timeoutHandle)), - timeoutPromise - ]); - - const durationMs = Date.now() - testStartTime; - console.log(` ${pc.green(pc.bold('PASSED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}\n`); - testResults.push({ file, testName: testCase.name, passed: true, durationMs }); - } catch (error) { - const durationMs = Date.now() - testStartTime; - const errorMsg = (error as Error).message; - - console.log(` ${pc.red(pc.bold('FAILED'))} ${pc.dim(`(${formatDuration(durationMs)})`)}: ${pc.red(errorMsg)}\n`); - - testResults.push({ - file, - testName: testCase.name, - passed: false, - durationMs, - error: error as Error - }); - } finally { - await disconnectAllBots(); - } - } + try { + await env.setup(session); + session.refreshConsole(); + await plugins.setup(session); + + if (env.capabilities.console) { + console.log(pc.green(`console: reachable (output=${session.console?.output})`)); + } else { + console.log(pc.yellow('console: unavailable')); + problems.push('no console channel could be reached'); } - } finally { - await disconnectAllBots(); - - // Stop the server - if (serverProcess.exitCode === null && !serverProcess.killed) { + pool = env.accounts?.() ?? null; + if (pool) { try { - serverProcess.stdin.write('stop\n'); - } catch (err) { - console.log(pc.yellow(`[WARNING] Failed to send stop command to server: ${(err as Error).message}`)); + account = await pool.lease(); + await env.beforeJoin?.(); + const connOpts = env.connection(); + const botOptions = { + ...connOpts, + auth: account.auth, + profilesFolder: account.microsoftCacheDir, + }; + const bot = session.createBot({ ...botOptions, username: account.username }); + const player = new PlayerWrapper(bot, session); + player._captureSpawnPromise(); + player._setBotOptions(botOptions); + player._setAccount(account); + await player.join(); + console.log(pc.green(`auth: "${account.username}" connected and authenticated`)); + await session.disconnectBot(bot, account.username); + session.removeBot(bot); + } catch (error) { + problems.push(`auth check failed: ${(error as Error).message}`); } + } else { + console.log(pc.dim('auth: no account pool configured for this environment, skipped')); } + } catch (error) { + problems.push((error as Error).message); + } finally { + if (account && pool) pool.release(account); + await plugins.teardown(); + await session.disconnectAllBots(); + await env.teardown(); + } - await new Promise((resolve) => { - const timeout = setTimeout(() => { - console.log(pc.yellow('[WARNING] Server did not stop gracefully, forcing shutdown...')); - serverProcess.kill(); - resolve(); - }, 30000); - - serverProcess.once('exit', (code) => { - clearTimeout(timeout); - if (code !== 0) { - console.log(pc.yellow(`[WARNING] Server exited with code: ${code}`)); - } - resolve(); - }); - }); - - serverProcess.removeAllListeners(); - serverProcess.stdin.end(); - serverProcess.stdout.destroy(); - serverProcess.stderr.destroy(); - - exitCode = printTestSummary(testResults); - - setTimeout(() => { - process.exit(exitCode); - }, 1000).unref(); + let exitCode = 0; + if (problems.length > 0) { + console.log(pc.red('\nplugwrightPing failed:')); + for (const problem of problems) console.log(pc.red(` - ${problem}`)); + exitCode = 1; + } else { + console.log(pc.green('\nplugwrightPing: environment is reachable')); } + + // Both: the unref'd timer only fires if something else is still holding the loop + // open (a lingering socket); process.exitCode carries the result when it isn't. + process.exitCode = exitCode; + setTimeout(() => process.exit(exitCode), 500).unref(); } -export { sleep, poll, waitForAssertion, waitUntil, waitForStable } from './lib/utils.js'; diff --git a/scripts/bump-version.js b/scripts/bump-version.js index ebec250..e8ce328 100644 --- a/scripts/bump-version.js +++ b/scripts/bump-version.js @@ -4,6 +4,14 @@ const { execSync } = require("child_process"); const fs = require("fs"); const readline = require("readline"); +// Every npm package published out of this repo. They move as one version: a plugin package +// and the runner it is written against are only recognisable as a matching pair if their +// version numbers say so, and the plugin packages are useless on their own anyway. +const NPM_PACKAGES = [ + "runner-package", + "auth-authme-package", +]; + function prompt(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => rl.question(question, (ans) => { rl.close(); resolve(ans.trim()); })); @@ -41,13 +49,6 @@ function bumpVersionFiles(newVersion, isPrerelease) { `id("io.github.drownek.plugwright") version "${newVersion}"` ); } - - // Matches any version after the package name, e.g., "@drownek/plugwright": "^1.x.x" - replaceRegexInFile( - "gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt", - /"@drownek\/plugwright": "\^[^"]+"/g, - `"@drownek/plugwright": "^${newVersion}"` - ); } replaceRegexInFile( @@ -55,6 +56,12 @@ function bumpVersionFiles(newVersion, isPrerelease) { /id\("io\.github\.drownek\.plugwright"\) version "[^"]+"/g, `id("io.github.drownek.plugwright") version "${newVersion}"` ); + + replaceRegexInFile( + "auth-authme-package/package.json", + /"@plugwright\/runner":\s*">=[^"]+"/g, + `"@plugwright/runner": ">=${newVersion}"` + ); } async function main() { @@ -84,20 +91,35 @@ async function main() { // update version.txt fs.writeFileSync("version.txt", newVersion + "\n"); - // update runner-package/package.json - execSync( - `npm version ${newVersion} --no-git-tag-version --allow-same-version`, - { cwd: "runner-package", stdio: "inherit" } - ); + // update each published package's package.json and its lockfile's own version field + for (const pkg of NPM_PACKAGES) { + console.log(`\nBumping ${pkg}...`); + execSync( + `npm version ${newVersion} --no-git-tag-version --allow-same-version`, + { cwd: pkg, stdio: "inherit" } + ); + } - // update the lockfile in the example plugin - console.log("\nUpdating lockfile in example_plugin..."); - execSync( - `npm install --package-lock-only`, - { cwd: "example_plugin/src/test/e2e", stdio: "inherit" } - ); + // Refresh every lockfile that records the runner's version rather than its own. + // + // The plugin packages depend on the runner through `file:../runner-package`, and npm + // copies the linked package's version into their lockfiles. `npm version` does not + // rewrite that copy — only an install does — so without this the plugin packages ship a + // lockfile still naming the previous runner version. + const LOCKFILE_ONLY = [ + ...NPM_PACKAGES.filter((pkg) => pkg !== "runner-package"), + "example_plugin/src/test/e2e", + ]; + + for (const dir of LOCKFILE_ONLY) { + console.log(`\nUpdating lockfile in ${dir}...`); + execSync( + `npm install --package-lock-only`, + { cwd: dir, stdio: "inherit" } + ); + } - // bump version references in source files (docs and templates only for stable releases) + // bump version references in source files (docs only for stable releases) const changedSourceFiles = [ "example_plugin/build.gradle.kts", ]; @@ -106,15 +128,13 @@ async function main() { changedSourceFiles.push( "README.md", "docs/quickstart.mdx", - "gradle-plugin/src/main/kotlin/me/drownek/plugwright/PlugwrightPlugin.kt", ); } // commit version files (+ source files if updated) const filesToCommit = [ "version.txt", - "runner-package/package.json", - "runner-package/package-lock.json", + ...NPM_PACKAGES.flatMap((pkg) => [`${pkg}/package.json`, `${pkg}/package-lock.json`]), "example_plugin/src/test/e2e/package-lock.json", ...changedSourceFiles, ].join(" "); diff --git a/scripts/publish.js b/scripts/publish.js new file mode 100644 index 0000000..893d482 --- /dev/null +++ b/scripts/publish.js @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +// Publishes every npm package in this repository. +// +// There are two places these packages need to reach, and they differ only in where they are +// sent and how the request is authenticated: +// +// - npmjs.com, the public release. This is what the release workflow runs, and what +// anyone reproducing a release runs locally. It is also the default here, so a bare +// `npm run publish:packages` does the public thing. +// +// - a registry of your own. An organisation behind a proxy, or one that mirrors its +// dependencies, needs these packages somewhere its builds can reach. Pointing +// `publishConfig.registry` at that registry inside each package.json would send the +// public release there too, so the registry and its credentials live outside the +// packages, in the environment. +// +// Configuration, every entry optional: +// +// PLUGWRIGHT_NPM_REGISTRY registry URL; unset means npmjs.com +// PLUGWRIGHT_NPM_USER registry username, for a registry that wants a password +// PLUGWRIGHT_NPM_PASSWORD registry password +// PLUGWRIGHT_NPM_TAG dist-tag to publish under (default: latest) +// PLUGWRIGHT_NPM_ACCESS npm access level (default: public) +// PLUGWRIGHT_NPM_PROVENANCE set to publish with --provenance +// +// The same settings can be given as flags: --registry, --user, --password, --tag, --access, +// --provenance. Flags win over the environment. `--dry-run` packs each package and reports +// what would be sent without sending it. +// +// A username and password are only used when both are given. Without them the publish uses +// whatever credentials npm already has — an `npm login` session, an `NPM_TOKEN` in `.npmrc`, +// or the OIDC token a CI job was issued. That covers npmjs.com and every registry that +// authenticates the same way. +// +// When a username and password are given they are written to a temporary npm config outside +// the working tree and passed with `--userconfig`, so nothing lands in a file the repository +// could commit. They go in as the Basic `_auth` pair rather than a bearer `_authToken`, +// because some registries (Nexus among them) answer a bearer token with 401. + +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +// Every npm package published out of this repository, in dependency order: the plugin +// packages are written against the runner, so a consumer resolving them wants the runner to +// already be there. +const PACKAGES = [ + "runner-package", + "auth-authme-package", +]; + +const PUBLIC_REGISTRY = "https://registry.npmjs.org/"; + +// Reads `--name value` and bare `--name` switches out of argv. +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith("--")) continue; + const name = arg.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { + args[name] = next; + i++; + } else { + args[name] = true; + } + } + return args; +} + +// How to run npm as a child process. +// +// Windows npm is a `.cmd` shim, and Node refuses to spawn one without a shell — a shell that +// would then reinterpret what it is handed. When npm runs this script it points +// `npm_execpath` at its own entry point, so the shim can be stepped over entirely; the shell +// is only the fallback for a run straight through node. +function npmInvocation() { + const execpath = process.env.npm_execpath; + if (execpath && execpath.endsWith(".js")) { + return { command: process.execPath, prefix: [execpath], shell: false }; + } + return { + command: process.platform === "win32" ? "npm.cmd" : "npm", + prefix: [], + shell: process.platform === "win32", + }; +} + +// Writes a throwaway npm config holding the credentials, and returns its path. +// +// The path in the auth key has to match the registry's, minus the protocol — npm looks the +// credentials up by that path and silently sends none when it does not match. +function writeAuthConfig(registry, user, password) { + const authKey = registry.replace(/^https?:/, ""); + const npmrc = [ + `registry=${registry}`, + `${authKey}:_auth=${Buffer.from(`${user}:${password}`).toString("base64")}`, + `${authKey}:always-auth=true`, + "", + ].join("\n"); + + const configFile = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "plugwright-publish-")), + "npmrc" + ); + fs.writeFileSync(configFile, npmrc, { mode: 0o600 }); + return configFile; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + const registry = args.registry || process.env.PLUGWRIGHT_NPM_REGISTRY || PUBLIC_REGISTRY; + const user = args.user || process.env.PLUGWRIGHT_NPM_USER; + const password = args.password || process.env.PLUGWRIGHT_NPM_PASSWORD; + const tag = args.tag || process.env.PLUGWRIGHT_NPM_TAG || "latest"; + const access = args.access || process.env.PLUGWRIGHT_NPM_ACCESS || "public"; + const provenance = Boolean(args.provenance || process.env.PLUGWRIGHT_NPM_PROVENANCE); + const dryRun = Boolean(args["dry-run"]); + + if (Boolean(user) !== Boolean(password)) { + console.error( + "A username without a password, or the other way round. Set both " + + "PLUGWRIGHT_NPM_USER and PLUGWRIGHT_NPM_PASSWORD, or neither." + ); + process.exit(1); + } + + const version = fs.readFileSync("version.txt", "utf8").trim(); + console.log( + `${dryRun ? "Dry run: would publish" : "Publishing"} ${version} to ${registry} ` + + `under the "${tag}" tag\n` + ); + + const configFile = user ? writeAuthConfig(registry, user, password) : null; + const npm = npmInvocation(); + + try { + for (const pkg of PACKAGES) { + const name = JSON.parse(fs.readFileSync(path.join(pkg, "package.json"), "utf8")).name; + console.log(`\n=== ${name}@${version}`); + execFileSync( + npm.command, + [ + ...npm.prefix, + "publish", + ...(configFile ? ["--userconfig", configFile] : []), + "--registry", registry, + "--tag", tag, + "--access", access, + ...(provenance ? ["--provenance"] : []), + ...(dryRun ? ["--dry-run"] : []), + ], + { cwd: pkg, stdio: "inherit", shell: npm.shell } + ); + } + } finally { + // Credentials, so they go whether the publish worked or not. + if (configFile) { + fs.rmSync(path.dirname(configFile), { recursive: true, force: true }); + } + } + + console.log(`\n${dryRun ? "Dry run complete for" : "Published"} ${version} to ${registry}`); +} + +main(); diff --git a/version.txt b/version.txt index 2165f8f..4a36342 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.4 +3.0.0