Skip to content

[v3] feat: concurrent test execution via concurrency - #67

Open
monikon22 wants to merge 11 commits into
Drownek:v3-devfrom
monikon22:feat/64-concurrent-test-execution
Open

[v3] feat: concurrent test execution via concurrency#67
monikon22 wants to merge 11 commits into
Drownek:v3-devfrom
monikon22:feat/64-concurrent-test-execution

Conversation

@monikon22

Copy link
Copy Markdown
Contributor

Closes #64.

test() and describe.serial() had no way to put several bots on the same feature at once — everything runs as one instance, sequentially, awaited one at a time. Races between real players (two bots claiming the same chest, buying the last item in stock) never got exercised.

{ concurrency: N }

test('only one player can claim the chest', { concurrency: 3 }, async ({ player }) => {
  player.chat('/claim');
  await expect(player).toHaveReceivedMessage(/Claimed|already claimed/);
});

N independent instances run at once, each with its own bot leased from the pool. One instance failing fails the whole result — this is for finding a race, not averaging a pass rate. describe.serial blocks take the same option and run N full copies of the ordered chain.

Concurrency is validated against AccountPool.capacity() before any test in the session runs (every spec file is loaded up front for this — see runner.ts), so concurrency: 10 against a 4-account pool fails immediately instead of the 5th bot hanging on a lease. LocalMode has no pool and nothing to check; its ceiling is max-players.

Two bugs concurrency exposed

  • session.consoleLog.clear() at the top of every test would have raced two concurrent tests wiping each other's messages. Replaced with a non-destructive cursor: ServerWrapper captures session.consoleLog.length at construction, and toHaveReceivedMessage defaults to reading from there instead of index 0.
  • createBotScope.close() called session.disconnectAllBots() with no arguments, tearing down every bot in the session — harmless sequentially, but the first concurrent instance to finish would have kicked every still-running sibling. Fixed to keep every bot outside its own scope.

Report shape

Still one row per test/test-in-block — concurrency multiplies bots, not report rows. durationMs is the slowest instance; a new instances array carries every instance's own outcome (bot username, pass/fail, duration), so a failure names which bot lost. Wired into the console summary and the JSON report; JUnit keeps the single aggregate.

Console log labeling

Test:, Serial block:, PASSED/FAILED, and bot-creation lines get an [i/N] tag — N instances logging the same test name at the same time was unreadable without it:

Test: concurrent bots each see their own marker and stay connected [1/3]
[Bot] Creating bot: pw_9c27 [1/3]
Test: concurrent bots each see their own marker and stay connected [2/3]
[Bot] Creating bot: pw_dfc2 [2/3]
Test: concurrent bots each see their own marker and stay connected [3/3]
[Bot] Creating bot: pw_8c5d [3/3]
...
PASSED [1/3] (2.5s)
PASSED [2/3] (2.9s)
PASSED [3/3] (3.0s)

Docs

Writing Tests gets a new section on concurrency; Reports gets the aggregated JSON shape.

Checked

tsc --noEmit clean. plugwrightTestLocal: 54/54 pass, run three times across the changes.

session.consoleLog.clear() wiped the whole shared buffer at the start of
every test. Fine when only one test runs at a time, but it means two
tests running concurrently would race to wipe each other's messages
out from under them.

ServerWrapper now captures its own startIndex at construction (and can
resetCursor() to "now"), and the toHaveReceivedMessage matcher defaults
to reading from that instead of index 0 when since isn't given. Same
observable behavior for a solo test, but the log itself is never
destroyed, so nothing racing to read it can lose messages.
test(name, { concurrency: N }, fn) and describe.serial(name, { concurrency: N }, fn)
fan out into N independent instances running at once, each with its
own bot leased from the account pool. One failing instance fails the
whole result — this is for races between real players, not a
pass-rate to average.

