Skip to content

feat(runner): add afterAll hook for describe.serial block cleanup - #78

Merged
Drownek merged 3 commits into
Drownek:masterfrom
monikon22:feat/serial-block-afterall-cleanup
Sep 13, 2026
Merged

Drownek merged 3 commits into
Drownek:masterfrom
monikon22:feat/serial-block-afterall-cleanup

Conversation

@monikon22

Copy link
Copy Markdown
Contributor

Problem

describe.serial has no hook that's guaranteed to run once the block is done, whatever "done" turned out to mean. afterEach/ctx.cleanup() are per-test: once the block stops early (a failure, a timeout, invalidatePlayer), every remaining test is reported skipped and its body — and any cleanup registered inside it — never runs. There's no way to write "revoke this permission / release this resource no matter which test in the block failed."

Fix

Adds afterAll(hook), importable alongside test/describe/beforeEach/afterEach. Only valid inside describe.serial — a plain describe/test has no single block-completion event to hang it on, and calling it outside a block throws immediately at registration time.

It runs:

  • once per instance of the block (so concurrency: 5 runs it 5 times, once per bot)
  • after the last test that actually executed — whether the block finished cleanly, stopped early, or a test in it was skipped
  • before the plugins' own afterEach and before the bot disconnects
  • not at all if the block never got its player (the initial connect failed) — nothing ran, so there's nothing to clean up

A throwing afterAll hook is logged ([serial <block>] afterAll error: ...) and never flips the block's result, matching how ctx.cleanup() finalizer errors are already handled.

describe.serial('vip trial', () => {
  test('grants the trial permission', async ({ player, server }) => {
    await server.execute(`lp user ${player.username} permission settemp vip.trial true 15m`);
  });

  afterAll(async ({ player, server }) => {
    // Runs once, even if a later test in the block fails or gets skipped.
    await server.execute(`lp user ${player.username} permission unsettemp vip.trial`);
  });

  test('sees the vip shop', async ({ player }) => { /* ... */ });
});

Changes

  • test-registry.ts: SerialBlock.afterAllHooks, afterAll() export
  • test-runner.ts: runSerialBlock runs the block's afterAllHooks (LIFO, try/catch-logged) in its finally, gated on lastCtx (mirrors the existing plugins.afterEach(lastCtx) gate)
  • runner.ts: re-exports afterAll
  • docs/writing-tests.mdx, docs/api-reference.mdx: document the hook

Verification

tsc --noEmit clean. No existing behavior touched outside the runSerialBlock finally block and the registry's block construction.

Registers on the SerialBlock itself (afterAllHooks), not the generic
describe scope stack, since only a serial block has a single
'finished' moment to hang cleanup on - a plain describe/test gets a
fresh bot per test and has no equivalent event. Throws if called
outside describe.serial.
Fires once per block instance, after the last test that actually
executed - covers a block that stopped early and skipped the rest,
which afterEach/cleanup finalizers never see since the skipped tests'
bodies never run. Skipped entirely if the block never got its player.
Runs before the plugins' own afterEach and bots.close(), mirroring how
plugin hooks already wrap the whole block. A throwing hook is logged
and does not fail the block.

@Drownek Drownek left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice addition, this fixes a real architectural pain point in describe.serial test suites.

What I checked:

  • Guaranteed cleanup: this fixes the issue where an early test exit or failure would skip downstream test bodies, leaving server-side state (permissions, whitelists, economy data) dangling with no cleanup.
  • Lifecycle & concurrency: hook execution in the finally block, scoped per bot instance with its own lastCtx, holds up fine under concurrency: N.
  • LIFO ordering: reversing the hook array gets teardown order right, matching how resource unwinding normally works.
  • Resilience: catching and logging throwing hooks stops a broken teardown from flipping an otherwise-passing test block.
  • Graceful skip: correctly skips execution when the bot never connected (lastCtx === null).
  • Registration guard: calling afterAll outside describe.serial throws immediately, so you find out at import time instead of during a run.
  • Docs in api-reference.mdx and writing-tests.mdx are clear.

Tested locally with early test exits, behavior checks out.

LGTM, ready to merge.

@Drownek
Drownek merged commit c33fd75 into Drownek:master Sep 13, 2026
2 checks passed
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