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