Skip to content
Merged
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ gradle-app.setting
!gradle-wrapper.jar
*.class
gradle.properties
/gradle-plugin/bin/
/example_plugin/bin/
**/bin/

# IDE
.idea/
Expand All @@ -32,6 +31,9 @@ gradle.properties
Thumbs.db
desktop.ini

# Code Graph index
.codegraph/

# Logs
*.log
logs/
Expand Down
27 changes: 27 additions & 0 deletions docs/ci-cd.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,30 @@ jobs:
# Path to your plugin gradle project if it's not at the project's root
working-directory: "."
```

## Private npm registries

If the test workspace installs from a private registry, declare it once in `build.gradle.kts` and pass the token through the environment. Nothing about the registry has to be configured on the runner, and the workflow file holds a secret name rather than a secret:

```kotlin
plugwright {
npm {
registry("https://nexus.corp/repository/npm-group/") {
authToken(secret.env("NPM_TOKEN"))
}
}
}
```

```yaml
- uses: drownek/plugwright-action@v1
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
with:
java-version: "17"
node-version: "24"
```

Plugwright generates the `.npmrc` from that block before each `npm install`. If `NPM_TOKEN` is missing from the job, the build stops and names it, instead of failing later with a 404 that looks like a typo in a package name. See [Configuration](/configuration#npm-registries).

The generated file is gitignored, but it does exist on disk for the length of the job. On a self-hosted runner with a shared workspace, clean it up the way you would any other credential the job writes.
56 changes: 56 additions & 0 deletions docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,62 @@ downloadNode.set(true) // no local Node.js required - download it automatically
nodeVersion.set("22.14.0")
```

## npm registries

The workspace is an npm project, and by default it installs from whatever registry the machine is already pointed at. If your packages come from a private registry (a Nexus or an Artifactory, usually), declare it in the build script instead of leaving a `.npmrc` for everyone to set up by hand:

```kotlin
import me.drownek.plugwright.api.secret

plugwright {
npm {
registry("https://nexus.corp/repository/npm-group/") {
authToken(secret.env("NPM_TOKEN"))
}

// Only @drownek packages come from here; everything else uses the registry above.
scope("@drownek", "https://nexus.corp/repository/npm-private/") {
username(secret.env("NPM_USER"))
password(secret.env("NPM_PASS"))
}

option("strict-ssl", "false")
}
}
```

Plugwright writes this to a `.npmrc` next to `package.json` immediately before it runs `npm install`, which covers both the workspace's own dependencies and the runner packages your environments pull in. Without an `npm { }` block no file is written and nothing changes.

<ParamField path="npm" type="Action<NpmSpec>">
Registries the workspace installs from. `registry(url)` sets the default one, `scope("@org", url)` routes a single scope, and `option(key, value)` writes any other npmrc setting verbatim. All three are optional and can appear in any order.
</ParamField>

### Credentials

