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
9 changes: 8 additions & 1 deletion auth-authme-package/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,14 @@ export default definePlugin<AuthAuthmeOptions>({
message: `authme: never saw a login/register prompt or session-resume message for "${account.username}"`,
},
);
if (promptResult === 'resumed') return;
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
Expand Down
26 changes: 25 additions & 1 deletion docs/reports.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,36 @@ build/reports/plugwright/<env>.log per-environment output, matrix runs o
}
```

`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.
`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
Expand Down
38 changes: 36 additions & 2 deletions docs/writing-tests.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ describe.serial('vip shop', { account: 'pw_0001' }, async () => {
});
```

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 (`local` invents a name per bot and has none), the block fails with that message rather than quietly running as somebody else.
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

Expand All @@ -221,6 +221,40 @@ describe.serial('trading', () => {
});
```

## 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.

### 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 unless it is in a `describe.serial` block
Expand All @@ -232,7 +266,7 @@ describe.serial('trading', () => {

## 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
42 changes: 42 additions & 0 deletions example_plugin/src/test/e2e/tests/concurrency.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
6 changes: 4 additions & 2 deletions runner-package/lib/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,13 @@ export class RunnerMatchers<T = unknown> extends Matchers<T> {

// 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.
// 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 view = (): string[] => buffer.slice(since);
const effectiveSince = since ?? (this.actual instanceof PlayerWrapper ? undefined : this.actual.startIndex);
const view = (): string[] => buffer.slice(effectiveSince);

await this.pollAssertion(
() => view().some(isMatch),
Expand Down
41 changes: 40 additions & 1 deletion runner-package/lib/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ function statusOf(result: TestResult): 'PASS' | 'FAIL' | 'SKIP' {
return result.passed ? 'PASS' : 'FAIL';
}

/** min/avg/max duration across a `concurrency > 1` result's instances. */
function instanceStats(instances: NonNullable<TestResult['instances']>): { 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'));
Expand Down Expand Up @@ -55,7 +65,12 @@ export function printTestSummary(testResults: TestResult[]): number {
? 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);
Expand Down Expand Up @@ -83,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('');
}

Expand Down Expand Up @@ -126,6 +153,18 @@ export function writeJsonReport(path: string, environmentName: string, testResul
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,
})),
};

Expand Down
12 changes: 12 additions & 0 deletions runner-package/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,21 @@ import type { Session } from './session.js';

export class ServerWrapper {
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;
}

execute(cmd: string): void {
Expand Down
20 changes: 13 additions & 7 deletions runner-package/lib/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,19 +168,25 @@ export class Session {
*
* 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. */
* 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<void> {
const keepSet = new Set(keep);
const toDisconnect = this.bots.filter(b => !keepSet.has(b));

await Promise.all(
this.bots
.filter(b => !keepSet.has(b))
.map((b, i) => this.disconnectBot(b, b.username ?? `bot-${i}`, 2000))
toDisconnect.map((b, i) => this.disconnectBot(b, b.username ?? `bot-${i}`, 2000))
);

const remaining = this.bots.filter(b => keepSet.has(b));
this.bots.length = 0;
this.bots.push(...remaining);
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);
}
}

/** Feeds raw environment output (e.g. Minecraft server stdout/stderr) into the console log buffer. */
Expand Down
29 changes: 29 additions & 0 deletions runner-package/lib/test-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ type TestFn = (context: TestContext) => Promise<void>;
export interface TestOptions {
requires?: string[];
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 {
Expand All @@ -32,6 +37,7 @@ export interface TestCase {
afterHooks: Hook[];
requires: string[];
environments: string[] | null;
concurrency: number;
}

/** What a `describe.serial` block accepts beyond the usual filters. */
Expand All @@ -50,6 +56,7 @@ export interface SerialBlock {
tests: TestCase[];
requires: string[];
environments: string[] | null;
concurrency: number;
}

export type RegistryItem =
Expand Down Expand Up @@ -82,12 +89,33 @@ function scopedEntry(name: string, options: TestOptions) {
afterHooks: [...scopeStack].reverse().flatMap(s => s.afterHooks),
requires: options.requires ?? [],
environments: options.environments ?? null,
concurrency: normalizeConcurrency(options.concurrency),
};
}

/** `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;
}

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 });
Expand Down Expand Up @@ -156,6 +184,7 @@ function serialImpl(label: string, optionsOrFn: SerialOptions | (() => void), ma
tests: [],
requires: options.requires ?? [],
environments: options.environments ?? null,
concurrency: normalizeConcurrency(options.concurrency),
};

currentBlock = block;
Expand Down
Loading
Loading