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/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/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/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 ``` 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/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": { 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/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 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/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..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 @@ -1,23 +1,37 @@ 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 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 /** - * 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 +53,153 @@ 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.parseString(tsconfigFile.readText()).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 +209,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..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 @@ -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 { @@ -322,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()) @@ -417,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) @@ -424,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/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-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" + ] +} 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) 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"