Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion auth-authme-package/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ let resolved: Required<Omit<AuthAuthmeOptions, 'password'>> & { password?: strin
export default definePlugin<AuthAuthmeOptions>({
name: 'authme',
apiVersion: 1,
tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight' }],
// Preflight exists to prove the login/register flow actually runs — a reused, already
// authenticated player would skip straight past what this test checks.
tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight', reuse: false }],

setup({ options }) {
resolved = { ...DEFAULTS, ...options };
Expand Down
24 changes: 24 additions & 0 deletions docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,22 @@ matrix {
}
```

<ParamField path="reuse" type="Action<ReuseSpec>">
Settings for reusing a connected bot across test boundaries instead of reconnecting for every test. `enabled` is `false` by default — an existing suite that depends on a fresh player per test keeps working unchanged until it opts in. `maxPlayers` caps live registry entries; unset falls back to 4, or the environment's account pool capacity minus one when it has a pool. `stay` decides whether a reused bot keeps its connection between the tests that borrow it; `true` by default.
</ParamField>

```kotlin
reuse {
enabled.set(true)
maxPlayers.set(4)
stay.set(true)
}
```

`stay.set(false)` keeps the reuse but drops the parking: at the end of every test the bot leaves the server, and the entry it came from — same account, same nick, same ability labels — waits offline until a later test takes it and rejoins under that identity. What carries over is the identity, not the connection. That's the only form of reuse a server which kicks idle players allows, and it's what an environment declaring `capabilities.playerReuse = 'rejoin'` forces regardless of this setting. A single test can override it with `reuse: { stay }` — see [Writing Tests](/writing-tests).

`PLUGWRIGHT_REUSE=1` / `PLUGWRIGHT_REUSE=0` overrides `reuse.enabled` from the environment, and `PLUGWRIGHT_REUSE_STAY` does the same for `reuse.stay`, for trying either in a dev loop without editing a committed build script.

Per-environment, inside `create(...) { }`:

<ParamField path="includeInMatrix" type="Property<Boolean>">
Expand Down Expand Up @@ -309,4 +325,12 @@ plugins {
Set `PLUGWRIGHT_DEBUG=1` in your environment to enable verbose debug logging during test execution. This is particularly useful for troubleshooting GUI flows and inspecting window open/close events from the bot.
</ParamField>

<ParamField path="PLUGWRIGHT_REUSE" type="String">
Overrides `reuse.enabled` for this run: `1`/`true` turns it on, `0`/`false` turns it off. Unset, or any other value, leaves the build script's own setting alone.
</ParamField>

<ParamField path="PLUGWRIGHT_REUSE_STAY" type="String">
Overrides `reuse.stay` for this run, same `1`/`true` and `0`/`false` spelling. `0` keeps reuse on but sends each bot off the server at the end of every test, rejoining it when a later test takes its entry. Ignored when reuse itself is off.
</ParamField>


6 changes: 5 additions & 1 deletion docs/custom-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ class VelocityEnvironment implements Environment {
arbitraryUsernames: true,
lifecycle: true,
cleanupStrategy: 'compensating',
// Absent means "allowed". `false` if a bot surviving a test boundary at all would
// break the environment (a per-test world reset); 'rejoin' if only a bot *sitting*
// there is the problem (an idle-kick timeout, an AFK check).
playerReuse: true,
};

async setup(session: Session): Promise<void> { /* connect, probe, warm up */ }
Expand All @@ -163,7 +167,7 @@ class VelocityEnvironment implements Environment {
}
```

Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` are skipped when you report `op: false`, so report what is true after `setup()` rather than what the build script hoped for. `consoleOutput` is three-valued (`full`, `responses`, `none`) because a console that answers its own commands still cannot show a test the server log.
Capabilities are a promise the runner holds you to. Tests declaring `requires: ['op']` 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. `playerReuse: false` overrides `tests.reuse.enabled` for the whole run against this environment — set it when a long-lived bot would break something the environment can't tell tests about any other way. `playerReuse: 'rejoin'` is the softer form for a server that only objects to an *idle* bot: reuse stays on, but every entry leaves at the end of its test and rejoins when a later one takes it, and no `tests.reuse.stay` or per-test `reuse: { stay: true }` can talk it out of that.

`accounts()` and `beforeJoin()` are optional. Returning no pool means every bot gets a throwaway `Test_<uuid>` username, which is what `local` does.

Expand Down
6 changes: 6 additions & 0 deletions docs/external-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ That is a request for a specific identity, not for whatever is free — so nothi
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.
</Warning>

## Reuse and the account pool

With `tests.reuse` on ([Configuration](/configuration)), a registry entry holds its leased account for as long as the entry lives, not just for one test — an account checked out by a long-lived player doesn't return to the pool until that player is evicted, invalidated, or the run ends. Size `accounts { }` accordingly: `maxPlayers` defaults to the pool's capacity minus one so a test's own `createPlayer()` still has a spare slot, but a pool exactly as big as `maxPlayers` leaves nothing free for it.

An environment that can't tolerate a bot surviving a test boundary at all — a per-test world reset — should report `capabilities.playerReuse = false` after `setup()` rather than let reuse quietly misbehave. When the problem is narrower than that, and it usually is on a public server, `capabilities.playerReuse = 'rejoin'` keeps the reuse and drops the idling: the bot leaves at the end of every test and rejoins under the same account when a later test takes its entry. `tests.reuse.stay = false` asks for the same thing from the config side. Either way the account stays leased while the entry is parked, so pool sizing doesn't change. See [Writing a Mode](/custom-modes).

## Checking the stand before you test

```bash
Expand Down
21 changes: 20 additions & 1 deletion docs/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ export interface PlugwrightPlugin<O = unknown> {
apiVersion?: number;
setup?(ctx: { session, env, options: O }): Promise<void> | void;
onPlayerCreate?(player, ctx: { account, env }): Promise<void> | void;
onPlayerReuse?(player, ctx: { account, env }): Promise<void> | void;
beforeEach?(ctx: TestContext): Promise<void> | void;
afterEach?(ctx: TestContext): Promise<void> | void;
extendContext?(ctx: TestContext): Record<string, unknown> | void;
matchers?: Record<string, MatcherFn>;
tests?: Array<{ file: string; mode: 'preflight' | 'suite' }>;
tests?: Array<{ file: string; mode: 'preflight' | 'suite'; reuse?: false }>;
cleanup?(ctx: { session, scope: 'session' | 'manual' }): Promise<void> | void;
teardown?(): Promise<void> | void;
}
Expand Down Expand Up @@ -71,6 +72,24 @@ export default definePlugin({

`onPlayerCreate` fires on every connection: the first bot of a test, a second bot from `createPlayer()`, every `player.rejoin()`, and the admin-bot console channel. A "log in first" test fires once, in whatever order the spec files happen to load, and leaves every other connection unauthenticated. If you want the visible reassurance of a login test in the report, ship one as a `preflight` test alongside the hook.

## Reuse

```ts
export interface PlugwrightPlugin<O = unknown> {
onPlayerReuse?(player, ctx: { account, env }): Promise<void> | void;
}
```

Fires before a reused player is handed to the next test — never on the first connection, where `onPlayerCreate` already runs. By the time it fires, the core has already done its own safe minimum (closing a leftover open window); anything beyond that — clearing a hotbar, resetting a scoreboard value your plugin tracks — is yours to do here. See [Writing Tests](/writing-tests) for the test-side `reuse` option and ability labels.

An auth plugin's preflight exists to prove the login flow runs, so a reused, already-authenticated player would defeat the point:

```ts
tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight', reuse: false }]
```

`PluginTestRef.reuse: false` forces every test in that file onto a fresh connection, regardless of the run's own `reuse` setting.

## Inherited tests

```ts
Expand Down
8 changes: 6 additions & 2 deletions docs/reports.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ build/reports/plugwright/<env>.log per-environment output, matrix runs o
"durationMs": 63,
"error": null,
"skipReason": null,
"plugin": null
"plugin": null,
"reuse": { "key": "auto:[]!()", "reused": true, "stay": true, "abilities": [] }
},
{
"file": "…/dist/simple-ts.spec.js",
Expand All @@ -34,14 +35,17 @@ build/reports/plugwright/<env>.log per-environment output, matrix runs o
"durationMs": 0,
"error": null,
"skipReason": "requires capability [consoleOutput:full], unavailable on \"staging\"",
"plugin": null
"plugin": null,
"reuse": 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.

`reuse` is `null` when `tests.reuse` is off for the run. Otherwise it's always present, even for a test that opted out with `reuse: false` (`reused: false`, `key: "none"`). `reused: true` means the player came from an earlier test instead of a fresh connection; `abilities` is the label set it was matched against; `stay` is whether it kept its connection after this test or was parked offline until a later test rejoins it. See [Writing Tests](/writing-tests).

Every skip carries its reason: excluded by name, wrong environment, or a capability the environment doesn't have. A skipped test that doesn't say why is worse than a failing one, because it reads as coverage.

## JUnit XML
Expand Down
4 changes: 4 additions & 0 deletions docs/test-filtering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ test('the /debug dev command', { environments: ['local'] }, async ({ player }) =
});
```

## Reuse is a different axis

`requires` and `environments` decide whether a test runs at all. `reuse` (see [Writing Tests](/writing-tests)) decides which bot it gets once it's already running — a filter never skips a test because of its `reuse` option. The two do interact on one environment setting: `capabilities.playerReuse` disables reuse for the whole environment when it's `false`, or forces every player off the server between tests when it's `'rejoin'` — neither affects anything a test declared through `requires`.

## Skips are reported

Every skipped test lands in the report with its reason:
Expand Down
90 changes: 89 additions & 1 deletion docs/writing-tests.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,97 @@ test('advanced waiting', async ({ player }) => {
});
```

## Player Reuse

By default, every test gets a fresh bot and disconnects it when the test ends. When `tests.reuse` is on ([Configuration](/configuration)), a test can ask for a bot that survived from an earlier test instead of reconnecting:

```typescript
test('shows prices', async ({ player }) => {
// same long-lived player as any other default-reuse test, if one is free
});

opTest('admin can edit', async ({ player }) => {
// matched to a player already carrying the `op` label — makeOp() only runs if none does
});

test('regular player cannot edit', { reuse: { excludeAbilities: ['op'] } }, async ({ player }) => {
// explicitly asks for a player that is NOT op, even if an op'd one is sitting free
});

test('first join flow', { reuse: false }, async ({ player }) => {
// always a brand-new connection, regardless of the run's reuse setting
});
```

Matching goes by **ability labels**, not by resetting server state — the runner has no way to undo what a command changed, so it doesn't pretend to. `player.makeOp()`, `player.deOp()` and `player.setGameMode()` label the player automatically (`op`, `gamemode:creative`, …); anything else needs an explicit `player.mark('kit:starter')` / `player.unmark(...)`. Read the current set with `player.abilities`.

```typescript
export interface ReuseOptions {
key?: string; // explicit identity — same bot every time, e.g. the second player in a multiplayer test
abilities?: string[]; // player must carry all of these
excludeAbilities?: string[]; // player must carry none of these
strict?: boolean; // player's labels must equal `abilities` exactly, no extras
stay?: boolean; // keep the connection after this test, or park the entry offline
}
```

`reuse` on `test()` accepts `false`, a string (shorthand for `{ key }`), or a `ReuseOptions` object. `ctx.createPlayer({ reuse: … })` takes the same shape for any secondary player a test creates.

`stay` is the one option that describes what happens *after* the test rather than which player it gets. Left alone it follows the run's `tests.reuse.stay` (`true` by default): the bot stays on the server, and the next test that matches it skips connecting entirely. `stay: false` sends it off at the end of this test and keeps only the entry — the account, the nick and the labels — so a later test gets the same identity back through a rejoin:

```typescript
test('slow inventory walk', { reuse: { stay: false } }, async ({ player }) => {
// the bot leaves when this test ends; the next test to want this player rejoins it
});
```

Reach for it when a parked bot is the problem — an idle-kick timeout, an AFK check, a server that counts online players. Everything is disconnected at the end of the run either way. An environment can force it for every test by declaring `capabilities.playerReuse = 'rejoin'`, and then `stay: true` here doesn't lift it.

A test that cares about a clean nick, the absence of a label, or a first-registration flow declares `reuse: false` (or the right `excludeAbilities`) explicitly — reuse never guesses on a test's behalf.

```typescript
test('cleans up on failure', async ({ player, invalidatePlayer }) => {
// ...
if (somethingLeftThePlayerInABadState) invalidatePlayer(player);
});
```

`invalidatePlayer` marks a player unfit for the next test: it disconnects instead of being handed out again. The runner does this automatically for a test that fails or times out — one bad test shouldn't hand its mess to the next one.

### Initializing a reuse pool with `reuseTest`

`reuseTest(pool, fn)` registers a one-time setup step for a named reuse pool (the same string used as `reuse: 'poolName'` or `reuse: { key: 'poolName' }`). It runs **only** when the pool's registry entry is actually (re)built — the first time any test asks for `'poolName'`, or later if that entry was dropped (a rejoin failed, abilities stopped matching) and needs to be created again. An ordinary checkout of an already-live entry never runs it:

```typescript
reuseTest('shopkeeper', async ({ player }) => {
await player.chat('/vip add');
player.mark('vip');
});

test('shop shows vip discount', { reuse: 'shopkeeper' }, async ({ player }) => {
// guaranteed to run after 'shopkeeper' has been initialized at least once
});
```

`reuseTest` is reported as its own test, listed right before whichever test triggered the (re)creation. If its body throws, that test fails too — the entry is discarded so the next attempt runs `reuseTest` again instead of handing out a half-initialized player.

It takes the same scope as `test`/`opTest` — everything except `reuse` itself, which doesn't apply to a test that's initializing a pool rather than resolving into one:

```typescript
describe('Shop', () => {
reuseTest('shopkeeper', { requires: ['op'] }, async ({ player }) => {
// named "Shop > reuse:shopkeeper" — describe nesting applies same as any other test
});
});
```

- `requires` / `environments` skip it exactly like a regular test would be skipped — reported `skipped`, `fn` never runs. A player handed out under a pool whose `reuseTest` doesn't apply on this environment still connects; it's just never initialized, same as if no `reuseTest` had been declared for that pool at all.
- Spec-level `beforeEach`/`afterEach` from the enclosing `describe` wrap it, and so do plugin `beforeEach`/`afterEach` and `extendContext` fixtures — `ctx.holy`, matchers, everything a normal test body gets.
- It does not accept `reuse` — pass `(pool, fn)` or `(pool, options, fn)` where `options` is `requires`/`environments` only.

## Best Practices

1. **Keep tests isolated** - Each test gets a fresh bot
1. **Keep tests isolated** - Each test gets a fresh bot, unless reuse is on
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
Expand Down
4 changes: 3 additions & 1 deletion example_plugin/src/test/e2e/tests/events.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { test, expect } from '@drownek/plugwright';

test('player receives item on first join', async ({ player }) => {
// Depends on the join itself, not just on a player's current state, so it always needs a
// brand-new connection — reuse would hand it a player who already joined once before.
test('player receives item on first join', { reuse: false }, async ({ player }) => {
await expect(player).toHaveReceivedMessage('Welcome');
await expect(player).toContainItem('wooden_sword');
});
Expand Down
4 changes: 3 additions & 1 deletion example_plugin/src/test/e2e/tests/kits.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ test('kit has cooldown', async ({ player }) => {
await expect(player).toHaveReceivedMessage('cooldown');
});

test('VIP kit requires permission', async ({ player }) => {
// Op bypasses permission checks in Bukkit by default, so this only proves anything against a
// player that isn't one.
test('VIP kit requires permission', { reuse: { excludeAbilities: ['op'] } }, async ({ player }) => {
player.chat('/kit vip');
await expect(player).toHaveReceivedMessage('no permission');
});
4 changes: 3 additions & 1 deletion example_plugin/src/test/e2e/tests/player-wrapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

import { expect, test } from '@drownek/plugwright';

test('makeOp', async ({ player }) => {
// Needs a player that isn't already op, or the server never sends the "Made ... a server
// operator" confirmation this test checks for.
test('makeOp', { reuse: { excludeAbilities: ['op'] } }, async ({ player }) => {
// This executes op server command, and we wait for response from server
// so when await completes, we are sure player is op.
await player.makeOp();
Expand Down
4 changes: 3 additions & 1 deletion example_plugin/src/test/e2e/tests/shop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ test('purchase item from shop', async ({ player }) => {
await expect(player).toContainItem('diamond');
});

test('cannot buy without money', async ({ player }) => {
// Depends on starting with no currency, which a reused player carried over from an earlier
// test can't promise — a fresh connection is the only way to guarantee it.
test('cannot buy without money', { reuse: false }, async ({ player }) => {
player.chat('/shop');
const gui = await player.gui({ title: 'Shop' });
await gui.locator(item => item.name === 'diamond').click();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ abstract class AbstractNodeTask : DefaultTask() {
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.map { quoteForCmd(it) }
// "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()
}
Expand Down
Loading