- Whole-session preflight: every spec file is loaded (imported once,
  registrations snapshotted) before any test runs, and every
  concurrency value is checked against the account pool's capacity()
  up front. A misconfigured concurrency aborts immediately instead of
  the Nth lease() hanging mid-run. An environment with no pool
  (LocalMode mints a throwaway account per bot) has nothing to check.
- createBotScope.close() used to call session.disconnectAllBots()
  with no arguments, tearing down every bot in the session rather than
  just its own scope's. Harmless when nothing ran concurrently; with
  concurrency it would mean one finishing instance kicking every
  still-running sibling's bot. Fixed to keep every bot outside its own
  scope.
- The report still has one row per test/test-in-block. Its durationMs
  is the slowest instance, and a new instances array carries every
  instance's own outcome (bot username, pass/fail, duration) so a
  failure names which bot lost the race. Wired into the console
  summary and the JSON report; JUnit keeps the single aggregated
  pass/fail, no per-instance breakdown.
- A concurrent describe.serial block runs N full copies of the block;
  instances can diverge mid-block (one loses its race and stops early
  while another keeps going), so a test position only counts as
  skipped if every instance skipped it.
- Console log lines (Test:, Serial block:, PASSED/FAILED, bot
  creation) are tagged with [i/N] — N instances logging the same test
  name at the same time is unreadable without it.
Covers both shapes: a plain test with concurrency, and a concurrent
describe.serial block. Each instance checks its own marker against the
shared server log and confirms it's still connected afterward — the
two bugs concurrency exposed (destructive log clear, scope-wide bot
teardown) would show up here as flaky or crashed instances.
Writing Tests gets a new section covering test()/describe.serial with
concurrency: what it's for, what you get back, how many instances you
can ask for, and why the server log stays shared and unfiltered across
instances. Reports gets the aggregated JSON shape (instances array,
botUsername) and a note that JUnit only ever sees the one aggregate.
…ut isn't full

expect(server).toHaveReceivedMessage() needs consoleOutput: 'full'. The
stand environment's RCON console only offers 'responses', same
limitation simple-ts.spec.ts's 'server logs command execution' already
declares — this test needed the same requires and didn't have it,
so it failed test-example-plugin-stand in CI instead of skipping.
Two additions, both surfacing data the aggregate result already had:

- TestInstanceResult gets index (1-based, matching the [i/N] console
  log tag for that same run) — was missing from both the console
  detail breakdown and the JSON report.
- The summary table's own row for a concurrent test now tags itself
  with [passed/total] next to the duration, instead of that count
  only showing up in the Failed Tests detail section below. A passing
  concurrent test previously gave no indication in the table that it
  was even concurrent.
@Drownek

Drownek commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Nice work on the concurrency support, the overall approach looks solid. Found a couple of race conditions around bot teardown during review and a small ux issue

1. Bot connection leak on join failure

createBotScope.close() only disconnects "own bots" by checking connected.map(p => p.bot). But bots only land in connected after player.join() succeeds. If a join times out, the bot exists but never makes it into that array — so teardown treats it as one to keep, and the TCP connection just sits there open forever

2. Race condition in disconnectAllBots

The method awaits disconnection of the unwanted bots, then filters this.bots afterward. If another test connects a new player mid-await during a concurrent run, that new bot gets wiped out by the filter before it ever gets a chance to disconnect

3. Inner concurrency gets silently ignored

If someone sets { concurrency: 5 } on a test() nested inside describe.serial, right now it just runs sequentially anyway — no warning, nothing. validateConcurrency doesn't catch it either since it only looks at top-level items. People are left confused about why their test isn't actually running concurrently

4. Load order changed by preflight check

The validateConcurrency preflight check means the runner now loads all spec files before running preflight tests. If any spec files depend on global state that a preflight test sets up during execution, they'll now import against stale state

Let me know if any of this needs more context, happy to help in any way

