local and external cover the two common cases: a server Plugwright owns, and one it doesn’t. A mode of your own is for the cases in between — a Velocity proxy with backend servers, a Docker Compose stack, a server your company provisions through an internal API.
A mode has two halves that version independently:
- Kotlin, in the build: how the environment is declared and what has to happen before tests run.
- JavaScript, in the runner: where the bots connect and what the environment can do.
The Kotlin half
Your module compiles against the API classes, which ship inside the published plugin jar:compileOnly on purpose. The plugin is already on the build’s classpath at runtime, and a second copy is how you get a NoSuchMethodError that takes an afternoon to read.
The spec
The spec is what a build script fills in. Use Gradle property types so laziness and the configuration cache keep working:The mode
idlands in the config asenvironment.modeand names the mode in error messages.runnerPackagesis installed byplugwrightCompileTests, merged with every other environment’s packages into onenpm install. The first entry with anexportbecomes the runtime reference the runner loads the environment from, so name it there.validatereports problems through the context instead of throwing. Every environment is validated before the build fails, so a script with three mistakes reports three, not the first.serializewritesenvironment.configat configuration time. Secrets staySecretRefs here —node.put("password", spec.password.get())writes a reference, not a password.registerTasksadds tasks namedplugwright<Suffix><Environment>, soregister("Up", ...)in an environment calledproxygivesplugwrightUpProxy.prepareTaskmarks the one that has to run before the tests do.
Files your mode generates
Anything written while an environment runs belongs underctx.layout.generatedDir(ctx.environmentName) — src/test/e2e/generated/proxy for the mode above. That directory is gitignored and is yours alone; no other environment writes there.
If the spec has a property for it, fill the default in applyLayoutDefaults rather than in the property’s convention. It runs before validation, only for properties the build script left unset, so an explicit value in the build script still wins:
PlugwrightLayout also knows where the sources and the compiled output are: testsDir, pluginsDir, compiledTestsDir, compiledPluginsDir. See Project Layout.
Preparation belongs in a task rather than a callback. A callback executed inside someone else’s @TaskAction drags your mode object into that task’s state, breaks the configuration cache, and can never be run on its own. A task with declared inputs and outputs gets up-to-date checks and a name someone can type.
If a config value needs something only a task can reach — the Java toolchain, a Gradle service — set it from registerTasks with ctx.environmentConfig(provider) instead of from serialize. That is what LocalMode does for the Java executable path.
Registering it
create is generic over the mode, so the block has your spec type as its receiver with no cast.
The JavaScript half
The npm package named inrunnerPackages exports a factory. It takes the environment.config object your serialize wrote and returns an Environment:
{ requires: { op: true } } 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.
accounts() and beforeJoin() are optional. Returning no pool means every bot gets a throwaway pw_<rand> username, which is what local does. A pool is also what makes describe.serial('...', { account: 'pw_0001' }) possible: without one, a block asking for a named account fails rather than running as somebody else.
Checking it works
RunnerPackageRef in runnerPackages names an export.
Versioning
PlugwrightMode.apiVersion defaults to the API version your module compiled against, and Plugwright refuses to load a mode whose version it doesn’t understand. On the runner side, RunnerPackageRef carries an npm range for the same reason: the Kotlin module and the npm package are released separately, and the pair has to agree.