From bd5ce7de7ed5303ed6ed6402ad0aee347380537c Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 14:37:20 +0300 Subject: [PATCH 1/7] feat(gradle): give the workspace a fixed directory layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specs live in tests/, runner plugins in plugins/, and everything an environment writes at run time goes to generated// — so a local server lands under the workspace instead of a run/ directory next to build.gradle.kts, and two local environments in one matrix stop sharing a server directory. A workspace still holding its specs at the root is moved into tests/ the first time plugwrightCompileTests runs, tsconfig.json included: its include still points at where the specs used to be. plugins { local("stand-reset") } names a plugin instead of spelling out the path its compiled form ends up at. --- .../me/drownek/plugwright/api/PluginRef.kt | 8 + .../me/drownek/plugwright/api/PluginsSpec.kt | 12 +- .../plugwright/api/PlugwrightLayout.kt | 78 +++++++++ .../drownek/plugwright/api/PlugwrightMode.kt | 10 ++ .../plugwright/api/TaskRegistrationContext.kt | 7 +- .../plugwright/PlugwrightCompileTestsTask.kt | 157 ++++++++++++++++-- .../plugwright/PlugwrightCorePlugin.kt | 22 ++- .../drownek/plugwright/PlugwrightExtension.kt | 9 +- .../plugwright/PlugwrightMatrixTask.kt | 8 +- .../drownek/plugwright/PlugwrightTestTask.kt | 22 ++- .../me/drownek/plugwright/RunnerLauncher.kt | 17 +- .../plugwright/TaskRegistrationContextImpl.kt | 2 + .../external/PlugwrightCleanupTask.kt | 2 +- .../plugwright/external/PlugwrightPingTask.kt | 2 +- .../me/drownek/plugwright/local/LocalMode.kt | 9 + 15 files changed, 319 insertions(+), 46 deletions(-) create mode 100644 gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.kt diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt index 6e216bf..a3a40a3 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginRef.kt @@ -16,5 +16,13 @@ data class PluginRef @JvmOverloads constructor( ) : Serializable { companion object { private const val serialVersionUID: Long = 1L + + /** + * Marks a [specifier] that names a plugin in the workspace's `plugins` directory + * rather than an npm package or a path — what `plugins { local("stand-reset") }` + * produces. The build resolves it against [PlugwrightLayout.compiledPluginsDir] + * before the config is written, so the runner only ever sees a real path. + */ + const val WORKSPACE_SCHEME: String = "plugwright-workspace:" } } diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt index f4274dd..37f86b1 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PluginsSpec.kt @@ -32,7 +32,17 @@ class PluginsSpec { entries.add(PluginRef(specifier, spec.options, spec.inheritTests)) } - /** A plugin living as a file in the test project, e.g. under `src/test/e2e`. */ + /** + * A plugin written in the workspace's `plugins` directory, named without its extension: + * `local("stand-reset")` loads what `plugins/stand-reset.ts` compiles into. + */ + fun local(name: String, action: PluginRefSpec.() -> Unit = {}) { + val spec = PluginRefSpec().apply(action) + entries.add(PluginRef(PluginRef.WORKSPACE_SCHEME + name, spec.options, spec.inheritTests)) + } + + /** A plugin at a path of your own choosing. Prefer [local] with a name: it follows the + * workspace layout, so the path stops being something the build script has to know. */ fun local(file: File, action: PluginRefSpec.() -> Unit = {}) { val spec = PluginRefSpec().apply(action) entries.add(PluginRef(file.absolutePath, spec.options, spec.inheritTests)) diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.kt new file mode 100644 index 0000000..625eb12 --- /dev/null +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightLayout.kt @@ -0,0 +1,78 @@ +package me.drownek.plugwright.api + +import java.io.File + +/** + * The directories inside a plugwright workspace — the directory `plugwright.testsDir` points + * at, `src/test/e2e` by default. + * + * ``` + * src/test/e2e/ + * tests/ spec sources + * plugins/ runner plugin sources + * dist/ compiled output, mirroring the two directories above + * generated// whatever an environment writes while it runs + * ``` + * + * Everything under `dist`, `generated` and `node_modules` is disposable: the build recreates + * it, and `plugwrightInit` writes a `.gitignore` that keeps all three out of version control. + * + * A mode reads the layout through [TaskRegistrationContext.layout], and gets a chance to seed + * spec defaults from it in [PlugwrightMode.applyLayoutDefaults]. + */ +interface PlugwrightLayout { + + /** Root of the npm project: the value of `plugwright.testsDir`. */ + val workspaceDir: File + + /** Spec sources, `/tests`. */ + val testsDir: File + + /** Runner plugin sources, `/plugins`. */ + val pluginsDir: File + + /** Compiled output root, `/dist`. */ + val compiledDir: File + + /** Compiled specs, `/dist/tests`. */ + val compiledTestsDir: File + + /** Compiled runner plugins, `/dist/plugins`. */ + val compiledPluginsDir: File + + /** Root of the per-environment scratch space, `/generated`. */ + val generatedRootDir: File + + /** Where environment [environmentName] writes what it generates: `/generated/`. + * The local mode puts its server here; nothing else may write outside its own directory. */ + fun generatedDir(environmentName: String): File + + /** + * The directory the runner scans for `.spec.js`: the compiled one once it exists, and the + * sources otherwise — a workspace of plain JavaScript specs has nothing to compile. + */ + fun runnableTestsDir(): File + + companion object { + const val TESTS_DIR_NAME = "tests" + const val PLUGINS_DIR_NAME = "plugins" + const val COMPILED_DIR_NAME = "dist" + const val GENERATED_DIR_NAME = "generated" + + /** The layout of the workspace rooted at [workspaceDir]. */ + fun of(workspaceDir: File): PlugwrightLayout = DefaultPlugwrightLayout(workspaceDir) + } +} + +private class DefaultPlugwrightLayout(override val workspaceDir: File) : PlugwrightLayout { + override val testsDir: File get() = File(workspaceDir, PlugwrightLayout.TESTS_DIR_NAME) + override val pluginsDir: File get() = File(workspaceDir, PlugwrightLayout.PLUGINS_DIR_NAME) + override val compiledDir: File get() = File(workspaceDir, PlugwrightLayout.COMPILED_DIR_NAME) + override val compiledTestsDir: File get() = File(compiledDir, PlugwrightLayout.TESTS_DIR_NAME) + override val compiledPluginsDir: File get() = File(compiledDir, PlugwrightLayout.PLUGINS_DIR_NAME) + override val generatedRootDir: File get() = File(workspaceDir, PlugwrightLayout.GENERATED_DIR_NAME) + + override fun generatedDir(environmentName: String): File = File(generatedRootDir, environmentName) + + override fun runnableTestsDir(): File = if (compiledTestsDir.exists()) compiledTestsDir else testsDir +} diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt index 9763180..1e21937 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/PlugwrightMode.kt @@ -34,6 +34,16 @@ interface PlugwrightMode { */ fun applyLegacyDefaults(spec: S, legacy: LegacyEnvironmentProperties) {} + /** + * Fills in whatever [spec] leaves unset that follows from the workspace layout, before + * validation and [registerTasks] run. The local mode places its server this way, so a + * build script that never mentions `runDir` still gets one, under + * `/generated/`. + * + * Only set properties the build script did not: an explicit value always wins. + */ + fun applyLayoutDefaults(spec: S, layout: PlugwrightLayout) {} + /** * Writes the mode-specific part of the runner config, landing under * `environment.config`. Runs at configuration time, so secrets stay [SecretRef]s. diff --git a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt index 01e8c03..8c6ffb9 100644 --- a/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt +++ b/gradle-plugin/plugwright-api/src/main/kotlin/me/drownek/plugwright/api/TaskRegistrationContext.kt @@ -29,9 +29,14 @@ interface TaskRegistrationContext { */ val projectPluginJar: Provider - /** Directory the runner scans for spec files, same value `plugwrightTest` uses. */ + /** Root of the npm project (`plugwright.testsDir`), same value `plugwrightTest` + * uses as its working directory. For the directories inside it, use [layout]. */ val testsDir: Provider + /** Directory conventions of the workspace, including where this environment may write + * what it generates: `layout.generatedDir(environmentName)`. */ + val layout: PlugwrightLayout + /** * Registers a task named `plugwright`, e.g. `plugwrightProvisionLocal` * for `register("Provision", …)` in the `local` environment. 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 253bd84..ef05a62 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 @@ -1,23 +1,39 @@ package me.drownek.plugwright +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.stream.JsonReader +import me.drownek.plugwright.api.PlugwrightLayout import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.TaskAction import java.io.File +import java.io.StringReader /** - * Installs the test project's npm dependencies and compiles its TypeScript. + * Installs the workspace's npm dependencies and compiles its TypeScript. + * + * Sources live in two directories — `tests` for specs, `plugins` for runner plugins — and + * `tsc` mirrors both into `dist`. A workspace still holding its specs at the root (the layout + * before `tests` existed) is moved into place the first time this task runs. * * Split out of the test task so several environments share one install and one `tsc` * run instead of paying for them per environment. */ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { - @get:InputDirectory - @get:Optional + /** + * Root of the npm project: `plugwright.testsDir`. + * + * Not declared as an input directory: the task never reports itself up to date, and the + * workspace holds `node_modules` and a running server's `generated` directory — fingerprinting + * either of those costs seconds and decides nothing. + */ + @get:Internal abstract val testsDir: DirectoryProperty /** @@ -39,45 +55,154 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { @TaskAction fun compile() { - val userTestsDirectory = if (testsDir.isPresent) { + val workspace = if (testsDir.isPresent) { testsDir.get().asFile } else { logger.warn("Tests directory not configured") return } - if (!userTestsDirectory.exists()) { - logger.warn("Tests directory does not exist: ${userTestsDirectory.absolutePath}") + if (!workspace.exists()) { + logger.warn("Tests directory does not exist: ${workspace.absolutePath}") return } + val layout = PlugwrightLayout.of(workspace) + migrateRootLevelSpecs(layout) + val nodePaths = resolveNode() val npmEnv = nodePathEnv(nodePaths) // Install dependencies if needed - if (!File(userTestsDirectory, "node_modules").exists()) { + if (!File(workspace, "node_modules").exists()) { logger.lifecycle("Installing Node.js dependencies...") - runCommand(userTestsDirectory, nodePaths.npm, "install", env = npmEnv) + runCommand(workspace, nodePaths.npm, "install", env = npmEnv) } - installMissingRunnerPackages(userTestsDirectory, nodePaths, npmEnv) + installMissingRunnerPackages(workspace, nodePaths, npmEnv) // Build TypeScript tests if tsconfig.json exists - val tsconfigFile = File(userTestsDirectory, "tsconfig.json") + val tsconfigFile = File(workspace, "tsconfig.json") if (tsconfigFile.exists()) { logger.lifecycle("TypeScript config found, compiling tests...") - runCommand(userTestsDirectory, nodePaths.npm, "run", "build", env = npmEnv) + runCommand(workspace, nodePaths.npm, "run", "build", env = npmEnv) } else { logger.lifecycle("No TypeScript config found, running JavaScript tests directly") } } + // ---- Migration ------------------------------------------------------------------- + + /** + * Moves a workspace laid out the old way — specs anywhere under the root — into `tests`. + * + * Runs only while there is no `tests` directory at all, so it happens once and never + * touches a workspace that already follows the layout. The `tsconfig.json` goes along + * with the files: its `include` still describes where the specs used to be. + */ + private fun migrateRootLevelSpecs(layout: PlugwrightLayout) { + if (layout.testsDir.exists()) return + + val strays = findSpecSources(layout.workspaceDir, layout) + if (strays.isEmpty()) return + + strays.forEach { source -> + val destination = File(layout.testsDir, source.relativeTo(layout.workspaceDir).path) + destination.parentFile.mkdirs() + if (!source.renameTo(destination)) { + source.copyTo(destination, overwrite = true) + source.delete() + } + } + removeEmptyDirectories(layout.workspaceDir, layout) + + logger.lifecycle( + "Moved ${strays.size} spec file(s) into ${layout.testsDir.absolutePath} — " + + "plugwright looks for specs under 'tests' now." + ) + retargetTsConfig(layout) + } + + /** Spec files outside the directories the layout owns; empty for a workspace that has + * already been migrated or was created by `plugwrightInit`. */ + private fun findSpecSources(directory: File, layout: PlugwrightLayout): List { + val children = directory.listFiles() ?: return emptyList() + return children.flatMap { child -> + when { + child.isDirectory && isIgnoredDirectory(child, layout) -> emptyList() + child.isDirectory -> findSpecSources(child, layout) + child.name.endsWith(".spec.ts") || child.name.endsWith(".spec.js") -> listOf(child) + else -> emptyList() + } + } + } + + private fun isIgnoredDirectory(directory: File, layout: PlugwrightLayout): Boolean = + directory.name == "node_modules" || directory.name == ".git" || + directory == layout.compiledDir || directory == layout.generatedRootDir || + directory == layout.pluginsDir || directory == layout.testsDir + + private fun removeEmptyDirectories(directory: File, layout: PlugwrightLayout) { + val children = directory.listFiles() ?: return + children.filter { it.isDirectory && !isIgnoredDirectory(it, layout) }.forEach { child -> + removeEmptyDirectories(child, layout) + if (child.list()?.isEmpty() == true) child.delete() + } + } + + /** + * Points a migrated workspace's `tsconfig.json` at the directories the sources now live + * in, and at the `dist` that mirrors them. + * + * A config the parser chokes on (comments are legal in `tsconfig.json`, and JSON says + * otherwise) is left alone with an explanation — a rewrite that drops the comments is a + * worse outcome than an edit by hand. + */ + private fun retargetTsConfig(layout: PlugwrightLayout) { + val tsconfigFile = File(layout.workspaceDir, "tsconfig.json") + if (!tsconfigFile.exists()) return + + val config = try { + JsonParser.parseReader(JsonReader(StringReader(tsconfigFile.readText())).apply { isLenient = true }) + .asJsonObject + } catch (e: Exception) { + logger.warn( + "Could not update ${tsconfigFile.absolutePath} (${e.message}). Point its \"include\" at " + + "\"tests/**/*.ts\" and \"plugins/**/*.ts\" by hand." + ) + return + } + + val compilerOptions = config.getAsJsonObject("compilerOptions") ?: JsonObject().also { + config.add("compilerOptions", it) + } + compilerOptions.addProperty("rootDir", ".") + compilerOptions.addProperty("outDir", "./${PlugwrightLayout.COMPILED_DIR_NAME}") + config.add("include", jsonArrayOf( + "${PlugwrightLayout.TESTS_DIR_NAME}/**/*.ts", + "${PlugwrightLayout.PLUGINS_DIR_NAME}/**/*.ts", + )) + config.add("exclude", jsonArrayOf( + "node_modules", + PlugwrightLayout.COMPILED_DIR_NAME, + PlugwrightLayout.GENERATED_DIR_NAME, + )) + + tsconfigFile.writeText(GsonBuilder().setPrettyPrinting().create().toJson(config) + "\n") + logger.lifecycle("Updated ${tsconfigFile.absolutePath} for the new layout") + } + + private fun jsonArrayOf(vararg values: String): JsonArray = + JsonArray().apply { values.forEach { add(it) } } + + // ---- npm ------------------------------------------------------------------------- + private fun installMissingRunnerPackages( - testsDirectory: File, + workspace: File, nodePaths: NodeManager.NodePaths, npmEnv: Map ) { - val nodeModules = File(testsDirectory, "node_modules") + val nodeModules = File(workspace, "node_modules") val missing = runnerPackages.get().filterNot { spec -> File(nodeModules, packageNameOf(spec)).exists() } @@ -87,7 +212,7 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { try { // --no-save: these come from the build script's environments, so the test project's // package.json shouldn't grow a second, drifting copy of the same decision. - runCommand(testsDirectory, nodePaths.npm, "install", "--no-save", *missing.toTypedArray(), env = npmEnv) + runCommand(workspace, nodePaths.npm, "install", "--no-save", *missing.toTypedArray(), env = npmEnv) } catch (e: Exception) { // A package that can't be installed is not a reason to stop compiling the tests: // only the environment that asked for it is affected, and the runner reports the 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 22c99b9..c6263ef 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 @@ -1,6 +1,8 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNodeBuilder +import me.drownek.plugwright.api.PluginRef +import me.drownek.plugwright.api.PlugwrightLayout import org.gradle.api.GradleException import org.gradle.api.plugins.ExtensionAware import org.gradle.api.Plugin @@ -132,6 +134,7 @@ class PlugwrightCorePlugin : Plugin { ) } + val layout = PlugwrightLayout.of(extension.testsDir.get().asFile) val projectPluginJarProvider = resolveProjectPluginJar(project, extension) val validationProblems = mutableListOf() val reportsDir = project.layout.buildDirectory.dir("reports/plugwright") @@ -147,9 +150,12 @@ class PlugwrightCorePlugin : Plugin { extension.environments.all.forEach { entry -> val envName = entry.spec.name val mode = entry.mode.erased() + // Before validation and registerTasks: a mode fills in what it can derive from + // the layout here, and both of those already expect a complete spec. + mode.applyLayoutDefaults(entry.spec, layout) val ctx = TaskRegistrationContextImpl( project, envName, envName == primaryName, projectPluginJarProvider, - extension.testsDir.map { it.asFile }, extension, defaultNodeInstallDir + extension.testsDir.map { it.asFile }, layout, extension, defaultNodeInstallDir ) val journalFilePath = project.layout.buildDirectory.file("plugwright/$envName-journal.jsonl").get().asFile val modePackages = mode.runnerPackages(entry.spec) @@ -201,8 +207,8 @@ class PlugwrightCorePlugin : Plugin { val environmentConfigProvider = ctx.environmentConfigProvider ?: project.provider { ConfigNodeBuilder().apply { mode.serialize(entry.spec, this) }.build() } - val pluginConfigsProvider = ctx.pluginConfigsProvider - ?: project.provider { emptyList() } + val pluginConfigsProvider = (ctx.pluginConfigsProvider ?: project.provider { emptyList() }) + .map { refs -> refs.map { resolveWorkspacePlugin(it, layout) } } // A plugin declared by npm name is installed alongside the environment's own // runner packages; a plugin given as a path is already in the project. @@ -223,7 +229,7 @@ class PlugwrightCorePlugin : Plugin { name = envName, modeId = mode.id, allowFailure = entry.spec.allowFailure.get(), - testsDir = extension.testsDir.get().asFile, + workspaceDir = layout.workspaceDir, configFile = project.layout.buildDirectory.file("tmp/plugwright/$envName.json").get().asFile, jsonReportFile = File(reportsDirFile, "$envName.json"), junitReportFile = File(File(reportsDirFile, "junit"), "$envName.xml"), @@ -262,6 +268,14 @@ class PlugwrightCorePlugin : Plugin { } } + /** Turns `plugins { local("stand-reset") }` into the path the compiler writes it to. + * Anything else — an npm name, a path the build script spelled out — passes through. */ + private fun resolveWorkspacePlugin(ref: PluginRef, layout: PlugwrightLayout): PluginRef { + if (!ref.specifier.startsWith(PluginRef.WORKSPACE_SCHEME)) return ref + val name = ref.specifier.removePrefix(PluginRef.WORKSPACE_SCHEME) + return ref.copy(specifier = File(layout.compiledPluginsDir, "$name.js").absolutePath) + } + /** 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 { 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 5413963..7cf9d0d 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 @@ -73,10 +73,13 @@ abstract class PlugwrightExtension(project: Project) : LegacyEnvironmentProperti @Deprecated("Use environments { create(\"local\", LocalMode) { acceptEula.set(...) } }") override val acceptEula: Property = project.objects.property(Boolean::class.java).convention(true) + /** + * Left unset on purpose: an absent value is what tells the local mode to place the server + * under `/generated//run`. Setting it here is still honoured, and + * still means "this exact directory". + */ @Deprecated("Use environments { create(\"local\", LocalMode) { runDir.set(...) } }") - override val runDir: DirectoryProperty = project.objects.directoryProperty().convention( - project.layout.projectDirectory.dir("run") - ) + override val runDir: DirectoryProperty = project.objects.directoryProperty() @Deprecated("Use environments { create(\"local\", LocalMode) { cleanExcludePatterns.set(...) } }") override val cleanExcludePatterns: ListProperty = project.objects.listProperty(String::class.java).convention( diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt index 895a219..ad3e9fa 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightMatrixTask.kt @@ -19,7 +19,7 @@ internal data class MatrixEnvironmentInput( val name: String, val modeId: String, val allowFailure: Boolean, - val testsDir: File, + val workspaceDir: File, val configFile: File, val jsonReportFile: File, val junitReportFile: File, @@ -117,7 +117,7 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { environmentName = env.name, modeId = env.modeId, environmentConfig = env.environmentConfig.get(), - testsDir = env.testsDir, + workspaceDir = env.workspaceDir, configFile = env.configFile, testFiles = fileFilters, testNames = nameFilters, @@ -130,10 +130,10 @@ abstract class PlugwrightMatrixTask : AbstractNodeTask() { runtimeExport = env.runtimeExport, ) RunnerLauncher.writeConfig(entry) - val cliJs = RunnerLauncher.resolveCliJs(env.testsDir) + val cliJs = RunnerLauncher.resolveCliJs(env.workspaceDir) runCommand( - env.testsDir, nodePaths.node, cliJs.absolutePath, "--config", entry.configFile.absolutePath, + env.workspaceDir, nodePaths.node, cliJs.absolutePath, "--config", entry.configFile.absolutePath, onStdoutLine = { line -> env.logFile.appendText(line + System.lineSeparator()) } ) Outcome(env, readSummary(env.jsonReportFile), null) diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt index 1cfb18e..0578217 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/PlugwrightTestTask.kt @@ -18,8 +18,14 @@ import java.io.File */ abstract class PlugwrightTestTask : AbstractNodeTask() { - @get:InputDirectory - @get:Optional + /** + * Root of the npm project: `plugwright.testsDir`. The runner is pointed at the compiled + * specs inside it — see [RunnerLauncher.writeConfig]. + * + * Not an input directory, for the same reason as in [PlugwrightCompileTestsTask]: this + * task always runs, and the workspace now contains the server's own generated files. + */ + @get:Internal abstract val testsDir: DirectoryProperty @get:Input @@ -94,15 +100,15 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { fun runTests() { val nodePaths = resolveNode() - val userTestsDirectory = if (testsDir.isPresent) { + val workspace = if (testsDir.isPresent) { testsDir.get().asFile } else { logger.warn("Tests directory not configured") return } - if (!userTestsDirectory.exists()) { - logger.warn("Tests directory does not exist: ${userTestsDirectory.absolutePath}") + if (!workspace.exists()) { + logger.warn("Tests directory does not exist: ${workspace.absolutePath}") return } @@ -113,7 +119,7 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { environmentName = environmentName.get(), modeId = modeId.get(), environmentConfig = environmentConfig.get(), - testsDir = userTestsDirectory, + workspaceDir = workspace, configFile = configDestination, testFiles = with(RunnerLauncher) { testFiles.orNull.splitFilter() }, testNames = with(RunnerLauncher) { testNames.orNull.splitFilter() }, @@ -128,9 +134,9 @@ abstract class PlugwrightTestTask : AbstractNodeTask() { RunnerLauncher.writeConfig(entry) logger.lifecycle("Runner config: ${configDestination.absolutePath}") - val cliJsFile = RunnerLauncher.resolveCliJs(userTestsDirectory) + val cliJsFile = RunnerLauncher.resolveCliJs(workspace) - runCommand(userTestsDirectory, nodePaths.node, cliJsFile.absolutePath, "--config", configDestination.absolutePath) + runCommand(workspace, nodePaths.node, cliJsFile.absolutePath, "--config", configDestination.absolutePath) logger.lifecycle("E2E tests completed successfully") } diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt index 36349ec..31abd16 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/RunnerLauncher.kt @@ -3,6 +3,7 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNode import me.drownek.plugwright.api.ConfigNodeBuilder import me.drownek.plugwright.api.PluginRef +import me.drownek.plugwright.api.PlugwrightLayout import org.gradle.api.GradleException import java.io.File @@ -21,7 +22,9 @@ object RunnerLauncher { val environmentName: String, val modeId: String, val environmentConfig: ConfigNode, - val testsDir: File, + /** Root of the npm project. The directory the runner actually scans is derived from + * it — see [PlugwrightLayout.runnableTestsDir]. */ + val workspaceDir: File, val configFile: File, val testFiles: List?, val testNames: List?, @@ -57,7 +60,7 @@ object RunnerLauncher { put("config", entry.environmentConfig) } obj("tests") { - put("dir", entry.testsDir.absolutePath) + put("dir", PlugwrightLayout.of(entry.workspaceDir).runnableTestsDir().absolutePath) if (entry.testFiles != null) putStrings("include", entry.testFiles) else putNull("include") if (entry.testNames != null) putStrings("names", entry.testNames) else putNull("names") if (entry.excludeTests.isNotEmpty()) putStrings("exclude", entry.excludeTests) else putNull("exclude") @@ -89,20 +92,20 @@ object RunnerLauncher { RunnerConfigWriter.write(entry.configFile, root) } - /** Resolves `cli.js` relative to a test project's `node_modules`, falling back to the + /** Resolves `cli.js` relative to the workspace's `node_modules`, falling back to the * in-repo build for `example_plugin`-style development setups. */ - fun resolveCliJs(testsDir: File): File { - val defaultCliJs = File(testsDir, "node_modules/@drownek/plugwright/dist/cli.js") + fun resolveCliJs(workspaceDir: File): File { + val defaultCliJs = File(workspaceDir, "node_modules/@drownek/plugwright/dist/cli.js") return sequenceOf( // Canonical path resolves npm symlink bugs on CI defaultCliJs.canonicalFile, defaultCliJs, // Dev-environment fallback when running inside this repository - File(testsDir, "../../../../runner-package/dist/cli.js") + File(workspaceDir, "../../../../runner-package/dist/cli.js") ).firstOrNull { it.exists() } ?: throw GradleException( "plugwright cli.js not found at ${defaultCliJs.absolutePath}. " + - "Did 'npm install' succeed in ${testsDir.absolutePath}?" + "Did 'npm install' succeed in ${workspaceDir.absolutePath}?" ) } diff --git a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt index 4fae8ff..e4bb5e3 100644 --- a/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt +++ b/gradle-plugin/plugwright-core/src/main/kotlin/me/drownek/plugwright/TaskRegistrationContextImpl.kt @@ -2,6 +2,7 @@ package me.drownek.plugwright import me.drownek.plugwright.api.ConfigNode import me.drownek.plugwright.api.PluginRef +import me.drownek.plugwright.api.PlugwrightLayout import me.drownek.plugwright.api.TaskRegistrationContext import org.gradle.api.Project import org.gradle.api.Task @@ -20,6 +21,7 @@ internal class TaskRegistrationContextImpl( private val isPrimary: Boolean, override val projectPluginJar: Provider, override val testsDir: Provider, + override val layout: PlugwrightLayout, private val extension: PlugwrightExtension, private val nodeInstallDir: File ) : TaskRegistrationContext { diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt index 9a01f8b..849f9b3 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightCleanupTask.kt @@ -52,7 +52,7 @@ abstract class PlugwrightCleanupTask : AbstractNodeTask() { environmentName = environmentName.get(), modeId = modeId.get(), environmentConfig = environmentConfig.get(), - testsDir = userTestsDirectory, + workspaceDir = userTestsDirectory, configFile = configFile.get().asFile, testFiles = null, testNames = null, diff --git a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt index c141a80..85933e5 100644 --- a/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt +++ b/gradle-plugin/plugwright-external/src/main/kotlin/me/drownek/plugwright/external/PlugwrightPingTask.kt @@ -48,7 +48,7 @@ abstract class PlugwrightPingTask : AbstractNodeTask() { environmentName = environmentName.get(), modeId = modeId.get(), environmentConfig = environmentConfig.get(), - testsDir = userTestsDirectory, + workspaceDir = userTestsDirectory, configFile = configFile.get().asFile, testFiles = null, testNames = null, diff --git a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt index 861436b..d673876 100644 --- a/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt +++ b/gradle-plugin/plugwright-local/src/main/kotlin/me/drownek/plugwright/local/LocalMode.kt @@ -3,6 +3,7 @@ package me.drownek.plugwright.local import me.drownek.plugwright.api.ConfigNode import me.drownek.plugwright.api.ConfigNodeBuilder import me.drownek.plugwright.api.LegacyEnvironmentProperties +import me.drownek.plugwright.api.PlugwrightLayout import me.drownek.plugwright.api.PlugwrightMode import me.drownek.plugwright.api.RunnerPackageRef import me.drownek.plugwright.api.TaskRegistrationContext @@ -38,6 +39,14 @@ object LocalMode : PlugwrightMode { } } + /** The server lives under the workspace, in this environment's own generated directory, + * unless the build script named a directory itself. */ + override fun applyLayoutDefaults(spec: LocalEnvironmentSpec, layout: PlugwrightLayout) { + if (!spec.runDir.isPresent) { + spec.runDir.set(File(layout.generatedDir(spec.name), "run")) + } + } + override fun applyLegacyDefaults(spec: LocalEnvironmentSpec, legacy: LegacyEnvironmentProperties) { spec.minecraftVersion.set(legacy.minecraftVersion) spec.jvmArgs.set(legacy.jvmArgs) From 3b9fa83ced4ad664ffff5514329d97578bfc5c7c Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 14:41:34 +0300 Subject: [PATCH 2/7] feat(init): scaffold the layout, gitignore included plugwrightInit now creates tests/example.spec.ts, plugins/example-plugin.ts and a .gitignore covering node_modules, dist and generated. An existing .gitignore gets the missing lines appended rather than replaced. The scaffolded files live in resources as real .ts/.json files instead of Kotlin string literals, so an editor checks them and nothing needs escaping. The runner dependency follows the plugin's own version instead of a range last updated by hand. --- .../plugwright/PlugwrightCorePlugin.kt | 139 +++++++++--------- .../plugwright-init/example-plugin.ts | 34 +++++ .../resources/plugwright-init/example.spec.ts | 6 + .../main/resources/plugwright-init/gitignore | 6 + .../resources/plugwright-init/package.json | 14 ++ .../resources/plugwright-init/tsconfig.json | 26 ++++ 6 files changed, 155 insertions(+), 70 deletions(-) create mode 100644 gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts create mode 100644 gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts create mode 100644 gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore create mode 100644 gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json create mode 100644 gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json 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 c6263ef..60c47cd 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 @@ -336,76 +336,12 @@ class PlugwrightCorePlugin : Plugin { throw GradleException("IO ERROR: Failed to create target directory: ${targetDir.absolutePath}. Check your file permissions.") } - val packageJson = targetDir.resolve("package.json") - if (!packageJson.exists()) { - packageJson.writeText( - """ - { - "type": "module", - "scripts": { - "build": "rimraf dist && tsc" - }, - "dependencies": { - "@drownek/plugwright": "^2.0.3" - }, - "devDependencies": { - "@types/node": "^22.10.5", - "rimraf": "^6.1.3", - "typescript": "^5.7.3" - } - } - """.trimIndent() - ) - project.logger.lifecycle("Created: ${packageJson.absolutePath}") - } - - val tsconfigJson = targetDir.resolve("tsconfig.json") - if (!tsconfigJson.exists()) { - tsconfigJson.writeText( - """ - { - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "node", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": ".", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": false, - "sourceMap": true - }, - "include": [ - "*.spec.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] - } - """.trimIndent() - ) - project.logger.lifecycle("Created: ${tsconfigJson.absolutePath}") - } - - val testFile = targetDir.resolve("example.spec.ts") - if (!testFile.exists()) { - testFile.writeText( - """ - import {expect, test} from '@drownek/plugwright'; - - test('help displays message', async ({ player, server }) => { - player.chat('/help'); - await expect(player).toHaveReceivedMessage('Help'); - }); - """.trimIndent() - ) - project.logger.lifecycle("Created: ${testFile.absolutePath}") - } + val layout = PlugwrightLayout.of(targetDir) + writeGitignore(project, targetDir) + writeIfAbsent(project, targetDir.resolve("package.json"), initTemplate("package.json")) + writeIfAbsent(project, targetDir.resolve("tsconfig.json"), initTemplate("tsconfig.json")) + writeIfAbsent(project, layout.testsDir.resolve("example.spec.ts"), initTemplate("example.spec.ts")) + writeIfAbsent(project, layout.pluginsDir.resolve("example-plugin.ts"), initTemplate("example-plugin.ts")) project.logger.lifecycle("Executing 'npm install' in ${targetDir.absolutePath}...") val nodePaths = NodeManager.getOrDownloadNode(defaultNodeInstallDir, extension.nodeVersion.get(), extension.downloadNode.get()) @@ -431,6 +367,9 @@ class PlugwrightCorePlugin : Plugin { } project.logger.lifecycle("Dependencies installed successfully.") project.logger.lifecycle("\nYou're all set! Run tests with: ./gradlew plugwrightTest") + project.logger.lifecycle( + "To load the example plugin, add plugins { local(\"example-plugin\") } to an environment." + ) } catch (e: Exception) { if (e is GradleException) throw e throw GradleException("EXEC FATAL: Failed to launch npm process. Original error: ${e.message}", e) @@ -438,4 +377,64 @@ class PlugwrightCorePlugin : Plugin { } } } + + private fun writeIfAbsent(project: Project, file: File, content: String) { + if (file.exists()) return + file.parentFile?.mkdirs() + file.writeText(content) + project.logger.lifecycle("Created: ${file.absolutePath}") + } + + /** + * One of the files `plugwrightInit` scaffolds, from `src/main/resources/plugwright-init`. + * + * They are real `.ts` / `.json` files rather than string literals in here, so an editor + * checks them and nothing has to be escaped past the Kotlin parser. `@runnerVersion@` is + * the only placeholder. + */ + private fun initTemplate(name: String): String { + val stream = PlugwrightCorePlugin::class.java.getResourceAsStream("/plugwright-init/$name") + ?: throw GradleException("plugwright is missing its '$name' template. Reinstall the plugin.") + return stream.bufferedReader().use { it.readText() } + .replace("@runnerVersion@", runnerVersionRange()) + } + + /** + * Keeps the three generated directories out of version control. + * + * Appends to a `.gitignore` that is already there rather than replacing it: the workspace + * may well have entries of its own, and none of them are this task's to decide about. + */ + private fun writeGitignore(project: Project, workspaceDir: File) { + val gitignore = File(workspaceDir, ".gitignore") + val template = initTemplate("gitignore") + + if (!gitignore.exists()) { + gitignore.writeText(template) + project.logger.lifecycle("Created: ${gitignore.absolutePath}") + return + } + + val required = template.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") } + val present = gitignore.readLines().map { it.trim().trimEnd('/') }.toSet() + val missing = required.filter { it.trimEnd('/') !in present } + if (missing.isEmpty()) return + + val separator = if (gitignore.readText().endsWith("\n")) "" else "\n" + gitignore.appendText(separator + missing.joinToString("\n", postfix = "\n")) + project.logger.lifecycle("Added ${missing.joinToString(", ")} to ${gitignore.absolutePath}") + } + + /** + * npm range for the runner that goes with this plugin: `2.0.4-dev.0` asks for `^2.0.0`. + * + * The runner and the plugin are released together, so the plugin's own version is the + * right thing to derive from — but only down to the minor. A pre-release plugin names a + * patch npm has never seen, and `^2.0.0` resolves to the newest 2.x either way. + */ + private fun runnerVersionRange(): String { + val match = Regex("""^(\d+)\.(\d+)\.""").find(Banner.pluginVersion()) ?: return "latest" + val (major, minor) = match.destructured + return "^$major.$minor.0" + } } diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts new file mode 100644 index 0000000..41e7e9b --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example-plugin.ts @@ -0,0 +1,34 @@ +import { definePlugin } from '@drownek/plugwright'; + +/** + * A runner plugin: hooks that run around every test, plus fixtures the tests can + * destructure. Load it by adding this to the environment in build.gradle.kts: + * + * plugins { local("example-plugin") } + * + * The name is the file name — plugwright compiles plugins/example-plugin.ts into + * dist/plugins/example-plugin.js and points the runner at that. + */ +export default definePlugin({ + name: 'example-plugin', + + // Runs before every test, with the bot already connected. + async beforeEach({ player, server }) { + // An environment without a console has no way to run commands, and says so. + if (!server.session.env.capabilities.console) return; + await server.executeAndWait(`minecraft:gamemode survival ${player.username}`); + }, + + // What this returns becomes part of the object every test destructures: + // test('...', async ({ player, say }) => { ... }) + extendContext({ player }) { + return { say: (message: string) => player.chat(message) }; + }, +}); + +// Without this block the fixture still works and TypeScript still complains. +declare module '@drownek/plugwright' { + interface TestContext { + say: (message: string) => void; + } +} diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts new file mode 100644 index 0000000..7c57f24 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/example.spec.ts @@ -0,0 +1,6 @@ +import {expect, test} from '@drownek/plugwright'; + +test('help displays message', async ({ player, server }) => { + player.chat('/help'); + await expect(player).toHaveReceivedMessage('Help'); +}); diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore new file mode 100644 index 0000000..8cedc9f --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/gitignore @@ -0,0 +1,6 @@ +# Installed by plugwrightCompileTests +node_modules/ +# Compiled specs and plugins +dist/ +# Whatever the environments write while they run: servers, worlds, logs +generated/ diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json new file mode 100644 index 0000000..b24a898 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/package.json @@ -0,0 +1,14 @@ +{ + "type": "module", + "scripts": { + "build": "rimraf dist && tsc" + }, + "dependencies": { + "@drownek/plugwright": "@runnerVersion@" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "rimraf": "^6.1.3", + "typescript": "^5.7.3" + } +} diff --git a/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json new file mode 100644 index 0000000..a591734 --- /dev/null +++ b/gradle-plugin/plugwright-core/src/main/resources/plugwright-init/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": [ + "tests/**/*.ts", + "plugins/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "generated" + ] +} From 0c293a5933394d44892305420b50fb0c6e0b79be Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 14:49:19 +0300 Subject: [PATCH 3/7] refactor(example): move the example onto the new layout Specs go to tests/, the server the local environment starts goes to generated/local/run, and the stand-reset plugin is named rather than pointed at through dist/. npm cannot install a package linked by path while that package has a prepare script: it packs the directory into a staging copy without dev dependencies, so the build there fails and takes the whole install with it. The three local packages are built up front instead, by the same two scripts CI now runs. --- .github/workflows/ci.yml | 9 +++++---- .gitignore | 6 ++---- auth-authme-package/package-lock.json | 3 +++ auth-authme-package/package.json | 1 - console-rcon-package/package-lock.json | 3 +++ console-rcon-package/package.json | 1 - example_plugin/build.gradle.kts | 10 ++++++---- example_plugin/src/test/e2e/.gitignore | 6 ++++++ .../src/test/e2e/{ => tests}/commands.spec.ts | 0 .../src/test/e2e/{ => tests}/describe.spec.ts | 0 .../src/test/e2e/{ => tests}/economy.spec.ts | 0 example_plugin/src/test/e2e/{ => tests}/events.spec.ts | 0 example_plugin/src/test/e2e/{ => tests}/kits.spec.ts | 0 .../src/test/e2e/{ => tests}/minigame.spec.ts | 0 .../src/test/e2e/{ => tests}/multi-bot.spec.ts | 0 .../src/test/e2e/{ => tests}/pagination.spec.ts | 0 .../src/test/e2e/{ => tests}/player-wrapper.spec.ts | 0 example_plugin/src/test/e2e/{ => tests}/shop.spec.ts | 0 .../src/test/e2e/{ => tests}/simple-ts.spec.ts | 0 .../src/test/e2e/{ => tests}/teleport.spec.ts | 0 example_plugin/src/test/e2e/tsconfig.json | 6 ++++-- package.json | 6 ++++-- runner-package/package-lock.json | 3 +++ runner-package/package.json | 1 - 24 files changed, 36 insertions(+), 19 deletions(-) create mode 100644 example_plugin/src/test/e2e/.gitignore rename example_plugin/src/test/e2e/{ => tests}/commands.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/describe.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/economy.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/events.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/kits.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/minigame.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/multi-bot.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/pagination.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/player-wrapper.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/shop.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/simple-ts.spec.ts (100%) rename example_plugin/src/test/e2e/{ => tests}/teleport.spec.ts (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be881fa..2bb8373 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Build runner-package + # The example links all three by path, and npm cannot build a linked package: it packs + # the directory into a staging copy that never gets its dev dependencies. + - name: Build the local npm packages run: | - cd runner-package - npm install - npm run build + npm run install:packages + npm run build:packages - uses: drownek/plugwright-action@v1 with: java-version: "17" diff --git a/.gitignore b/.gitignore index 9df230c..8f8de63 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,8 @@ logs/ *.tsbuildinfo dist/ -# Test server runtime +# Test server runtime — plugwright writes it under /generated/ +generated/ run/ test-server/ **/server.properties @@ -62,9 +63,6 @@ test-server/ **/spigot.jar server.jar -# Compiled test files -**/e2e/dist/ - # Temporary files *.tmp *.temp diff --git a/auth-authme-package/package-lock.json b/auth-authme-package/package-lock.json index 51e9f33..15a0e5f 100644 --- a/auth-authme-package/package-lock.json +++ b/auth-authme-package/package-lock.json @@ -32,6 +32,9 @@ "picocolors": "^1.1.1", "source-map-support": "^0.5.21" }, + "bin": { + "plugwright": "dist/cli.js" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.5", diff --git a/auth-authme-package/package.json b/auth-authme-package/package.json index 3a8f25e..0a92837 100644 --- a/auth-authme-package/package.json +++ b/auth-authme-package/package.json @@ -7,7 +7,6 @@ "types": "dist/index.d.ts", "scripts": { "build": "rimraf dist && tsc", - "prepare": "npm run build", "prepublishOnly": "npm run build", "watch": "tsc --watch", "typecheck": "tsc --noEmit" diff --git a/console-rcon-package/package-lock.json b/console-rcon-package/package-lock.json index eb1a967..c14af36 100644 --- a/console-rcon-package/package-lock.json +++ b/console-rcon-package/package-lock.json @@ -32,6 +32,9 @@ "picocolors": "^1.1.1", "source-map-support": "^0.5.21" }, + "bin": { + "plugwright": "dist/cli.js" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.5", diff --git a/console-rcon-package/package.json b/console-rcon-package/package.json index a182928..40f3621 100644 --- a/console-rcon-package/package.json +++ b/console-rcon-package/package.json @@ -7,7 +7,6 @@ "types": "dist/index.d.ts", "scripts": { "build": "rimraf dist && tsc", - "prepare": "npm run build", "prepublishOnly": "npm run build", "watch": "tsc --watch", "typecheck": "tsc --noEmit" diff --git a/example_plugin/build.gradle.kts b/example_plugin/build.gradle.kts index 22e402d..d1e9668 100644 --- a/example_plugin/build.gradle.kts +++ b/example_plugin/build.gradle.kts @@ -29,7 +29,8 @@ plugwright { create("local", LocalMode) { minecraftVersion.set("1.21.11") acceptEula.set(true) - runDir.set(file("run")) + // No runDir: the server goes to src/test/e2e/generated/local/run, which is where + // the layout puts what an environment generates. // start.sh is the hand-written launcher the "stand" environment connects to; it // lives in the run directory and has to survive the clean that precedes each run. @@ -90,7 +91,8 @@ plugwright { } // The same tests against a server plugwright does not own: started by hand from - // ./run, still up when the tests connect, still up after they finish. Out of the + // src/test/e2e/generated/local/run, still up when the tests connect, still up after + // they finish (the local environment left it there). Out of the // default matrix because it needs that server to be running. create("stand", ExternalMode) { host.set("localhost") @@ -120,8 +122,8 @@ plugwright { plugins { npm("@plugwright/auth-authme") - // Compiled output of src/test/e2e/plugins/stand-reset.ts. - local(file("src/test/e2e/dist/plugins/stand-reset.js")) + // src/test/e2e/plugins/stand-reset.ts, by the name of the file. + local("stand-reset") } // Matched against test names. What is left out here is what the stand cannot give diff --git a/example_plugin/src/test/e2e/.gitignore b/example_plugin/src/test/e2e/.gitignore new file mode 100644 index 0000000..8cedc9f --- /dev/null +++ b/example_plugin/src/test/e2e/.gitignore @@ -0,0 +1,6 @@ +# Installed by plugwrightCompileTests +node_modules/ +# Compiled specs and plugins +dist/ +# Whatever the environments write while they run: servers, worlds, logs +generated/ diff --git a/example_plugin/src/test/e2e/commands.spec.ts b/example_plugin/src/test/e2e/tests/commands.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/commands.spec.ts rename to example_plugin/src/test/e2e/tests/commands.spec.ts diff --git a/example_plugin/src/test/e2e/describe.spec.ts b/example_plugin/src/test/e2e/tests/describe.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/describe.spec.ts rename to example_plugin/src/test/e2e/tests/describe.spec.ts diff --git a/example_plugin/src/test/e2e/economy.spec.ts b/example_plugin/src/test/e2e/tests/economy.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/economy.spec.ts rename to example_plugin/src/test/e2e/tests/economy.spec.ts diff --git a/example_plugin/src/test/e2e/events.spec.ts b/example_plugin/src/test/e2e/tests/events.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/events.spec.ts rename to example_plugin/src/test/e2e/tests/events.spec.ts diff --git a/example_plugin/src/test/e2e/kits.spec.ts b/example_plugin/src/test/e2e/tests/kits.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/kits.spec.ts rename to example_plugin/src/test/e2e/tests/kits.spec.ts diff --git a/example_plugin/src/test/e2e/minigame.spec.ts b/example_plugin/src/test/e2e/tests/minigame.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/minigame.spec.ts rename to example_plugin/src/test/e2e/tests/minigame.spec.ts diff --git a/example_plugin/src/test/e2e/multi-bot.spec.ts b/example_plugin/src/test/e2e/tests/multi-bot.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/multi-bot.spec.ts rename to example_plugin/src/test/e2e/tests/multi-bot.spec.ts diff --git a/example_plugin/src/test/e2e/pagination.spec.ts b/example_plugin/src/test/e2e/tests/pagination.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/pagination.spec.ts rename to example_plugin/src/test/e2e/tests/pagination.spec.ts diff --git a/example_plugin/src/test/e2e/player-wrapper.spec.ts b/example_plugin/src/test/e2e/tests/player-wrapper.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/player-wrapper.spec.ts rename to example_plugin/src/test/e2e/tests/player-wrapper.spec.ts diff --git a/example_plugin/src/test/e2e/shop.spec.ts b/example_plugin/src/test/e2e/tests/shop.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/shop.spec.ts rename to example_plugin/src/test/e2e/tests/shop.spec.ts diff --git a/example_plugin/src/test/e2e/simple-ts.spec.ts b/example_plugin/src/test/e2e/tests/simple-ts.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/simple-ts.spec.ts rename to example_plugin/src/test/e2e/tests/simple-ts.spec.ts diff --git a/example_plugin/src/test/e2e/teleport.spec.ts b/example_plugin/src/test/e2e/tests/teleport.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/teleport.spec.ts rename to example_plugin/src/test/e2e/tests/teleport.spec.ts diff --git a/example_plugin/src/test/e2e/tsconfig.json b/example_plugin/src/test/e2e/tsconfig.json index ca67432..68a0509 100644 --- a/example_plugin/src/test/e2e/tsconfig.json +++ b/example_plugin/src/test/e2e/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "node", + "rootDir": ".", "outDir": "./dist", "strict": true, "esModuleInterop": true, @@ -10,5 +11,6 @@ "sourceMap": true, "inlineSources": true }, - "include": ["*.spec.ts", "plugins/*.ts"] -} \ No newline at end of file + "include": ["tests/**/*.ts", "plugins/**/*.ts"], + "exclude": ["node_modules", "dist", "generated"] +} diff --git a/package.json b/package.json index 0de69f6..9653ab4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,8 @@ { "private": true, "scripts": { - "bump": "node scripts/bump-version.js" + "bump": "node scripts/bump-version.js", + "install:packages": "npm install --prefix runner-package && npm install --prefix auth-authme-package && npm install --prefix console-rcon-package", + "build:packages": "npm run build --prefix runner-package && npm run build --prefix auth-authme-package && npm run build --prefix console-rcon-package" } -} \ No newline at end of file +} diff --git a/runner-package/package-lock.json b/runner-package/package-lock.json index e021a78..0fd1f73 100644 --- a/runner-package/package-lock.json +++ b/runner-package/package-lock.json @@ -14,6 +14,9 @@ "picocolors": "^1.1.1", "source-map-support": "^0.5.21" }, + "bin": { + "plugwright": "dist/cli.js" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.5", diff --git a/runner-package/package.json b/runner-package/package.json index 7032c9a..4593c44 100644 --- a/runner-package/package.json +++ b/runner-package/package.json @@ -10,7 +10,6 @@ }, "scripts": { "build": "rimraf dist && tsc", - "prepare": "npm run build", "prepublishOnly": "npm run build", "watch": "tsc --watch", "typecheck": "tsc --noEmit" From 8b31c29927659935a92e9667192c551e977ee4fe Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 16 Aug 2026 14:52:36 +0300 Subject: [PATCH 4/7] docs: document the workspace layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a page describing the directories — tests, plugins, dist, generated — and what happens to a project still laid out the old way. The pages that showed runDir, a path to a compiled plugin, or a spec at the root of testsDir now show the current shape instead. --- README.md | 15 ++++++-- docs/configuration.mdx | 21 +++++----- docs/custom-modes.mdx | 16 ++++++++ docs/docs.json | 1 + docs/environments.mdx | 3 +- docs/plugins.mdx | 6 +-- docs/project-layout.mdx | 82 ++++++++++++++++++++++++++++++++++++++++ docs/quickstart.mdx | 15 ++++++-- docs/writing-tests.mdx | 2 +- example_plugin/README.md | 22 ++++++----- 10 files changed, 149 insertions(+), 34 deletions(-) create mode 100644 docs/project-layout.mdx diff --git a/README.md b/README.md index 4629518..da8600f 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,22 @@ plugwright { **2. Initialize the test folder:** -Run the init command to set up your test folder. -This will automatically generate your package.json, TypeScript configuration, and an example test in a chosen directory. +Run the init command to set up your test folder. It asks where to put it, then writes an npm project with a `package.json`, a TypeScript config, a `.gitignore`, an example spec and an example runner plugin: -This command is interactive, so simply follow the prompts on your screen: ```bash ./gradlew plugwrightInit ``` +``` +src/test/e2e/ + 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 +``` + +Compiled specs land in `dist`, and everything an environment writes — the Paper server the local one starts, for instance — in `generated`. Neither belongs in version control. See [Project Layout](https://plugwright.dev/project-layout). + **3. Run your tests:** ```bash @@ -131,6 +139,7 @@ plugwright { `./gradlew plugwrightTest` runs the matrix and prints a summary per environment; `./gradlew plugwrightTestStaging` runs one. A server behind a login wall needs a runner plugin to get past it, and `@plugwright/auth-authme` is the reference implementation for AuthMe-style login. Writing your own kind of environment — a proxy, a Compose stack — is a Kotlin mode plus an npm package. +- [Project layout](https://plugwright.dev/project-layout) — where specs, plugins and generated files live - [Environments](https://plugwright.dev/environments) — modes, tasks, the matrix - [External servers](https://plugwright.dev/external-servers) — console channels, account pools, cleanup - [Runner plugins](https://plugwright.dev/plugins) — hooks, fixtures, matchers, inherited tests diff --git a/docs/configuration.mdx b/docs/configuration.mdx index bbe5ac4..448ce30 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -18,7 +18,6 @@ plugwright { environments { create("local", LocalMode) { minecraftVersion.set("1.21.11") - runDir.set(file("run")) acceptEula.set(true) } } @@ -40,10 +39,11 @@ plugwright { // ---- Server Configuration ---- // Version of Paper server to download and run minecraftVersion.set("1.19.4") - - // Directory where the test server will be located - runDir.set(file("run")) - + + // Where the test server lives. Leave it out and it goes to + // /generated//run + // runDir.set(file("/mnt/fast-disk/paper")) + // Automatically accept the Minecraft EULA acceptEula.set(true) @@ -86,21 +86,20 @@ minecraftVersion.set("1.20.1") ``` - Directory where the test server will be located. Default is `project.layout.projectDirectory.dir("run")`. + Directory where the test server will be located. Unset by default, which puts it in `/generated//run` — set it only to keep the server somewhere else. See [Project Layout](/project-layout). ```kotlin -runDir.set(file("run")) -runDir.set(file("test-server")) +runDir.set(file("/mnt/fast-disk/paper")) ``` - Directory containing test files. Default is `file("src/test/e2e")`. + Root of the test workspace: the npm project, the `tests` and `plugins` sources, and the `dist` and `generated` directories the build writes. Default is `file("src/test/e2e")`. ```kotlin testsDir.set(file("src/test/e2e")) -testsDir.set(file("tests/integration")) +testsDir.set(file("e2e")) ``` @@ -237,7 +236,7 @@ Per-environment, inside `create(...) { }`: - Runner plugins this environment loads: `npm("@scope/name") { options["key"] = "value" }` for a published package, `local(file("…"))` for a compiled file in your test project. See [Runner Plugins](/plugins). + Runner plugins this environment loads: `npm("@scope/name") { options["key"] = "value" }` for a published package, `local("name")` for one of your own in the workspace's `plugins` directory. See [Runner Plugins](/plugins). ```kotlin diff --git a/docs/custom-modes.mdx b/docs/custom-modes.mdx index b422cc2..6a43ecb 100644 --- a/docs/custom-modes.mdx +++ b/docs/custom-modes.mdx @@ -90,6 +90,22 @@ What each piece is for: - `serialize` writes `environment.config` at configuration time. Secrets stay `SecretRef`s here — `node.put("password", spec.password.get())` writes a reference, not a password. - `registerTasks` adds tasks named `plugwright`, so `register("Up", ...)` in an environment called `proxy` gives `plugwrightUpProxy`. `prepareTask` marks the one that has to run before the tests do. +### Files your mode generates + +Anything written while an environment runs belongs under `ctx.layout.generatedDir(ctx.environmentName)` — `src/test/e2e/generated/proxy` for the mode above. That directory is gitignored and is yours alone; no other environment writes there. + +If the spec has a property for it, fill the default in `applyLayoutDefaults` rather than in the property's convention. It runs before validation, only for properties the build script left unset, so an explicit value in the build script still wins: + +```kotlin +override fun applyLayoutDefaults(spec: VelocityEnvironmentSpec, layout: PlugwrightLayout) { + if (!spec.workDir.isPresent) { + spec.workDir.set(File(layout.generatedDir(spec.name), "compose")) + } +} +``` + +`PlugwrightLayout` also knows where the sources and the compiled output are: `testsDir`, `pluginsDir`, `compiledTestsDir`, `compiledPluginsDir`. See [Project Layout](/project-layout). + Preparation belongs in a task rather than a callback. A callback executed inside someone else's `@TaskAction` drags your mode object into that task's state, breaks the configuration cache, and can never be run on its own. A task with declared inputs and outputs gets up-to-date checks and a name someone can type. If a config value needs something only a task can reach — the Java toolchain, a Gradle service — set it from `registerTasks` with `ctx.environmentConfig(provider)` instead of from `serialize`. That is what `LocalMode` does for the Java executable path. diff --git a/docs/docs.json b/docs/docs.json index 55dccec..08d7e9b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -20,6 +20,7 @@ "pages": [ "introduction", "quickstart", + "project-layout", "configuration" ] }, diff --git a/docs/environments.mdx b/docs/environments.mdx index d9f8251..78f5ccf 100644 --- a/docs/environments.mdx +++ b/docs/environments.mdx @@ -26,7 +26,6 @@ plugwright { create("local", LocalMode) { minecraftVersion.set("1.21.11") acceptEula.set(true) - runDir.set(file("run")) } create("staging", ExternalMode) { @@ -38,7 +37,7 @@ plugwright { } ``` -The name you pass to `create` becomes the task suffix and the report file name: `local` gives you `plugwrightTestLocal` and `build/reports/plugwright/local.json`. +The name you pass to `create` becomes the task suffix and the report file name: `local` gives you `plugwrightTestLocal` and `build/reports/plugwright/local.json`. It also names the directory the environment writes to — `src/test/e2e/generated/local`, where the Paper server for that environment ends up. Two local environments in one build therefore run two separate servers without either one saying where. See [Project Layout](/project-layout). A build script with no `environments { }` block still works. The flat properties (`minecraftVersion`, `runDir`, `downloadPlugins`, and the rest) describe one implicit `local` environment, exactly as they did before. See [Configuration](/configuration). diff --git a/docs/plugins.mdx b/docs/plugins.mdx index 9e33281..a9dc070 100644 --- a/docs/plugins.mdx +++ b/docs/plugins.mdx @@ -13,14 +13,14 @@ create("staging", ExternalMode) { npm("@plugwright/auth-authme") { options["loginCommand"] = "/log" } - local(file("src/test/e2e/dist/plugins/staging.js")) { + local("staging") { inheritTests = false } } } ``` -`npm(...)` names a published package, installed by `plugwrightCompileTests` along with the rest of the environment's packages. `local(...)` points at a compiled file in your own test project. Options are plain strings — anything secret belongs in `accounts { }`, where it stays a secret reference. +`npm(...)` names a published package, installed by `plugwrightCompileTests` along with the rest of the environment's packages. `local(...)` names a plugin of your own: `local("staging")` is `plugins/staging.ts` in the test workspace, compiled to `dist/plugins/staging.js` by the same `tsc` run as your specs. For a plugin that lives outside the workspace there is still `local(file("..."))`. Options are plain strings — anything secret belongs in `accounts { }`, where it stays a secret reference. `LocalMode` takes the same block. A local server running an authentication plugin needs the login hook exactly as much as a remote one does. @@ -82,7 +82,7 @@ tests: [ `preflight` tests run before any user spec and abort the run when they fail — there is no point testing a shop when nobody can log in. `suite` tests run alongside your own and are tagged with the plugin's name in the report. -Spec discovery skips `node_modules`, so this is the only way a packaged test ever runs. Per-plugin, `inheritTests = false` loads the hooks and matchers without the tests. +Spec discovery only looks at your own compiled `tests` directory, so this is the only way a packaged test ever runs. Per-plugin, `inheritTests = false` loads the hooks and matchers without the tests. ## Fixtures diff --git a/docs/project-layout.mdx b/docs/project-layout.mdx new file mode 100644 index 0000000..cd4cf8e --- /dev/null +++ b/docs/project-layout.mdx @@ -0,0 +1,82 @@ +--- +title: "Project Layout" +description: "Where the specs, the plugins and the generated files live." +--- + +Everything plugwright needs sits under one directory — `src/test/e2e` unless you point `testsDir` somewhere else. It is an npm project, so `package.json` and `node_modules` are there too: + +``` +src/test/e2e/ + package.json the npm project the runner is installed into + tsconfig.json + .gitignore node_modules, dist, generated + tests/ your specs + shop.spec.ts + plugins/ runner plugins you wrote yourself + stand-reset.ts + dist/ compiled output, mirroring tests/ and plugins/ + generated/ what the environments write while they run + local/run/ the Paper server the local environment starts + node_modules/ +``` + +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. + +## 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. + +A workspace of plain JavaScript needs no compile step. Without a `tsconfig.json` the runner reads `tests/` directly. + +## plugins + +Runner plugins — hooks, fixtures, matchers, inherited tests — go in `plugins/`, one file each, and compile into `dist/plugins`. A plugin is loaded by name: + +```kotlin +plugins { + local("stand-reset") // plugins/stand-reset.ts +} +``` + +`local(file(...))` still takes a path, for a plugin that lives somewhere else entirely. See [Runner Plugins](/plugins). + +## generated + +Each environment gets its own directory under `generated/`, named after it. The local environment puts its Paper server in `generated//run`: the jar, the worlds, the logs, the plugins it downloaded. Two local environments in one matrix therefore never share a server directory. + +You can still choose the directory yourself, and an explicit value always wins: + +```kotlin +environments { + create("local", LocalMode) { + runDir.set(file("/mnt/fast-disk/paper")) + } +} +``` + +The `stand` in the [example project](https://github.com/Drownek/plugwright/tree/master/example_plugin) shows why the default is convenient: an external environment can point at the very server the local one left behind, because there is only one place it could be. + +## Moving the whole thing + +`testsDir` is the root of all of this: + +```kotlin +plugwright { + testsDir.set(file("e2e")) +} +``` + +Then the specs are in `e2e/tests`, the server in `e2e/generated/local/run`, and so on. + +## Migrating from the old layout + +Before this layout, specs sat directly in `testsDir` and the local server went to a `run/` directory next to `build.gradle.kts`. The move is mostly automatic — the first `plugwrightCompileTests` after upgrading moves every spec it finds into `tests/`, subdirectories intact, and rewrites `tsconfig.json` so `include` points at the new place. It logs both. + +Four things are worth checking by hand afterwards: + +1. **Your `.gitignore`.** `run/` no longer needs an entry. `generated/` inside the workspace does — run `plugwrightInit` again to have the lines appended, or add them yourself. +2. **`runDir`.** A build script that sets it keeps that exact directory. Drop the line to get `generated//run` instead, and move the server there if you want to keep the downloaded jar and the worlds. +3. **Local plugins.** `local(file("src/test/e2e/dist/plugins/x.js"))` becomes `local("x")` once the source is in `plugins/`. +4. **A `tsconfig.json` with comments.** JSON with comments is legal in a `tsconfig` and unparseable as JSON, so plugwright leaves such a file untouched and says so. Point `include` at `tests/**/*.ts` and `plugins/**/*.ts` yourself. + +If you would rather do the move by hand, `git mv` the specs into `tests/` before upgrading. The migration only runs while there is no `tests/` directory at all. diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index c053b19..c7cd12a 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -43,14 +43,21 @@ description: "Start running your first test in less than 5 minutes." - Run the init command to set up your test folder. - This will automatically generate your package.json, TypeScript configuration, and an example test in a chosen directory. - - This command is interactive, so simply follow the prompts on your screen: + Run the init command to set up your test folder. It asks where to put it, then writes an npm project with a `package.json`, a TypeScript config, a `.gitignore`, an example spec and an example runner plugin: ```bash ./gradlew plugwrightInit ``` + + ``` + src/test/e2e/ + 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 + ``` + + 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/docs/writing-tests.mdx b/docs/writing-tests.mdx index 9a8fcbb..8d59a41 100644 --- a/docs/writing-tests.mdx +++ b/docs/writing-tests.mdx @@ -17,7 +17,7 @@ test('test description', async ({ player }) => { ## Your First Test -Create `src/test/e2e/first.spec.ts`: +Create `src/test/e2e/tests/first.spec.ts` — specs live in `tests`, in whatever subdirectories you like ([Project Layout](/project-layout)): ```typescript import { test, expect } from '@drownek/plugwright'; diff --git a/example_plugin/README.md b/example_plugin/README.md index f5b2c0f..30f425d 100644 --- a/example_plugin/README.md +++ b/example_plugin/README.md @@ -10,21 +10,21 @@ The same 47 tests run against two environments, declared in `build.gradle.kts`. ./gradlew plugwrightTest ``` -Downloads Paper into `run/`, installs PlaceholderAPI and AuthMe next to the plugin under test, writes an AuthMe config a bot can get through, starts the server, runs everything, and shuts it down. Every test gets a fresh username, which AuthMe treats as a fresh registration, which `@plugwright/auth-authme` answers. +Downloads Paper into `src/test/e2e/generated/local/run`, installs PlaceholderAPI and AuthMe next to the plugin under test, writes an AuthMe config a bot can get through, starts the server, runs everything, and shuts it down. Every test gets a fresh username, which AuthMe treats as a fresh registration, which `@plugwright/auth-authme` answers. ## `stand` — someone else owns the server This one connects to a server that is already running and leaves it running. Provision it once, start it by hand, then point the tests at it. ```bash -# 1. Prepare run/ (Paper, plugins, server.properties with RCON enabled) +# 1. Prepare the run directory (Paper, plugins, server.properties with RCON enabled) ./gradlew plugwrightProvisionLocal -# 2. Start the server yourself, from the run directory -cd run && ./start.sh +# 2. Start the server yourself, from where the local environment put it +cd src/test/e2e/generated/local/run && ./start.sh ``` -`run/` is not in version control, so `start.sh` is yours to write. Anything that starts the jar with Java 21 will do: +`generated/` is not in version control, so `start.sh` is yours to write. Anything that starts the jar with Java 21 will do: ```sh #!/usr/bin/env sh @@ -48,13 +48,15 @@ export PLUGWRIGHT_RCON_PASSWORD=plugwright Expect skips. The stand leases four accounts from a pool instead of inventing a name per test, so anything that assumes a clean balance, an unclaimed kit or an empty arena is excluded, and anything that reads the whole server log is skipped — RCON answers commands, it doesn't stream the log. -`plugins/stand-reset.ts` handles what can be reset: it deops the leased account and clears its inventory before each test. It is loaded for the `stand` environment only, through `plugins { local(...) }`. +`plugins/stand-reset.ts` handles what can be reset: it deops the leased account and clears its inventory before each test. It is loaded for the `stand` environment only, through `plugins { local("stand-reset") }`. ## Layout ``` -src/main/java/…/ExamplePlugin.java the plugin under test -src/test/e2e/*.spec.ts the suite, run against both environments -src/test/e2e/plugins/stand-reset.ts a local runner plugin, stand only -build.gradle.kts both environment declarations +src/main/java/…/ExamplePlugin.java the plugin under test +src/test/e2e/tests/*.spec.ts the suite, run against both environments +src/test/e2e/plugins/stand-reset.ts a runner plugin, stand only +src/test/e2e/dist/ compiled specs and plugins +src/test/e2e/generated/local/run/ the Paper server the local environment owns +build.gradle.kts both environment declarations ``` From f7e4d5bc2410e8a12d2a34915598a5731fa3972a Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 23 Aug 2026 19:55:56 +0300 Subject: [PATCH 5/7] refactor(example): move message-buffer.spec.ts onto the new layout Landed at the workspace root in c4a89a4, after this branch moved the rest of the suite into tests/. The migration only runs while there is no tests/ directory, so nothing would have picked it up and the spec would have stopped running. --- example_plugin/src/test/e2e/{ => tests}/message-buffer.spec.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename example_plugin/src/test/e2e/{ => tests}/message-buffer.spec.ts (100%) diff --git a/example_plugin/src/test/e2e/message-buffer.spec.ts b/example_plugin/src/test/e2e/tests/message-buffer.spec.ts similarity index 100% rename from example_plugin/src/test/e2e/message-buffer.spec.ts rename to example_plugin/src/test/e2e/tests/message-buffer.spec.ts From 579fefcc920910eab52e0ba3d7aa4f4bfeb0faa6 Mon Sep 17 00:00:00 2001 From: Monikon Date: Sun, 23 Aug 2026 19:56:45 +0300 Subject: [PATCH 6/7] chore(example): refresh the workspace lockfile The linked plugin packages moved to 3.0.0-dev.0 and their peer range to >=3.0.0-dev.0 in the previous PR; the example's lockfile still recorded 1.0.0 and >=2.0.0. Written by 'npm install', no dependency changed. --- example_plugin/src/test/e2e/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example_plugin/src/test/e2e/package-lock.json b/example_plugin/src/test/e2e/package-lock.json index a039403..d4ba49a 100644 --- a/example_plugin/src/test/e2e/package-lock.json +++ b/example_plugin/src/test/e2e/package-lock.json @@ -17,7 +17,7 @@ }, "../../../../auth-authme-package": { "name": "@plugwright/auth-authme", - "version": "1.0.0", + "version": "3.0.0-dev.0", "license": "MIT", "devDependencies": { "@drownek/plugwright": "file:../runner-package", @@ -29,12 +29,12 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@drownek/plugwright": ">=2.0.0" + "@drownek/plugwright": ">=3.0.0-dev.0" } }, "../../../../console-rcon-package": { "name": "@plugwright/console-rcon", - "version": "1.0.0", + "version": "3.0.0-dev.0", "license": "MIT", "devDependencies": { "@drownek/plugwright": "file:../runner-package", @@ -46,7 +46,7 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@drownek/plugwright": ">=2.0.0" + "@drownek/plugwright": ">=3.0.0-dev.0" } }, "../../../../runner-package": { From 6f9726c8767dda9f6ab9efe7ad18275bb0021028 Mon Sep 17 00:00:00 2001 From: Monikon Date: Mon, 24 Aug 2026 14:09:44 +0300 Subject: [PATCH 7/7] fix(core): stop stripping tsconfig.json comments on migration rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gson's isLenient=true made retargetTsConfig successfully parse a tsconfig.json with comments, then rewrite it as plain JSON — deleting the comments the try/catch was meant to leave untouched. Drop isLenient so a commented config fails to parse and falls through to the existing catch, which warns and leaves the file alone. Spotted by Drownek in PR #49 review. --- .../me/drownek/plugwright/PlugwrightCompileTestsTask.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 ef05a62..6518b87 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,7 +4,6 @@ import com.google.gson.GsonBuilder import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser -import com.google.gson.stream.JsonReader import me.drownek.plugwright.api.PlugwrightLayout import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty @@ -12,7 +11,6 @@ import org.gradle.api.tasks.Input import org.gradle.api.tasks.Internal import org.gradle.api.tasks.TaskAction import java.io.File -import java.io.StringReader /** * Installs the workspace's npm dependencies and compiles its TypeScript. @@ -163,8 +161,7 @@ abstract class PlugwrightCompileTestsTask : AbstractNodeTask() { if (!tsconfigFile.exists()) return val config = try { - JsonParser.parseReader(JsonReader(StringReader(tsconfigFile.readText())).apply { isLenient = true }) - .asJsonObject + JsonParser.parseString(tsconfigFile.readText()).asJsonObject } catch (e: Exception) { logger.warn( "Could not update ${tsconfigFile.absolutePath} (${e.message}). Point its \"include\" at " +