From bf056c8872a78e0d4f6ffae11aec026191f2a7f2 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 15:51:34 +0300 Subject: [PATCH 1/2] feat(runner): let createPlayer take a password for a named bot A bot named by the test bypasses the account pool, so nothing knew a password for it and an auth plugin had no way to log it in. The only workaround was authme's plugin-wide `password` option, which is a plain value and documented as local-throwaway only. createPlayer({ username, password }) threads the password through to the synthetic account. No registration state needed: onPlayerCreate already ignores account.justCreated and answers whichever prompt arrives, so a name the server knows logs in and a fresh one registers. A password without a username, where the environment has a pool, throws rather than being silently dropped. --- docs/api-reference.mdx | 27 +++++++++++++++++++++++++++ docs/external-servers.mdx | 6 ++++-- runner-package/lib/account.ts | 8 ++++++-- runner-package/lib/test-runner.ts | 22 ++++++++++++++++------ runner-package/lib/types.ts | 8 ++++++-- 5 files changed, 59 insertions(+), 12 deletions(-) diff --git a/docs/api-reference.mdx b/docs/api-reference.mdx index b5ed7fd..5144c4f 100644 --- a/docs/api-reference.mdx +++ b/docs/api-reference.mdx @@ -120,6 +120,33 @@ const player2 = await createPlayer(); player2.chat('/tpa ' + player.username); ``` + + Connect as this exact name instead of taking whatever the environment's account pool has free. + + + + Names the bot inside a `describe.serial` block, so a later test in that block gets the same bot + back instead of connecting another one. + + + + The password an authentication plugin logs `username` in with. Only for a named bot: a pool + account already carries its own, and passing both is an error. + + +Ask for a name only when the test needs that specific identity — an account somebody provisioned +by hand, or a name that came out of an external API. A second bot that is only there to be a +second player should stay unnamed, so a stand can lease it from the pool. + +```javascript +const friend = await createPlayer({ + username: 'FriendBot', + password: process.env.FRIEND_BOT_PASSWORD, +}); +``` + +Read the password from the environment. Spec files go to git. + ### `sleep(ms)` Pauses execution for a specified number of milliseconds. diff --git a/docs/external-servers.mdx b/docs/external-servers.mdx index d259184..80a0445 100644 --- a/docs/external-servers.mdx +++ b/docs/external-servers.mdx @@ -85,10 +85,12 @@ One account is leased per bot and returned in a `finally`, whatever the test did An explicitly named bot bypasses the pool entirely: ```ts -const friend = await createPlayer({ username: 'FriendBot' }); +const friend = await createPlayer({ username: 'FriendBot', password: process.env.FRIEND_BOT_PASSWORD }); ``` -That is a request for a specific identity, not for whatever is free — so nothing knows its password. On a stand behind a login wall, either leave those tests to the local environment or give the account a password some other way. +That is a request for a specific identity, not for whatever is free, so the pool knows nothing about it and neither does your authentication plugin. Pass the password with the name. Read it from the environment; the spec file goes to git. + +Most second bots don't need this. A test that just wants another player should call `createPlayer()` with no arguments and let the pool answer — a name is worth asking for when the identity is, because somebody provisioned that account with a permission group or a balance, or because the name came from somewhere outside the test. A leased account comes back with the previous test's inventory, balance and op status. Nothing resets it for you. Reset what you can in a plugin's `beforeEach`, exclude what you can't, and treat `capabilities.freshState = false` as the honest description it is. diff --git a/runner-package/lib/account.ts b/runner-package/lib/account.ts index 0c4685e..b226255 100644 --- a/runner-package/lib/account.ts +++ b/runner-package/lib/account.ts @@ -19,9 +19,13 @@ export interface Account { /** * Stand-in used when an environment has no [AccountPool] of its own — a `local` bot is a * fresh offline-mode connection under a name the server has never seen. + * + * [password] is for the other case: a bot the test names itself. That bypasses the pool, so + * nothing else knows a password for it, and the test has to bring one for the authentication + * plugin to use. */ -export function syntheticAccount(username: string): Account { - return { username, auth: 'offline', justCreated: true }; +export function syntheticAccount(username: string, password?: string): Account { + return { username, password, auth: 'offline', justCreated: true }; } /** A short random identity suffix. Four hex digits: long enough that two names in a run diff --git a/runner-package/lib/test-runner.ts b/runner-package/lib/test-runner.ts index a492ca2..5b8627e 100644 --- a/runner-package/lib/test-runner.ts +++ b/runner-package/lib/test-runner.ts @@ -34,10 +34,12 @@ export interface RunSerialBlockParams { /** Bots created while one test, or one `describe.serial` block, is running: who leased what, * which player answers to which `as` name, and how to give it all back. */ interface BotScope { - connect(options?: { username?: string; account?: string }): Promise; + connect(options?: { username?: string; account?: string; password?: string }): Promise; /** `ctx.createPlayer`. `as` names the player so a later call — a later test, inside a block — - * gets the same bot back instead of connecting a second one. */ - createPlayer(options?: { username?: string; as?: string }): Promise; + * gets the same bot back instead of connecting a second one. `password` belongs with + * `username`: a named bot is not a pool account, so the test is the only thing that can + * say how it logs in. */ + createPlayer(options?: { username?: string; as?: string; password?: string }): Promise; /** Every player connected in this scope, in the order they joined. */ players(): PlayerWrapper[]; /** Disconnects every bot in the scope and returns the accounts they held. */ @@ -49,7 +51,7 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo const named = new Map(); const connected: PlayerWrapper[] = []; - const connect = async (options?: { username?: string; account?: string }): Promise => { + const connect = async (options?: { username?: string; account?: string; password?: string }): Promise => { const pool = options?.username ? null : session.env.accounts?.() ?? null; if (options?.account && !pool) { throw new Error( @@ -57,9 +59,17 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo 'to take it from — a named account needs one the build script declares.' ); } + // A pooled account brings its own password, so a password with no username would be + // read by nothing. Say so rather than connect as somebody else's account and ignore it. + if (options?.password && pool) { + throw new Error( + `a password was passed without a username, but environment "${session.env.id}" leases its ` + + 'accounts from a pool and those carry their own. Name the bot too, or drop the password.' + ); + } const account: Account = pool ? await pool.lease(options?.account) - : syntheticAccount(options?.username || `pw_${randomSuffix()}`); + : syntheticAccount(options?.username || `pw_${randomSuffix()}`, options?.password); try { const botUsername = account.username; @@ -97,7 +107,7 @@ function createBotScope(session: Session, server: ServerWrapper, connOpts: BotCo const existing = named.get(handle); if (existing) return existing; } - const player = await connect({ username: options?.username }); + const player = await connect({ username: options?.username, password: options?.password }); if (handle) named.set(handle, player); return player; }, diff --git a/runner-package/lib/types.ts b/runner-package/lib/types.ts index 6c979f4..acf20ea 100644 --- a/runner-package/lib/types.ts +++ b/runner-package/lib/types.ts @@ -6,8 +6,12 @@ export interface TestContext { server: ServerWrapper; /** Connects an extra bot. Inside a `describe.serial` block, `as` names it: the same name in * a later test of that block returns the same bot instead of connecting another. Outside a - * block the name is scoped to the one test, which is as long as the bot lives anyway. */ - createPlayer: (options?: { username?: string; as?: string }) => Promise; + * block the name is scoped to the one test, which is as long as the bot lives anyway. + * + * `username` asks for one specific identity instead of whatever the pool has free, and + * `password` is what an authentication plugin logs that identity in with. Read it from the + * environment rather than writing it in the spec — spec files go to git. */ + createPlayer: (options?: { username?: string; as?: string; password?: string }) => Promise; /** Says the player is in a state the tests after this one were not written for. Inside a * `describe.serial` block that stops the block: the rest is reported skipped. Outside one * it does nothing — the bot is disconnected at the end of the test either way. */ From ffecdc3ab96577aa3fb5653cb9befd0d38deef9d Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 30 Aug 2026 15:51:44 +0300 Subject: [PATCH 2/2] test(example): unname the second bot, run multi-bot and Cross-bot on the stand Neither test needs the name FriendBot; both just need a second player. createPlayer() with no username leases from the pool, so on the stand they get a real account instead of bypassing it, and they come off excludeTests. --- example_plugin/build.gradle.kts | 6 ++---- example_plugin/src/test/e2e/tests/message-buffer.spec.ts | 2 +- example_plugin/src/test/e2e/tests/multi-bot.spec.ts | 5 +++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index 79a1955..14205ba 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -132,11 +132,9 @@ plugwright { // Matched against test names. What is left out here is what no command puts back: // an arena slot that is filled once and stays filled, and a first join, which only // happens on an account the server has never seen. Op, inventory, balance and kit - // cooldowns are reset per test by the stand-reset plugin instead. multi-bot and - // Cross-bot are out for a different reason — they name their second bot, and a named - // bot is not a pool account, so nothing knows its password. + // cooldowns are reset per test by the stand-reset plugin instead. excludeTests.set(listOf( - "arena", "first join", "multi-bot", "Cross-bot" + "arena", "first join" )) } } diff --git a/example_plugin/src/test/e2e/tests/message-buffer.spec.ts b/example_plugin/src/test/e2e/tests/message-buffer.spec.ts index 345a559..2900fdb 100644 --- a/example_plugin/src/test/e2e/tests/message-buffer.spec.ts +++ b/example_plugin/src/test/e2e/tests/message-buffer.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@plugwright/runner'; test('Cross-bot message separation', async ({ player, createPlayer }) => { - const friend = await createPlayer({ username: 'FriendBot' }); + const friend = await createPlayer(); player.chat('/help'); diff --git a/example_plugin/src/test/e2e/tests/multi-bot.spec.ts b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts index d1a6b11..4cda208 100644 --- a/example_plugin/src/test/e2e/tests/multi-bot.spec.ts +++ b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts @@ -10,8 +10,9 @@ test('multi-bot teleportation', async ({ player, createPlayer }) => { // This can be also done with defining test as `opTest` instead of `test` or even within `beforeEach` block. await player.makeOp(); - // Spawn a second player - const friend = await createPlayer({ username: 'FriendBot' }); + // Spawn a second player. No username: the test needs a second bot, not a specific one, + // so on a stand this leases the next free pool account instead of bypassing the pool. + const friend = await createPlayer(); // Teleport the friend to a specific location // We wait for friend player to actually teleport.