Credentials are [`SecretRef`](/environments#secrets) values — `secret.env("NPM_TOKEN")`, `secret.file("/run/secrets/npm")`, `secret.systemProperty("npm.token")`. There is deliberately no way to write a literal token: a build script is a file in your repository, and a literal would also end up in the configuration cache.

`authToken(...)` becomes an `_authToken` line. `username(...)` plus `password(...)` become `username` and a base64-encoded `_password`, which is what npm 7 and later expect. A username without a password (or the other way round) is a configuration error and fails the build.

So is a secret that resolves to nothing. An unset `NPM_TOKEN` stops the build before `npm install` runs, with the name of the variable that was empty — rather than several minutes later, with a 404 from the public registry.

### The generated file

The `.npmrc` carries a marker on its first line:

```
# Generated by plugwright - do not edit
# Edit the npm { } block in your build script instead.
registry=https://nexus.corp/repository/npm-group/
@drownek:registry=https://nexus.corp/repository/npm-private/
//nexus.corp/repository/npm-private/:username=ci
//nexus.corp/repository/npm-private/:_password=Y2ktcGFzcw==
strict-ssl=false
```

Only a file carrying that marker is ever overwritten. If the workspace already has an `.npmrc` you wrote yourself, plugwright leaves it alone and warns that the `npm { }` block is being ignored — delete the file to hand the job over. Remove the block from the build script and the generated file is deleted with it, so a registry you stopped declaring stops applying.

The file holds resolved credentials, so it is gitignored: `plugwrightInit` scaffolds a `.gitignore` that lists it, and a workspace created before this existed gets the entry the first time the file is generated. It is written with owner-only permissions where the filesystem supports them.

## Multi-environment options

These live on `plugwright { }` itself, next to `testsDir`.
Expand Down
5 changes: 4 additions & 1 deletion docs/project-layout.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ Everything plugwright needs sits under one directory — `src/test/e2e` unless y
src/test/e2e/
package.json the npm project the runner is installed into
tsconfig.json
.gitignore node_modules, dist, generated
.npmrc generated from npm { }, when the build script has one
.gitignore node_modules, dist, generated, .npmrc
tests/ your specs
shop.spec.ts
plugins/ runner plugins you wrote yourself
Expand All @@ -22,6 +23,8 @@ src/test/e2e/

Three of those directories are disposable: `node_modules`, `dist` and `generated`. Delete any of them and the next `plugwrightTest` recreates it. `plugwrightInit` writes a `.gitignore` covering all three; if you already have one, it appends the lines it needs and leaves the rest alone.

So is the `.npmrc`, when there is one — it is generated from the `npm { }` block before every install and may hold a registry token, which is why it is gitignored too. See [Configuration](/configuration#npm-registries).

## tests

`plugwrightCompileTests` compiles `tests/**/*.ts` into `dist/tests`, keeping subdirectories, and the runner scans the result for `.spec.js`. Group specs into folders however you like — `tests/economy/shop.spec.ts` is fine.
Expand Down
16 changes: 15 additions & 1 deletion docs/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ description: "Start running your first test in less than 5 minutes."
<Tip>
If you already have Node.js installed on your system, you can comment out `downloadNode.set(true)` to speed up initialization. Otherwise, leave it uncommented.
</Tip>

If npm at your company goes through a private registry, add an `npm { }` block now — the next step installs packages, and it will need it:

```kotlin
plugwright {
npm {
registry("https://nexus.corp/repository/npm-group/") {
authToken(secret.env("NPM_TOKEN"))
}
}
}
```

Plugwright writes that to a gitignored `.npmrc` in the workspace before each install. See [Configuration](/configuration#npm-registries).
</Step>

<Step title="Initialization">
Expand All @@ -54,7 +68,7 @@ description: "Start running your first test in less than 5 minutes."
tests/example.spec.ts your specs go here
plugins/example-plugin.ts hooks, fixtures and matchers
package.json, tsconfig.json
.gitignore node_modules, dist, generated
.gitignore node_modules, dist, generated, .npmrc
```

Everything a run generates — the compiled specs, the server the local environment starts — stays inside that directory, under `dist` and `generated`. See [Project Layout](/project-layout).
Expand Down
2 changes: 2 additions & 0 deletions example_plugin/src/test/e2e/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ node_modules/
dist/
# Whatever the environments write while they run: servers, worlds, logs
generated/
# Generated from the npm { } block; may hold registry credentials
.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package me.drownek.plugwright.api

import java.io.Serializable

/**
* Credentials for one registry, as pointers to secrets — never the values.
*
* The values are read when the `.npmrc` is written, during task execution. Reading them
* while the build script is being configured would put them into the configuration cache.
*/
data class NpmCredentials(
val authToken: SecretRef? = null,
val username: SecretRef? = null,
val password: SecretRef? = null
) : Serializable {

val isEmpty: Boolean get() = authToken == null && username == null && password == null

companion object {
private const val serialVersionUID: Long = 1L

val NONE = NpmCredentials()
}
}

/**
* A registry npm should fetch from: the default one, or the one a single scope resolves to.
*
* @param scope npm scope including the leading `@`, e.g. `@drownek`; null for the default registry
* @param url registry URL, e.g. `https://nexus.corp/repository/npm-private/`
*/
data class NpmRegistry(
val scope: String?,
val url: String,
val credentials: NpmCredentials = NpmCredentials.NONE
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}

/**
* What the workspace's generated `.npmrc` should say: which registries to fetch from, how to
* authenticate against them, and any other npm option the build script sets.
*
* Built from the `npm { }` block ([NpmSpec]) and carried into the tasks that run `npm install`.
*/
data class NpmConfig(
val registries: List<NpmRegistry> = emptyList(),
val options: Map<String, String> = emptyMap()
) : Serializable {

/** No `npm { }` block, or an empty one: nothing to generate, and no `.npmrc` to keep. */
val isEmpty: Boolean get() = registries.isEmpty() && options.isEmpty()

/**
* Configuration mistakes worth failing the build over, reported before anything runs
* `npm install` — npm answers a malformed registry line with a 404 against the public
* registry, which is a much longer way round to the same conclusion.
*/
fun problems(): List<String> {
val problems = mutableListOf<String>()

registries.forEach { registry ->
val label = registry.scope?.let { "scope '$it'" } ?: "the default registry"

if (registry.scope != null && !registry.scope.startsWith("@")) {
problems += "npm scope '${registry.scope}' must start with '@'"
}
if (!registry.url.startsWith("http://") && !registry.url.startsWith("https://")) {
problems += "registry URL for $label must start with http:// or https://, got '${registry.url}'"
}

val credentials = registry.credentials
if (credentials.username != null && credentials.password == null) {
problems += "$label has a username but no password"
}
if (credentials.password != null && credentials.username == null) {
problems += "$label has a password but no username"
}
}

val duplicateScopes = registries.groupBy { it.scope }.filterValues { it.size > 1 }.keys
duplicateScopes.forEach { scope ->
problems += scope?.let { "npm scope '$it' is declared more than once" }
?: "the default npm registry is declared more than once"
}

options.keys.filter { it.isBlank() }.forEach { _ ->
problems += "npm option keys cannot be blank"
}

return problems
}

companion object {
private const val serialVersionUID: Long = 1L

val EMPTY = NpmConfig()
}
}

/**
* Credentials for one registry, as a build-script block.
*
* Only [SecretRef]s: a literal token in a build script ends up in the configuration cache,
* in build scans, and — for anyone who forgets what a build script is — in version control.
* Use `secret.env("NPM_TOKEN")`, which is also what a CI job already has.
*/
class NpmCredentialsSpec {
private var authToken: SecretRef? = null
private var username: SecretRef? = null
private var password: SecretRef? = null

/** Bearer token for this registry, written as `_authToken`. */
fun authToken(ref: SecretRef) {
authToken = ref
}

/** Basic-auth user, written as `username`; needs a [password]. */
fun username(ref: SecretRef) {
username = ref
}

/** Basic-auth password, written base64-encoded as `_password`; needs a [username]. */
fun password(ref: SecretRef) {
password = ref
}

internal fun build(): NpmCredentials = NpmCredentials(authToken, username, password)
}

/**
* The `npm { }` block: which registries this workspace installs from.
*
* ```kotlin
* plugwright {
* npm {
* registry("https://nexus.corp/repository/npm-group/") {
* authToken(secret.env("NPM_TOKEN"))
* }
* scope("@drownek", "https://nexus.corp/repository/npm-private/") {
* username(secret.env("NPM_USER"))
* password(secret.env("NPM_PASS"))
* }
* option("strict-ssl", "false")
* }
* }
* ```
*
* The block becomes a `.npmrc` in the workspace root, written just before each `npm install`
* the build runs. It covers the whole workspace rather than one environment: there is one
* `node_modules` and one install for the entire matrix.
*/
class NpmSpec {
private val registries = mutableListOf<NpmRegistry>()
private val options = linkedMapOf<String, String>()

/** The registry every package comes from unless a scope says otherwise. */
@JvmOverloads
fun registry(url: String, action: NpmCredentialsSpec.() -> Unit = {}) {
registries += NpmRegistry(null, url, NpmCredentialsSpec().apply(action).build())
}

/** The registry packages under [scope] (`@drownek`, leading `@` included) come from. */
@JvmOverloads
fun scope(scope: String, url: String, action: NpmCredentialsSpec.() -> Unit = {}) {
registries += NpmRegistry(scope, url, NpmCredentialsSpec().apply(action).build())
}

/**
* Any other npm setting, written verbatim: `option("strict-ssl", "false")`,
* `option("cafile", "/etc/ssl/corp-ca.pem")`.
*/
fun option(key: String, value: String) {
options[key] = value
}

/** Snapshot of the block, for the tasks that write the `.npmrc`. */
fun toConfig(): NpmConfig = NpmConfig(registries.toList(), options.toMap())
}
Loading