feat(tree-feller): migrate distribution and local harness to Purpur
Release / release (push) Successful in 7m13s
CI / build (push) Successful in 2m35s

BREAKING CHANGE: New releases require Java 25/Purpur 26.2 and use purpur-tree-feller-<version>.jar. Runtime TreeFeller identity, data folder, progression and felling/undo behavior are unchanged. Replace the old JAR rather than installing both distributions. The local harness now selects Purpur 2618; its stored worlds and operator/port/RCON settings are preserved. No containers are started by this migration.
This commit is contained in:
dmg
2026-09-12 15:04:03 -04:00
parent 2a793e8c69
commit 5b41190a09
15 changed files with 334 additions and 30 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 25
cache: gradle
- name: Validate conventional commits
@@ -35,6 +35,6 @@ jobs:
- name: Upload development artifact
uses: actions/upload-artifact@v3
with:
name: tree-feller-${{ github.sha }}
name: purpur-tree-feller-${{ github.sha }}
path: build/libs/*.jar
if-no-files-found: error
+5 -5
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 25
cache: gradle
- name: Set up Node.js
@@ -86,8 +86,8 @@ jobs:
if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@v3
with:
name: tree-feller-${{ steps.release.outputs.version }}
path: build/libs/tree-feller-${{ steps.release.outputs.version }}.jar
name: purpur-tree-feller-${{ steps.release.outputs.version }}
path: build/libs/purpur-tree-feller-${{ steps.release.outputs.version }}.jar
if-no-files-found: error
- name: Create Gitea release and upload plugin
@@ -101,7 +101,7 @@ jobs:
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
run: |
api_url="${GITEA_SERVER_URL}/api/v1"
jar="build/libs/tree-feller-${VERSION}.jar"
jar="build/libs/purpur-tree-feller-${VERSION}.jar"
export RELEASE_BODY
if [ -n "$PREVIOUS_TAG" ]; then
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
@@ -138,4 +138,4 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${jar}" \
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=tree-feller-${VERSION}.jar"
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-tree-feller-${VERSION}.jar"
+3 -3
View File
@@ -1,9 +1,9 @@
# spigot-tree-feller agent entrypoint
# purpur-tree-feller agent entrypoint
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-tree-feller/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-tree-feller/` stories. Also follow the parent workspace `AGENTS.md` when present.
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-tree-feller/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-tree-feller/` stories. Also follow the parent workspace `AGENTS.md` when present.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-tree-feller/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tree-feller/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+14 -10
View File
@@ -1,13 +1,15 @@
# Tree Feller
# Purpur Tree Feller
Tree Feller is a Spigot 26.2 plugin that lets Survival players earn animated automatic felling independently for each vanilla tree family.
Tree Feller is a Purpur 26.2 plugin that lets Survival players earn animated automatic felling independently for each vanilla tree family.
The approved behavior and implementation record are in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-tree-feller/index.md).
The approved behavior and implementation record are in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tree-feller/index.md).
## Requirements
- Spigot 26.2
- Java 17 or newer
- Purpur 26.2 build 2618
- Java 25
Repository, checkout and new artifacts use `purpur-tree-feller`. Runtime `TreeFeller`, Java packages/entrypoint, `plugins/TreeFeller/`, commands/permissions and player progression/preferences remain unchanged. Replace the old JAR rather than installing both distributions. Old release tags/assets remain available; new releases no longer support Java 17.
## Build
@@ -15,11 +17,11 @@ The approved behavior and implementation record are in the [SoMC OKF wiki](https
./gradlew clean check jar
```
The plugin JAR is written to `build/libs/`.
The plugin JAR is written to `build/libs/purpur-tree-feller-<version>.jar`. `check` includes `nativeTreeTest`, loading real item registries/services from SHA-256-verified Purpur 2618 under `build/tree-runtime/` without starting a listening server. Existing item/felling/undo assertions are retained. `LocalTestHarnessTest` checks parsed Compose settings and runs the actual startup script with isolated command doubles; it does not start Docker.
## Local Docker test server
Build the current plugin and start it on an isolated Spigot 26.2 server:
Build the current plugin and start it on an isolated Java 25/Purpur 26.2 build 2618 server:
```bash
./scripts/start-test-server.sh
@@ -27,7 +29,7 @@ Build the current plugin and start it on an isolated Spigot 26.2 server:
Connect to `localhost:25565` as `WindMagi`, which the harness configures as a level-4 operator using its deterministic offline-mode UUID. The committed `docker/test-ops.json` fixture replaces local operator state on every startup so the offline UUID remains deterministic. The server uses offline mode for local convenience and binds only to the loopback interface.
The first startup may take several minutes while the image downloads and Spigot is prepared. Override the 15-minute wait or local RCON password when needed:
The first startup may take several minutes while the image downloads and Purpur is prepared. Override the 15-minute wait or local RCON password when needed:
```bash
TREE_FELLER_START_TIMEOUT=1200 \
@@ -49,6 +51,8 @@ docker compose -f compose.test.yml down
rm -rf .docker/minecraft
```
The harness now mounts the `purpur-tree-feller` snapshot JAR at the unchanged `/plugins/TreeFeller.jar` path. A manual startup after migration switches the local test world from Spigot to Purpur; it preserves existing `.docker/minecraft/` state, so back up valuable test worlds before running it. The migration itself does not start or recreate containers.
Re-run the start script after code changes to rebuild the JAR and recreate the test container. Generated server state remains under `.docker/minecraft/` and is ignored by Git.
## Player commands
@@ -79,7 +83,7 @@ Administrative commands require `treefeller.admin`, granted to server operators
- Crimson and warped fungi
- Giant red and brown mushrooms
Azalea-grown logs count as oak. Unlock progress counts unstripped natural log and Nether stem materials even after the surrounding tree has been disrupted; wood, hyphae, stripped variants, and bamboo do not count. Player-placed qualifying materials are indistinguishable from generated materials in Spigot and therefore also count. Giant mushroom stems require enough cap context to identify their species.
Azalea-grown logs count as oak. Unlock progress counts unstripped natural log and Nether stem materials even after the surrounding tree has been disrupted; wood, hyphae, stripped variants, and bamboo do not count. Player-placed qualifying materials are indistinguishable from generated materials through the server API and therefore also count. Giant mushroom stems require enough cap context to identify their species.
## Configuration and state
@@ -91,7 +95,7 @@ Player preferences, administrative locks, progress, unlocks, and known names are
Tree detection searches matching natural trunk blocks laterally and upward, never below the initiating chop, and requires corresponding foliage or caps. Search bounds prevent unbounded traversal. Leaves, caps, roots, vines, and decorations are not automatically broken.
Automatic felling supports Spigot 26.2 wooden, stone, copper, iron, golden, diamond, and netherite axes. Additional trunk blocks use Spigot's `Player.breakBlock` path so block-break cancellation, drops, experience, enchantments, and axe durability remain authoritative. Protection plugins should cancel `BlockBreakEvent` normally. A cancelled additional break stops the remaining felling.
Automatic felling supports Purpur 26.2 wooden, stone, copper, iron, golden, diamond, and netherite axes. Additional trunk blocks use the server's `Player.breakBlock` path so block-break cancellation, drops, experience, enchantments, and axe durability remain authoritative. Protection plugins should cancel `BlockBreakEvent` normally. A cancelled additional break stops the remaining felling.
## Releases
+64 -5
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins {
java
}
@@ -9,23 +12,23 @@ version = providers.gradleProperty("releaseVersion")
repositories {
mavenCentral()
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
maven("https://repo.purpurmc.org/snapshots/")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
languageVersion = JavaLanguageVersion.of(25)
}
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
}
dependencies {
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
@@ -35,8 +38,64 @@ dependencies {
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
}
// Real item/felling/undo regressions require the declared runtime's registries and service providers.
val treeRuntime = layout.buildDirectory.dir("tree-runtime")
val downloadTreeRuntime = tasks.register("downloadTreeRuntime") {
val launcher = treeRuntime.map { it.file("purpur-26.2-2618.jar") }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
"Purpur 2618 checksum mismatch"
}
file.writeBytes(bytes)
}
}
val prepareTreeRuntime = tasks.register<JavaExec>("prepareTreeRuntime") {
dependsOn(downloadTreeRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(treeRuntime.map { it.file("purpur-26.2-2618.jar") })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(treeRuntime)
outputs.dir(treeRuntime.map { it.dir("versions") })
outputs.dir(treeRuntime.map { it.dir("libraries") })
}
val nativeTest = sourceSets.create("nativeTest")
dependencies {
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
}
val nativeRuntimeJars = files(fileTree(treeRuntime) {
include("versions/**/*.jar", "libraries/**/*.jar")
}).builtBy(prepareTreeRuntime)
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeTreeTest = tasks.register<Test>("nativeTreeTest") {
description = "Runs real item/felling/undo regressions without starting a server"
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(treeRuntime)
}
tasks.check { dependsOn(nativeTreeTest) }
val pluginVersion = version
tasks.processResources {
+3 -2
View File
@@ -8,8 +8,9 @@ services:
- "127.0.0.1:25565:25565"
environment:
EULA: "TRUE"
TYPE: SPIGOT
TYPE: PURPUR
VERSION: "26.2"
PURPUR_BUILD: "2618"
MEMORY: 2G
ONLINE_MODE: "false"
OPS_FILE: /config/tree-feller-ops.json
@@ -21,7 +22,7 @@ services:
SPAWN_PROTECTION: "0"
volumes:
- ./.docker/minecraft:/data
- ./build/libs/tree-feller-0.1.0-SNAPSHOT.jar:/plugins/TreeFeller.jar:ro
- ./build/libs/purpur-tree-feller-0.1.0-SNAPSHOT.jar:/plugins/TreeFeller.jar:ro
- ./docker/test-ops.json:/config/tree-feller-ops.json:ro
healthcheck:
test: ["CMD", "mc-health"]
+2 -2
View File
@@ -32,7 +32,7 @@ if [[ -z "$container_id" ]]; then
exit 1
fi
echo "Waiting up to ${wait_seconds}s for Spigot 26.2..."
echo "Waiting up to ${wait_seconds}s for Purpur 26.2 build 2618..."
deadline=$((SECONDS + wait_seconds))
while (( SECONDS < deadline )); do
container_state="$(docker inspect --format '{{.State.Status}}' "$container_id")"
@@ -51,7 +51,7 @@ done
health_state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")"
if [[ "$health_state" != "healthy" ]]; then
docker compose -f "$compose_file" logs --tail=150 minecraft >&2
echo "Timed out waiting for Spigot 26.2 (status: $health_state)." >&2
echo "Timed out waiting for Purpur 26.2 build 2618 (status: $health_state)." >&2
exit 1
fi
+1 -1
View File
@@ -1 +1 @@
rootProject.name = "tree-feller"
rootProject.name = "purpur-tree-feller"
@@ -25,6 +25,9 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class AnimatedTreeFellingEngineTest {
@org.junit.jupiter.api.BeforeAll
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void breaksRemainingTrunkBlocksBottomToTopAtTheConfiguredInterval() {
TestTree tree = new TestTree();
@@ -0,0 +1,61 @@
package games.dmg.treefeller;
import java.util.List;
import java.util.stream.Stream;
import net.minecraft.SharedConstants;
import net.minecraft.commands.Commands;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.LayeredRegistryAccess;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryDataLoader;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.RegistryLayer;
import net.minecraft.server.ReloadableServerResources;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.repository.ServerPacksSource;
import net.minecraft.server.packs.resources.MultiPackResourceManager;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.tags.TagLoader;
import net.minecraft.util.Util;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.level.DataPackConfig;
import net.minecraft.world.level.WorldDataConfiguration;
import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
final class NativeRuntime {
private NativeRuntime() {}
private static boolean ready;
static synchronized void bootstrap() throws Exception {
if (ready) { return; }
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
var flags = FeatureFlags.VANILLA_SET;
var packs = ServerPacksSource.createVanillaTrustedRepository();
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
}
ready = true;
}
}
@@ -22,6 +22,9 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class TreeFellingListenerTest {
@org.junit.jupiter.api.BeforeAll
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void startsOnlyForAnEnabledUnlockedNonSneakingSurvivalAxeUser() {
UUID playerId = UUID.randomUUID();
@@ -18,6 +18,9 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class TreeProgressListenerTest {
@org.junit.jupiter.api.BeforeAll
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void recordsNaturalTrunkMaterialAfterTheTreeStructureIsNoLongerIntact() throws Exception {
UUID playerId = UUID.randomUUID();
@@ -26,6 +26,9 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class TreeUndoServiceTest {
@org.junit.jupiter.api.BeforeAll
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
private static final Instant FELLED_AT = Instant.parse("2026-08-11T20:00:00Z");
@Test
@@ -0,0 +1,114 @@
package games.dmg.treefeller;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.yaml.snakeyaml.Yaml;
/** Executes the real startup script with isolated CLI doubles; never starts Docker. */
class LocalTestHarnessTest {
@TempDir Path temporary;
@Test
void composeUsesPinnedPurpurAndCurrentJarWithoutChangingIsolationOrState() throws Exception {
Map<?, ?> compose = new Yaml().load(Files.readString(Path.of("compose.test.yml")));
Map<?, ?> service = (Map<?, ?>) ((Map<?, ?>) compose.get("services")).get("minecraft");
Map<?, ?> environment = (Map<?, ?>) service.get("environment");
assertEquals("PURPUR", environment.get("TYPE"));
assertEquals("2618", environment.get("PURPUR_BUILD"));
assertEquals("26.2", environment.get("VERSION"));
assertEquals("itzg/minecraft-server:java25", service.get("image"));
assertEquals("tree-feller-test", compose.get("name"));
assertEquals("tree-feller-test", service.get("container_name"));
assertEquals(List.of("127.0.0.1:25565:25565"), service.get("ports"));
assertEquals(List.of("./.docker/minecraft:/data",
"./build/libs/purpur-tree-feller-0.1.0-SNAPSHOT.jar:/plugins/TreeFeller.jar:ro",
"./docker/test-ops.json:/config/tree-feller-ops.json:ro"), service.get("volumes"));
assertEquals("false", environment.get("ONLINE_MODE"));
assertEquals("true", environment.get("OVERRIDE_OPS"));
assertEquals("/config/tree-feller-ops.json", environment.get("OPS_FILE"));
assertEquals("true", environment.get("ENABLE_RCON"));
assertEquals("${TREE_FELLER_RCON_PASSWORD:-tree-feller-local-test}", environment.get("RCON_PASSWORD"));
List<?> ops = new Yaml().load(Files.readString(Path.of("docker/test-ops.json")));
assertEquals(1, ops.size());
Map<?, ?> operator = (Map<?, ?>) ops.getFirst();
assertEquals("WindMagi", operator.get("name"));
assertEquals("6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e", operator.get("uuid"));
assertEquals(4, operator.get("level"));
assertTrue(Files.readString(Path.of(".gitignore")).contains(".docker/"));
}
@Test
void startupVerifiesBuildBeforeRecreateAndRetainsWorldState() throws Exception {
Result result = runStartup(false);
assertEquals(0, result.exitCode(), result.output());
assertTrue(result.output().contains("Purpur 26.2 build 2618"));
assertTrue(result.output().contains("Tree Feller test server is ready"));
assertTrue(result.calls().indexOf("gradle clean check jar") < result.calls().indexOf("up -d --force-recreate"));
assertTrue(result.calls().contains("rcon-cli plugins"));
assertTrue(result.calls().contains("cat /data/ops.json"));
assertFalse(result.calls().contains("down"));
assertEquals("existing world", Files.readString(temporary.resolve(".docker/minecraft/sentinel")));
}
@Test
void failedBuildNeverStartsAContainer() throws Exception {
Result result = runStartup(true);
assertNotEquals(0, result.exitCode());
assertTrue(result.calls().contains("gradle clean check jar"));
assertFalse(result.calls().contains("up -d"));
assertFalse(result.calls().contains("rcon-cli"));
}
private Result runStartup(boolean failBuild) throws Exception {
Files.createDirectories(temporary.resolve("scripts"));
Files.createDirectories(temporary.resolve("bin"));
Files.createDirectories(temporary.resolve(".docker/minecraft"));
Files.writeString(temporary.resolve(".docker/minecraft/sentinel"), "existing world");
Files.copy(Path.of("scripts/start-test-server.sh"), temporary.resolve("scripts/start-test-server.sh"));
Files.copy(Path.of("docker/test-ops.json"), temporary.resolve("ops.json"));
executable(temporary.resolve("gradlew"), "#!/bin/sh\nprintf 'gradle %s\\n' \"$*\" >> \"$TEST_CALLS\"\nexit " + (failBuild ? "1" : "0") + "\n");
executable(temporary.resolve("bin/docker"), """
#!/bin/sh
printf 'docker %s\n' "$*" >> "$TEST_CALLS"
case "$*" in
info|'compose version') exit 0 ;;
*'up -d --force-recreate') exit 0 ;;
*'ps -q minecraft') echo fake-container ;;
*'{{if .State.Health}}'*) echo healthy ;;
*'{{.State.Status}}'*) echo running ;;
*'rcon-cli plugins') echo TreeFeller ;;
*'cat /data/ops.json') cat "$TEST_OPS" ;;
*) echo 'Unexpected Docker command' >&2; exit 42 ;;
esac
""");
Path output = temporary.resolve("output");
Path calls = temporary.resolve("calls");
var builder = new ProcessBuilder("bash", temporary.resolve("scripts/start-test-server.sh").toString());
builder.environment().put("PATH", temporary.resolve("bin") + ":" + System.getenv("PATH"));
builder.environment().put("TEST_CALLS", calls.toString());
builder.environment().put("TEST_OPS", temporary.resolve("ops.json").toString());
builder.environment().put("TREE_FELLER_START_TIMEOUT", "5");
builder.redirectErrorStream(true).redirectOutput(output.toFile());
Process process = builder.start();
try {
assertTrue(process.waitFor(15, TimeUnit.SECONDS), "Isolated startup script timed out");
return new Result(process.exitValue(), Files.readString(output), Files.readString(calls));
} finally {
if (process.isAlive()) { process.destroyForcibly(); }
}
}
private static void executable(Path path, String content) throws Exception {
Files.writeString(path, content);
assertTrue(path.toFile().setExecutable(true));
}
private record Result(int exitCode, String output, String calls) { }
}
@@ -0,0 +1,53 @@
package games.dmg.treefeller;
import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
class PurpurDistributionTest {
@Test
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-tree-feller-" + version + ".jar", artifact.getFileName().toString());
try (var jar = new JarFile(artifact.toFile())) {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
assertEquals("TreeFeller", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.treefeller.TreeFellerPlugin", metadata.get("main"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("treefeller.admin"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
}
}
@Test
void pluginBytecodeTargetsJava25() throws Exception {
try (var jar = new JarFile(System.getProperty("distribution.jar"));
var bytecode = new DataInputStream(jar.getInputStream(
jar.getJarEntry("games/dmg/treefeller/TreeFellerPlugin.class")))) {
assertEquals(0xCAFEBABE, bytecode.readInt());
bytecode.readUnsignedShort();
assertEquals(69, bytecode.readUnsignedShort());
}
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));
assertTrue(content.contains("purpur-tree-feller-"), workflow);
assertFalse(content.contains("build/libs/tree-feller-") || content.contains("name: tree-feller-"), workflow);
}
}
}