diff --git a/.gitignore b/.gitignore
index 8f8de63..815d8e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,8 +12,7 @@ gradle-app.setting
!gradle-wrapper.jar
*.class
gradle.properties
-/gradle-plugin/bin/
-/example_plugin/bin/
+**/bin/
# IDE
.idea/
@@ -32,6 +31,9 @@ gradle.properties
Thumbs.db
desktop.ini
+# Code Graph index
+.codegraph/
+
# Logs
*.log
logs/
diff --git a/docs/ci-cd.mdx b/docs/ci-cd.mdx
index b9f3101..c126eef 100644
--- a/docs/ci-cd.mdx
+++ b/docs/ci-cd.mdx
@@ -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.
diff --git a/docs/configuration.mdx b/docs/configuration.mdx
index 448ce30..83b5014 100644
--- a/docs/configuration.mdx
+++ b/docs/configuration.mdx
@@ -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.
+
+
+ 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.
+
+
+### 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`.
diff --git a/docs/project-layout.mdx b/docs/project-layout.mdx
index cd4cf8e..f1a08ba 100644
--- a/docs/project-layout.mdx
+++ b/docs/project-layout.mdx
@@ -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
@@ -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.
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index c7cd12a..94814f6 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -40,6 +40,20 @@ description: "Start running your first test in less than 5 minutes."
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.
+
+ 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).
@@ -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).
diff --git a/example_plugin/src/test/e2e/.gitignore b/example_plugin/src/test/e2e/.gitignore
index 8cedc9f..c2ac073 100644
--- a/example_plugin/src/test/e2e/.gitignore
+++ b/example_plugin/src/test/e2e/.gitignore
@@ -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
diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt
new file mode 100644
index 0000000..6ed6134
--- /dev/null
+++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt
@@ -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 = emptyList(),
+ val options: Map = 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 {
+ val problems = mutableListOf()
+
+ 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()
+ private val options = linkedMapOf()
+
+ /** 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())
+}
diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt
index aefa167..c9f09e8 100644
--- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt
+++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt
@@ -15,6 +15,11 @@ import java.io.File
*/
abstract class AbstractNodeTask : DefaultTask() {
+ private companion object {
+ /** What `cmd /c` acts on rather than hands to the program it runs. */
+ const val CMD_SPECIAL_CHARACTERS = "^&|<>()!%\" \t"
+ }
+
@get:Input
abstract val nodeVersion: Property
@@ -44,7 +49,11 @@ abstract class AbstractNodeTask : DefaultTask() {
val isWindows = System.getProperty("os.name").lowercase().contains("win")
val cmdName = File(command[0]).nameWithoutExtension.lowercase()
val cmd = if (isWindows && (cmdName == "npm" || cmdName == "node")) {
- listOf("cmd", "/c") + command
+ // "call" after /c so the line handed to cmd starts with a letter, not a quote:
+ // when it starts with a quote and holds more than two quotes total (guaranteed
+ // once quoteForCmd wraps an argument), cmd strips the outer pair itself, mangling
+ // a spaced npm.cmd path Java already quoted.
+ listOf("cmd", "/c", "call") + command.map { quoteForCmd(it) }
} else {
command.toList()
}
@@ -68,6 +77,25 @@ abstract class AbstractNodeTask : DefaultTask() {
}
}
+ /**
+ * Makes an argument survive the `cmd /c` in front of it.
+ *
+ * `cmd` re-parses the line it is handed and `^` is its escape character, so an npm range
+ * like `@scope/pkg@^1.2.0` reaches npm as `@scope/pkg@1.2.0` — an exact version nobody
+ * published, reported as "No matching version found". Java quotes an argument only when
+ * it holds a space or a redirection, and `^` is neither, so the quoting that makes it
+ * literal has to happen here.
+ *
+ * An argument already carrying a quote of its own is left alone: it is either quoted
+ * already or means something by it, and Java rejects a quoted argument with a quote
+ * inside outright.
+ */
+ private fun quoteForCmd(argument: String): String = when {
+ argument.none { it in CMD_SPECIAL_CHARACTERS } -> argument
+ argument.contains('"') -> argument
+ else -> "\"$argument\""
+ }
+
protected fun runProcess(
process: Process,
command: Array,
diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt
new file mode 100644
index 0000000..71bf6fb
--- /dev/null
+++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt
@@ -0,0 +1,196 @@
+package me.drownek.plugwright
+
+import me.drownek.plugwright.api.NpmConfig
+import me.drownek.plugwright.api.NpmRegistry
+import me.drownek.plugwright.api.SecretRef
+import org.gradle.api.GradleException
+import org.gradle.api.logging.Logger
+import java.io.File
+import java.io.IOException
+import java.nio.file.Files
+import java.nio.file.attribute.PosixFilePermissions
+import java.util.Base64
+
+/**
+ * Turns the `npm { }` block into the workspace's `.npmrc`, right before something runs
+ * `npm install` in it.
+ *
+ * The file is written at execution time and not a moment earlier: it holds resolved secrets,
+ * and the configuration cache is a file on disk like any other.
+ *
+ * Only a file plugwright wrote itself is ever replaced — the [MARKER] on the first line says
+ * so. A workspace with a hand-written `.npmrc` keeps it, and the build says which one won.
+ */
+internal object NpmrcWriter {
+
+ const val FILE_NAME = ".npmrc"
+
+ const val MARKER = "# Generated by plugwright - do not edit"
+
+ private const val EXPLANATION = "# Edit the npm { } block in your build script instead."
+
+ private const val IGNORE_COMMENT = "# Generated from the npm { } block; may hold registry credentials"
+
+ /**
+ * Brings `/.npmrc` in line with [config].
+ *
+ * An empty config removes a file plugwright generated earlier — a registry that has been
+ * deleted from the build script should stop applying — and leaves everything else alone.
+ */
+ fun write(workspace: File, config: NpmConfig, logger: Logger) {
+ val file = File(workspace, FILE_NAME)
+
+ if (file.exists() && !isGenerated(file)) {
+ if (!config.isEmpty) {
+ logger.warn(
+ "${file.absolutePath} was not written by plugwright, so the npm { } block in the " +
+ "build script is being ignored. Delete the file to let plugwright manage it."
+ )
+ }
+ return
+ }
+
+ if (config.isEmpty) {
+ if (file.exists() && file.delete()) {
+ logger.lifecycle("Removed ${file.absolutePath}: no npm { } block declares a registry anymore")
+ }
+ return
+ }
+
+ file.parentFile?.mkdirs()
+ file.writeText(render(config))
+ restrictPermissions(file)
+ ensureIgnored(workspace, logger)
+ logger.lifecycle("Wrote ${file.absolutePath} (${summarize(config)})")
+ }
+
+ /**
+ * Keeps the file out of version control.
+ *
+ * `plugwrightInit` scaffolds a `.gitignore` that already covers it, but a workspace made
+ * before this existed has one without the entry — and the file it is missing may hold a
+ * registry token.
+ */
+ private fun ensureIgnored(workspace: File, logger: Logger) {
+ val gitignore = File(workspace, ".gitignore")
+ val entry = FILE_NAME
+
+ if (gitignore.exists()) {
+ val present = gitignore.readLines().any { it.trim().trimStart('/') == entry }
+ if (present) return
+ val separator = if (gitignore.readText().endsWith("\n")) "" else "\n"
+ gitignore.appendText("$separator$IGNORE_COMMENT\n$entry\n")
+ } else {
+ gitignore.writeText("$IGNORE_COMMENT\n$entry\n")
+ }
+ logger.lifecycle("Added $entry to ${gitignore.absolutePath}")
+ }
+
+ /** Whether the file is one of ours: the marker is the first thing in it. */
+ private fun isGenerated(file: File): Boolean =
+ file.useLines { lines -> lines.firstOrNull()?.trim() == MARKER }
+
+ // ---- Rendering ---------------------------------------------------------------------
+
+ private fun render(config: NpmConfig): String = buildString {
+ appendLine(MARKER)
+ appendLine(EXPLANATION)
+
+ config.registries.forEach { registry ->
+ val key = registry.scope?.let { "$it:registry" } ?: "registry"
+ appendLine("$key=${registry.url}")
+ }
+
+ config.registries.forEach { registry ->
+ credentialLines(registry).forEach { appendLine(it) }
+ }
+
+ config.options.forEach { (key, value) -> appendLine("$key=$value") }
+ }
+
+ private fun credentialLines(registry: NpmRegistry): List {
+ val credentials = registry.credentials
+ if (credentials.isEmpty) return emptyList()
+
+ val prefix = authKeyPrefix(registry.url)
+ val label = registry.scope?.let { "npm scope '$it'" } ?: "the default npm registry"
+ val lines = mutableListOf()
+
+ credentials.authToken?.let { lines += "$prefix:_authToken=${resolve(it, label)}" }
+ credentials.username?.let { lines += "$prefix:username=${resolve(it, label)}" }
+ credentials.password?.let {
+ val encoded = Base64.getEncoder().encodeToString(resolve(it, label).toByteArray(Charsets.UTF_8))
+ lines += "$prefix:_password=$encoded"
+ }
+ return lines
+ }
+
+ /**
+ * The `//host/path/` npm keys credentials hang off, from a registry URL.
+ *
+ * npm matches these against the registry it is about to talk to, so the path matters:
+ * a Nexus with `/repository/npm-private/` authenticates separately from its sibling
+ * repositories on the same host.
+ */
+ private fun authKeyPrefix(url: String): String =
+ "//" + url.substringAfter("://").trimEnd('/') + "/"
+
+ // ---- Secrets -----------------------------------------------------------------------
+
+ /**
+ * The value behind a [SecretRef], read now rather than at configuration time.
+ *
+ * A secret that resolves to nothing fails the build here, with the name of what was
+ * empty — the alternative is npm answering 401 several minutes into a CI job.
+ */
+ private fun resolve(ref: SecretRef, label: String): String {
+ val (source, value) = when (ref) {
+ is SecretRef.FromEnv -> "environment variable '${ref.name}'" to System.getenv(ref.name)
+ is SecretRef.FromSystemProperty -> "system property '${ref.name}'" to System.getProperty(ref.name)
+ is SecretRef.FromFile -> {
+ val file = File(ref.path)
+ "file '${ref.path}'" to if (file.isFile) file.useLines { it.firstOrNull() } else null
+ }
+ }
+
+ if (value.isNullOrBlank()) {
+ throw GradleException(
+ "The credentials for $label read from $source, which is empty or unset. " +
+ "Set it, or drop the credentials from the npm { } block."
+ )
+ }
+ return value.trim()
+ }
+
+ // ---- Housekeeping ------------------------------------------------------------------
+
+ /** Owner-only, on the systems that have a say in it: the file holds tokens. */
+ private fun restrictPermissions(file: File) {
+ try {
+ Files.setPosixFilePermissions(file.toPath(), PosixFilePermissions.fromString("rw-------"))
+ } catch (_: UnsupportedOperationException) {
+ // Windows: the closest equivalent the java.io API offers.
+ file.setReadable(false, false)
+ file.setReadable(true, true)
+ file.setWritable(false, false)
+ file.setWritable(true, true)
+ } catch (_: IOException) {
+ // Best effort; a file we could write but not chmod is still a working .npmrc.
+ }
+ }
+
+ /** What went into the file, with the secrets left out of the build log. */
+ private fun summarize(config: NpmConfig): String {
+ val parts = mutableListOf()
+
+ config.registries.forEach { registry ->
+ val name = registry.scope ?: "default"
+ val authenticated = if (registry.credentials.isEmpty) "" else ", credentials ***"
+ parts += "$name -> ${registry.url}$authenticated"
+ }
+ if (config.options.isNotEmpty()) {
+ parts += "options: ${config.options.keys.joinToString(", ")}"
+ }
+ return parts.joinToString("; ")
+ }
+}
diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt
index 6518b87..0cd3a75 100644
--- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt
+++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCompileTestsTask.kt
@@ -4,9 +4,11 @@ import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
+import me.drownek.plugwright.api.NpmConfig
import me.drownek.plugwright.api.PlugwrightLayout
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.TaskAction
@@ -43,6 +45,15 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() {
@get:Input
abstract val runnerPackages: ListProperty
+ /**
+ * The registries npm should install from, from the build script's `npm { }` block.
+ *
+ * Holds [me.drownek.plugwright.api.SecretRef]s rather than credentials: the values are
+ * read when the `.npmrc` is written, in [compile].
+ */
+ @get:Input
+ abstract val npmConfig: Property
+
init {
group = "verification"
description = "Install npm dependencies and compile the E2E tests"
@@ -71,6 +82,10 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() {
val nodePaths = resolveNode()
val npmEnv = nodePathEnv(nodePaths)
+ // Before any install: both the dependency install below and the runner packages after
+ // it run with this workspace as their working directory, so one file covers both.
+ NpmrcWriter.write(workspace, npmConfig.getOrElse(NpmConfig.EMPTY), logger)
+
// Install dependencies if needed
if (!File(workspace, "node_modules").exists()) {
logger.lifecycle("Installing Node.js dependencies...")
diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt
index 60c47cd..aecce6f 100644
--- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt
+++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt
@@ -57,6 +57,7 @@ class PlugwrightCorePlugin : Plugin {
testsDir.set(extension.testsDir)
// Filled in once every environment has been wired; empty until then.
runnerPackages.convention(emptyList())
+ npmConfig.set(project.provider { extension.npm.toConfig() })
nodeVersion.set(extension.nodeVersion)
downloadNode.set(extension.downloadNode)
nodeInstallDir.set(defaultNodeInstallDir)
@@ -137,6 +138,7 @@ class PlugwrightCorePlugin : Plugin {
val layout = PlugwrightLayout.of(extension.testsDir.get().asFile)
val projectPluginJarProvider = resolveProjectPluginJar(project, extension)
val validationProblems = mutableListOf()
+ validationProblems += extension.npm.toConfig().problems().map { "[npm] $it" }
val reportsDir = project.layout.buildDirectory.dir("reports/plugwright")
// -Pplugwright.env=a,b narrows the matrix; ignored by direct plugwrightTest calls.
@@ -145,7 +147,26 @@ class PlugwrightCorePlugin : Plugin {
val matrixEntries = mutableListOf()
val matrixPrepareTasks = mutableListOf>()
- val runnerPackageSpecs = linkedSetOf()
+ // Keyed by package name rather than by the whole spec: a mode names a package with the
+ // version it needs, and the same build script names it again — bare — in that
+ // environment's plugins { npm(...) }. Both specs in one `npm install` is a package
+ // asked for twice at two different versions, and the bare one resolves "latest",
+ // which a package released only under another tag does not have.
+ val runnerPackageSpecs = linkedMapOf()
+ fun addRunnerPackage(spec: String) {
+ val name = npmPackageNameOf(spec)
+ val existing = runnerPackageSpecs[name]
+ when {
+ // A version beats no version; between two versions the mode's comes first and
+ // wins, because it is the half that knows what its own export needs.
+ existing == null || existing == name -> runnerPackageSpecs[name] = spec
+ spec == name || spec == existing -> Unit
+ else -> project.logger.warn(
+ "plugwright: $name is asked for as both '$existing' and '$spec'. Installing " +
+ "'$existing'; drop the version from one of them to say which you meant."
+ )
+ }
+ }
extension.environments.all.forEach { entry ->
val envName = entry.spec.name
@@ -196,7 +217,7 @@ class PlugwrightCorePlugin : Plugin {
// Merged across environments so the whole matrix is covered by one install.
modePackages.forEach { ref ->
- runnerPackageSpecs += if (ref.version != null) "${ref.name}@${ref.version}" else ref.name
+ addRunnerPackage(if (ref.version != null) "${ref.name}@${ref.version}" else ref.name)
}
val validation = ValidationContextImpl(envName, project.logger)
@@ -215,7 +236,7 @@ class PlugwrightCorePlugin : Plugin {
pluginConfigsProvider.get()
.map { it.specifier }
.filter { isNpmPackageName(it) }
- .forEach { runnerPackageSpecs += it }
+ .forEach { addRunnerPackage(it) }
testTask.configure {
ctx.prepareTaskRef?.let { dependsOn(it) }
@@ -245,7 +266,7 @@ class PlugwrightCorePlugin : Plugin {
}
}
- plugwrightCompileTests.configure { runnerPackages.set(runnerPackageSpecs.toList()) }
+ plugwrightCompileTests.configure { runnerPackages.set(runnerPackageSpecs.values.toList()) }
if (validationProblems.isNotEmpty()) {
throw GradleException("plugwright configuration problems:\n" + validationProblems.joinToString("\n") { " $it" })
@@ -276,6 +297,16 @@ class PlugwrightCorePlugin : Plugin {
return ref.copy(specifier = File(layout.compiledPluginsDir, "$name.js").absolutePath)
}
+ /** `@scope/name@^1.0.0` → `@scope/name`; the version separator is the last `@`, which for
+ * a scoped package is never the leading one. A git/URL spec (`git+ssh://git@host/repo`,
+ * `https://user:pass@registry/pkg`) carries its own `@`s that aren't a version separator
+ * at all, so it is returned as-is instead of being cut at the last one. */
+ private fun npmPackageNameOf(spec: String): String {
+ if (spec.contains("://") || spec.startsWith("git+")) return spec
+ val separator = spec.lastIndexOf('@')
+ return if (separator > 0) spec.substring(0, separator) else spec
+ }
+
/** Whether a plugin specifier names an npm package rather than a file in the project.
* Paths are what `plugins { local(file(...)) }` produces; everything else is installable. */
private fun isNpmPackageName(specifier: String): Boolean {
@@ -343,6 +374,11 @@ class PlugwrightCorePlugin : Plugin {
writeIfAbsent(project, layout.testsDir.resolve("example.spec.ts"), initTemplate("example.spec.ts"))
writeIfAbsent(project, layout.pluginsDir.resolve("example-plugin.ts"), initTemplate("example-plugin.ts"))
+ // The install below is the first one this workspace runs, so it needs the
+ // registries too — a scaffold that can only reach the public registry is no
+ // use to a project that lives behind a private one.
+ NpmrcWriter.write(targetDir, extension.npm.toConfig(), project.logger)
+
project.logger.lifecycle("Executing 'npm install' in ${targetDir.absolutePath}...")
val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get())
diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt
index 7cf9d0d..b6adc04 100644
--- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt
+++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightExtension.kt
@@ -1,6 +1,7 @@
package me.drownek.plugwright
import me.drownek.plugwright.api.LegacyEnvironmentProperties
+import me.drownek.plugwright.api.NpmSpec
import me.drownek.plugwright.api.PlugwrightMode
import me.drownek.plugwright.api.RunDirFile
import org.gradle.api.Project
@@ -58,6 +59,21 @@ abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperti
matrix.action()
}
+ /** Registries the workspace installs from, and the credentials for them. See [npm]. */
+ val npm: NpmSpec = NpmSpec()
+
+ /**
+ * Configures npm itself: `npm { registry("https://nexus.corp/repository/npm-group/") }`.
+ *
+ * The block covers the whole workspace rather than one environment — there is one
+ * `node_modules` and one install behind the entire matrix. It is written to a `.npmrc`
+ * next to `package.json` before each install; without a block, no file is written and
+ * npm keeps using whatever the machine already configures.
+ */
+ fun npm(action: NpmSpec.() -> Unit) {
+ npm.action()
+ }
+
// ---- Deprecated flat properties --------------------------------------------------
// Pre-3.0 shape: describes a single implicit "local" environment. Still read whenever
// the build script has no environments { } block — see PlugwrightMode.applyLegacyDefaults.
diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore
index 8cedc9f..921a22a 100644
--- a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore
+++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore
@@ -1,5 +1,7 @@
# Installed by plugwrightCompileTests
node_modules/
+# Generated from the npm { } block; may hold registry credentials
+.npmrc
# Compiled specs and plugins
dist/
# Whatever the environments write while they run: servers, worlds, logs