From 4028a876f99b4188d3adacf6036bf4c0d508384e Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 16:05:10 +0300 Subject: [PATCH 1/8] feat(gradle): describe npm registries in the build script An npm { } block on the extension names the registries the workspace installs from, per scope where it needs to be, plus any other npmrc option. Credentials are SecretRefs only - a literal token in a build script ends up in the configuration cache and in version control. NpmrcWriter turns the block into the workspace .npmrc. It resolves the secrets at execution time and only ever replaces a file it wrote itself, which the marker on the first line identifies. Nothing calls it yet. --- .../me/drownek/plugwright/api/NpmSpec.kt | 181 ++++++++++++++++++ .../me/drownek/plugwright/NpmrcWriter.kt | 171 +++++++++++++++++ .../drownek/plugwright/PlugwrightExtension.kt | 16 ++ 3 files changed, 368 insertions(+) create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/NpmSpec.kt create mode 100644 gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt 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/NpmrcWriter.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt new file mode 100644 index 0000000..1d0725e --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt @@ -0,0 +1,171 @@ +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." + + /** + * 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) + logger.lifecycle("Wrote ${file.absolutePath} (${summarize(config)})") + } + + /** 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/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. From 7544da74bf9ee26c29b4fa411ff3fa49ed8468b3 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 16:05:18 +0300 Subject: [PATCH 2/8] feat(gradle): write the .npmrc before installing plugwrightCompileTests writes the file just before npm install, so both the dependency install and the runner packages that follow it - same working directory, same npm - fetch from the configured registries. A malformed block (a scope without its @, a registry that is not http) is reported with the rest of the configuration problems rather than by npm 404ing against the public registry several minutes later. --- .../plugwright/PlugwrightCompileTestsTask.kt | 15 +++++++++++++++ .../me/drownek/plugwright/PlugwrightCorePlugin.kt | 2 ++ 2 files changed, 17 insertions(+) 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..0d2f3da 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. From 0062204d1ad8741ed39d61bf7caad19c2e32e3e8 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 16:06:08 +0300 Subject: [PATCH 3/8] feat(init): scaffold a workspace that knows about .npmrc plugwrightInit writes the file before its own npm install: a scaffold that can only reach the public registry is no use to a project that lives behind a private one. The .gitignore template lists .npmrc, and a workspace created before this got one keeps its own file and gains the entry the first time the .npmrc is generated. It may hold a registry token. --- example_plugin/src/test/e2e/.gitignore | 2 ++ .../me/drownek/plugwright/NpmrcWriter.kt | 25 +++++++++++++++++++ .../plugwright/PlugwrightCorePlugin.kt | 5 ++++ .../main/resources/plugwright-init/gitignore | 2 ++ 4 files changed, 34 insertions(+) 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-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/NpmrcWriter.kt index 1d0725e..71bf6fb 100644 --- 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 @@ -29,6 +29,8 @@ internal object NpmrcWriter { 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]. * @@ -58,9 +60,32 @@ internal object NpmrcWriter { 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 } 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 0d2f3da..1458998 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 @@ -345,6 +345,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/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 From ef98b104c9fc40772e1a44d677a77dc029ce76b5 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 16:09:57 +0300 Subject: [PATCH 4/8] docs: document custom npm registries Configuration gets the reference for the npm { } block: the registry and scope calls, credentials as SecretRefs, what the generated .npmrc looks like and when plugwright refuses to touch one. CI/CD gets the token-through-the-environment version, since that is where private registries actually bite. Project layout and quickstart just mention the file, which is gitignored and regenerated per install. --- docs/ci-cd.mdx | 27 ++++++++++++++++++++ docs/configuration.mdx | 56 +++++++++++++++++++++++++++++++++++++++++ docs/project-layout.mdx | 5 +++- docs/quickstart.mdx | 16 +++++++++++- 4 files changed, 102 insertions(+), 2 deletions(-) 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). From 9cd7472729eef4006df71d4101cf4a17241e141b Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 16:26:14 +0300 Subject: [PATCH 5/8] feat(gitignore): update patterns to ignore plugin binaries and add Code Graph index --- .gitignore | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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/ From 0bd09220bfd5f2757ab03cd03a8b4814aa2454d9 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 23:38:50 +0300 Subject: [PATCH 6/8] fix(npm): stop cmd eating the caret in a runner package range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows runs npm through `cmd /c`, where `^` is the escape character, so `@scope/pkg@^1.2.0` reached npm as `@scope/pkg@1.2.0` — an exact version nobody published, reported as ETARGET. Java quotes an argument only for a space or a redirection, so the quoting has to happen here. Runner packages are also deduplicated by package name now. A mode names the package it needs a version of, and the build script names it again, bare, in plugins { npm(...) }; both specs in one install is npm resolving the same package twice, and the bare one asks for a "latest" that a package released under another tag does not have. --- .../me/drownek/plugwright/AbstractNodeTask.kt | 26 +++++++++++++- .../plugwright/PlugwrightCorePlugin.kt | 34 ++++++++++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) 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..9170071 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,7 @@ 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 + listOf("cmd", "/c") + command.map { quoteForCmd(it) } } else { command.toList() } @@ -68,6 +73,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/PlugwrightCorePlugin.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt index 1458998..2948ef7 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 @@ -147,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 @@ -198,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) @@ -217,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) } @@ -247,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" }) @@ -278,6 +297,13 @@ 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. */ + private fun npmPackageNameOf(spec: String): String { + 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 { From bd4d87abeaf028df96e0111c8fb914b08d7e178d Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 24 Aug 2026 15:13:29 +0300 Subject: [PATCH 7/8] fix(npm): don't cut git/URL package specs at the last @ npmPackageNameOf assumed the last @ was always the version separator. git+ssh://git@host/repo and https://user:pass@host/pkg carry @ of their own, so two different URL specs got truncated to the same wrong key and collided in the map. Reported by Drownek on PR #50. --- .../kotlin/me/drownek/plugwright/PlugwrightCorePlugin.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 2948ef7..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 @@ -298,8 +298,11 @@ class PlugwrightCorePlugin : Plugin { } /** `@scope/name@^1.0.0` → `@scope/name`; the version separator is the last `@`, which for - * a scoped package is never the leading one. */ + * 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 } From 494c39f93c577cff13f7c79b0043e241c2f14e40 Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 24 Aug 2026 15:13:36 +0300 Subject: [PATCH 8/8] fix(npm): insert call after cmd /c to stop it eating outer quotes cmd.exe strips the outer pair of quotes from the whole command line when the line starts with a quote and holds more than two quotes total. quoteForCmd now adds quotes to protect ^ in version ranges, so a spaced npm.cmd path (already quoted by Java) plus one quoted argument hits that case and cmd mangles the path. call makes the line start with a letter instead of a quote, which cmd doesn't touch. Reported by Drownek on PR #50. --- .../main/kotlin/me/drownek/plugwright/AbstractNodeTask.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 9170071..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 @@ -49,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.map { quoteForCmd(it) } + // "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() }