disconnectAllBots recomputed "remaining" from this.bots after its await,
using a keepSet fixed at call start. A bot connected by a concurrent test
while the await was in flight was neither in keepSet nor among the ones
just disconnected, so the post-await reset silently dropped it from
tracking without ever disconnecting it.

Now it snapshots the bots to disconnect before awaiting and removes
exactly those afterward, leaving anything added mid-await untouched.
createBotScope.close() tracked "own" bots as connected.map(p => p.bot) —
populated only after a successful join(). A bot whose join() failed never
landed in connected, so close() treated it as belonging to another scope
and left it connected, leaking the connection.

Track every bot the scope creates in ownBots instead, regardless of join
outcome, and tear down from that.
A serial block always runs its tests sequentially on one player, so a
per-test concurrency set inside one was silently ignored by the block
runner — nothing warned, the test just ran once. Now it throws at
registration time, same as an invalid concurrency value does.
validateConcurrency needed every file loaded (imported) up front to
check concurrency before any test ran, which meant main/suite spec
files were now imported before preflight ran — importing against
whatever state preflight was about to set up, rather than what it left
behind.

Preflight files are now loaded, validated, and run on their own first;
main and suite files are loaded and validated afterward, restoring the
original import order while keeping the fail-fast concurrency check.
@monikon22
monikon22 force-pushed the feat/64-concurrent-test-execution branch from 5a59f1e to 37fc104 Compare September 3, 2026 02:26
The previous fix tracked "own" bots in ownBots, snapshotted once per
connect() call. player.rejoin() swaps a player's .bot for a freshly
created connection under the same username but never touches ownBots,
so close() no longer recognized the new bot as its own and left it
connected — a permanent "already playing" kick for every later test
that leased the same account.

close() now also reads connected.map(p => p.bot) fresh, which reflects
whatever rejoin() last swapped in.
@monikon22

monikon22 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review, all four caught real bugs. Pushed fixes:

1. Bot leak on join failure (7109b6b) — you're right, close() tracked "own" bots via connected.map(p => p.bot), which only fills after a successful join(). A bot stuck mid-handshake never landed there, so close() read it as belonging to another scope and left it connected. Now createBotScope tracks every bot it creates in a separate ownBots array from the moment session.createBot() returns, join outcome aside, and tears down from that.

2. Race in disconnectAllBots (dcc5ef8) — same shape of bug, one level down. It filtered this.bots into a keep-list before the await, then after awaiting recomputed "remaining" as "whatever's still in the keep-set" — so a bot pushed onto this.bots by a concurrent test mid-await was in neither set and got dropped from tracking, never disconnected either. Fixed by snapshotting exactly which bots to disconnect up front and only removing those specific references afterward.

3. Silent nested concurrency (a529bb5) — agreed this needed to fail loud rather than fail quiet. test() inside describe.serial now throws at registration time if you pass concurrency > 1, same pattern as the existing invalid-value check. Points at the block name so it's obvious where to fix it.

4. Load order (37fc104) — good catch, and you nailed the cause. Restructured so preflight files are loaded, validated, and run first; main/suite files are only loaded (and their concurrency validated) after preflight finishes. Same fail-fast guarantee, original import order restored.

One more I found while testing against the stand suite: my first pass at the ownBots fix (7109b6b) snapshots each bot once at connect time, but player.rejoin() swaps .bot for a fresh connection under the same username without touching that snapshot — so a rejoined bot stopped being recognized as the scope's own and leaked exactly like the original bug, just from a different angle. f7b7cd8 has close() also read connected.map(p => p.bot) fresh, which picks up whatever rejoin() last swapped in.

Ran the full stand suite against this — clean except for one warp command teleports player timeout waiting on "Teleported to spawn" that looks unrelated (passes in ~0.5s on main, took 5.5s here) and I can't rule out on my end without rerunning the job, which I don't have permission to trigger on the upstream Actions run. Could you kick off a rerun when you get a chance? Happy to dig further if it's not just a one-off.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants