expect(player).toHaveBalance(100) matcher, resetting state between tests on a shared stand, shipping a suite of tests that any server running your plugin should pass — all of that is a plugin, and none of it requires the test engine to know about it.
Plugins are declared per environment:
npm(...) names a published package, installed by plugwrightCompileTests along with the rest of the environment’s packages. local(...) names a plugin of your own: local("staging") is plugins/staging.ts in the test workspace, compiled to dist/plugins/staging.js by the same tsc run as your specs. For a plugin that lives outside the workspace there is still local(file("...")). Options are plain strings — anything secret belongs in accounts { }, where it stays a secret reference.
LocalMode takes the same block. A local server running an authentication plugin needs the login hook exactly as much as a remote one does.
What a plugin can do
expect(x).toHaveBalance() looks the matcher up when it is called, but the spec file has to typecheck and import first.
Authentication is a hook, not a test
onPlayerCreate fires on every connection: the bot a test starts with, a second bot from createPlayer(), and every player.rejoin(). 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.
Hooks and describe.serial
beforeEach and afterEach normally wrap every test. Around a describe.serial block they run once instead: before its first test and after its last.
That is deliberate, and it matters most for a plugin that resets an account between tests. A block exists because its second test depends on what its first one did; a reset firing in between would throw that away, and the plugin has no way to tell which state the block was counting on. Anything a plugin needs to do per test inside a block belongs in the spec’s own beforeEach, where the test author can see it.
Inherited tests
preflight tests run before any user spec and abort the run when they fail — there is no point testing a shop when nobody can log in. suite tests run alongside your own and are tagged with the plugin’s name in the report.
Spec discovery only looks at your own compiled tests directory, so this is the only way a packaged test ever runs. Per-plugin, inheritTests = false loads the hooks and matchers without the tests.
Fixtures
extendContext adds fields to the object every test destructures:
Matchers
Versioning
apiVersion unset skips the check.
Writing one
A plugin is an npm package (or a single compiled file) whose default export implements the interface:definePlugin is an identity function; it exists so TypeScript infers your options type at the definition site. Depend on @plugwright/runner as a peer dependency, ship compiled JavaScript, and point main at it.
@plugwright/auth-authme in this repository is a complete, working example: a hook, an options interface, a preflight test, and a README.