Skip to main content
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 build writes a config file; the runner reads it. Nothing else passes between them.

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

What each piece is for:
  • id lands in the config as environment.mode and names the mode in error messages.
  • runnerPackages is installed by plugwrightCompileTests, merged with every other environment’s packages into one npm install. The first entry with an export becomes the runtime reference the runner loads the environment from, so name it there.
  • validate reports 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.
  • serialize writes environment.config at configuration time. Secrets stay SecretRefs here — node.put("password", spec.password.get()) writes a reference, not a password.
  • registerTasks adds tasks named plugwright<Suffix><Environment>, so register("Up", ...) in an environment called proxy gives plugwrightUpProxy. prepareTask marks the one that has to run before the tests do.

Files your mode generates

Anything written while an environment runs belongs under ctx.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 in runnerPackages exports a factory. It takes the environment.config object your serialize wrote and returns an Environment:
Capabilities are a promise the runner holds you to. Tests declaring { 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

The config file is the contract between the two halves, and reading it answers most of the questions that come up while a mode is half-written. If the runner says the mode is one it “cannot run yet”, the runtime reference is missing — check that a 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.