diff --git a/.github/workflows/test-prs.yml b/.github/workflows/test-prs.yml index 51afdcddad..471304015a 100644 --- a/.github/workflows/test-prs.yml +++ b/.github/workflows/test-prs.yml @@ -47,6 +47,15 @@ jobs: - name: Run JUnit tests with Gradle run: ./gradlew :tests:runUnitTests + - name: Install software OpenGL rendering + run: sudo apt-get install xvfb libgl1-mesa-dri + + - name: Run production client self-test + run: xvfb-run ./gradlew :neoforge:testProductionClient + + - name: Run production server self-test + run: ./gradlew :neoforge:testProductionServer + - name: Store reports if: failure() uses: actions/upload-artifact@v4 diff --git a/buildSrc/src/main/java/net/neoforged/neodev/CreateCleanArtifacts.java b/buildSrc/src/main/java/net/neoforged/neodev/CreateCleanArtifacts.java index 2cb2e89ade..9854d597e4 100644 --- a/buildSrc/src/main/java/net/neoforged/neodev/CreateCleanArtifacts.java +++ b/buildSrc/src/main/java/net/neoforged/neodev/CreateCleanArtifacts.java @@ -7,9 +7,18 @@ import javax.inject.Inject; abstract class CreateCleanArtifacts extends CreateMinecraftArtifacts { + /** + * The unmodified downloaded client jar. + */ + @OutputFile + abstract RegularFileProperty getRawClientJar(); + @OutputFile abstract RegularFileProperty getCleanClientJar(); + /** + * The unmodified downloaded server jar. + */ @OutputFile abstract RegularFileProperty getRawServerJar(); @@ -24,6 +33,7 @@ abstract class CreateCleanArtifacts extends CreateMinecraftArtifacts { @Inject public CreateCleanArtifacts() { + getAdditionalResults().put("node.downloadClient.output.output", getRawClientJar().getAsFile()); getAdditionalResults().put("node.stripClient.output.output", getCleanClientJar().getAsFile()); getAdditionalResults().put("node.downloadServer.output.output", getRawServerJar().getAsFile()); getAdditionalResults().put("node.stripServer.output.output", getCleanServerJar().getAsFile()); diff --git a/buildSrc/src/main/java/net/neoforged/neodev/NeoDevPlugin.java b/buildSrc/src/main/java/net/neoforged/neodev/NeoDevPlugin.java index f77fbe9098..9595efc840 100644 --- a/buildSrc/src/main/java/net/neoforged/neodev/NeoDevPlugin.java +++ b/buildSrc/src/main/java/net/neoforged/neodev/NeoDevPlugin.java @@ -3,9 +3,16 @@ import net.neoforged.minecraftdependencies.MinecraftDependenciesPlugin; import net.neoforged.moddevgradle.internal.NeoDevFacade; import net.neoforged.moddevgradle.tasks.JarJar; +import net.neoforged.neodev.e2e.InstallProductionClient; +import net.neoforged.neodev.e2e.InstallProductionServer; +import net.neoforged.neodev.e2e.RunProductionClient; +import net.neoforged.neodev.e2e.RunProductionServer; +import net.neoforged.neodev.e2e.TestProductionClient; +import net.neoforged.neodev.e2e.TestProductionServer; import net.neoforged.neodev.installer.CreateArgsFile; import net.neoforged.neodev.installer.CreateInstallerProfile; import net.neoforged.neodev.installer.CreateLauncherProfile; +import net.neoforged.neodev.installer.IdentifiedFile; import net.neoforged.neodev.installer.InstallerProcessor; import net.neoforged.neodev.utils.DependencyUtils; import net.neoforged.nfrtgradle.CreateMinecraftArtifacts; @@ -32,6 +39,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Consumer; public class NeoDevPlugin implements Plugin { static final String GROUP = "neoforge development"; @@ -206,7 +214,9 @@ public void apply(Project project) { var createCleanArtifacts = tasks.register("createCleanArtifacts", CreateCleanArtifacts.class, task -> { task.setGroup(INTERNAL_GROUP); + task.setDescription("This task retrieves various files for the Minecraft version without applying NeoForge patches to them"); var cleanArtifactsDir = neoDevBuildDir.map(dir -> dir.dir("artifacts/clean")); + task.getRawClientJar().set(cleanArtifactsDir.map(dir -> dir.file("raw-client.jar"))); task.getCleanClientJar().set(cleanArtifactsDir.map(dir -> dir.file("client.jar"))); task.getRawServerJar().set(cleanArtifactsDir.map(dir -> dir.file("raw-server.jar"))); task.getCleanServerJar().set(cleanArtifactsDir.map(dir -> dir.file("server.jar"))); @@ -404,6 +414,18 @@ public void apply(Project project) { task.dependsOn(userdevJar); task.dependsOn(sourcesJarProvider); }); + + // Set up E2E testing of the produced installer + setupProductionClientTest( + project, + configurations, + downloadAssets, + installerJar, + minecraftVersion, + neoForgeVersion, + createCleanArtifacts.flatMap(CreateCleanArtifacts::getRawClientJar) + ); + setupProductionServerTest(project, installerJar); } private static TaskProvider configureAccessTransformer( @@ -528,4 +550,66 @@ static TaskProvider configureMinecraftDecompilation(Pr task.getNeoFormArtifact().set(mcAndNeoFormVersion.map(version -> "net.neoforged:neoform:" + version + "@zip")); }); } + + private void setupProductionClientTest(Project project, + NeoDevConfigurations configurations, + TaskProvider downloadAssets, + TaskProvider installer, + Provider minecraftVersion, + Provider neoForgeVersion, + Provider originalClientJar + ) { + + var installClient = project.getTasks().register("installProductionClient", InstallProductionClient.class, task -> { + task.setGroup(INTERNAL_GROUP); + task.setDescription("Runs the installer produced by this build and installs a production client."); + task.getInstaller().from(installer.flatMap(AbstractArchiveTask::getArchiveFile)); + + var destinationDir = project.getLayout().getBuildDirectory().dir("production-client"); + task.getInstallationDir().set(destinationDir); + }); + + Consumer configureRunProductionClient = task -> { + task.getLibraryFiles().addAll(IdentifiedFile.listFromConfiguration(project, configurations.neoFormClasspath)); + task.getLibraryFiles().addAll(IdentifiedFile.listFromConfiguration(project, configurations.launcherProfileClasspath)); + task.getAssetPropertiesFile().set(downloadAssets.flatMap(DownloadAssets::getAssetPropertiesFile)); + task.getMinecraftVersion().set(minecraftVersion); + task.getNeoForgeVersion().set(neoForgeVersion); + task.getInstallationDir().set(installClient.flatMap(InstallProductionClient::getInstallationDir)); + task.getOriginalClientJar().set(originalClientJar); + }; + project.getTasks().register("runProductionClient", RunProductionClient.class, task -> { + task.setGroup(INTERNAL_GROUP); + task.setDescription("Runs the production client installed by installProductionClient."); + configureRunProductionClient.accept(task); + }); + project.getTasks().register("testProductionClient", TestProductionClient.class, task -> { + task.setGroup(INTERNAL_GROUP); + task.setDescription("Tests the production client installed by installProductionClient."); + configureRunProductionClient.accept(task); + }); + } + + private void setupProductionServerTest(Project project, TaskProvider installer) { + var installServer = project.getTasks().register("installProductionServer", InstallProductionServer.class, task -> { + task.setGroup(INTERNAL_GROUP); + task.setDescription("Runs the installer produced by this build and installs a production server."); + task.getInstaller().from(installer.flatMap(AbstractArchiveTask::getArchiveFile)); + + var destinationDir = project.getLayout().getBuildDirectory().dir("production-server"); + task.getInstallationDir().set(destinationDir); + }); + + project.getTasks().register("runProductionServer", RunProductionServer.class, task -> { + task.setGroup(INTERNAL_GROUP); + task.setDescription("Runs the production server installed by installProductionServer."); + task.getInstallationDir().set(installServer.flatMap(InstallProductionServer::getInstallationDir)); + }); + + project.getTasks().register("testProductionServer", TestProductionServer.class, task -> { + task.setGroup(INTERNAL_GROUP); + task.setDescription("Tests the production server installed by installProductionServer."); + task.getInstallationDir().set(installServer.flatMap(InstallProductionServer::getInstallationDir)); + }); + } } diff --git a/buildSrc/src/main/java/net/neoforged/neodev/e2e/InstallProductionClient.java b/buildSrc/src/main/java/net/neoforged/neodev/e2e/InstallProductionClient.java new file mode 100644 index 0000000000..bc16b4b386 --- /dev/null +++ b/buildSrc/src/main/java/net/neoforged/neodev/e2e/InstallProductionClient.java @@ -0,0 +1,64 @@ +package net.neoforged.neodev.e2e; + +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.JavaExec; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.TaskAction; + +import javax.inject.Inject; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; + +/** + * Downloads and installs a production NeoForge client. + * By extending this task from {@link JavaExec}, it's possible to debug the actual legacy installer + * via IntelliJ directly. + */ +public abstract class InstallProductionClient extends JavaExec { + /** + * This file collection should contain exactly one file: + * The NeoForge Installer Jar-File. + */ + @InputFiles + public abstract ConfigurableFileCollection getInstaller(); + + /** + * Where NeoForge should be installed. + */ + @OutputDirectory + public abstract DirectoryProperty getInstallationDir(); + + @Inject + public InstallProductionClient() { + classpath(getInstaller()); + } + + @TaskAction + @Override + public void exec() { + var installDir = getInstallationDir().getAsFile().get().toPath().toAbsolutePath(); + + // Installer looks for this file + var profilesJsonPath = installDir.resolve("launcher_profiles.json"); + try { + Files.writeString(profilesJsonPath, "{}"); + } catch (IOException e) { + throw new GradleException("Failed to write fake launcher profiles file.", e); + } + + setWorkingDir(installDir.toFile()); + args("--install-client", installDir.toString()); + try { + setStandardOutput(new BufferedOutputStream(Files.newOutputStream(installDir.resolve("install.log")))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + super.exec(); + } +} diff --git a/buildSrc/src/main/java/net/neoforged/neodev/e2e/InstallProductionServer.java b/buildSrc/src/main/java/net/neoforged/neodev/e2e/InstallProductionServer.java new file mode 100644 index 0000000000..9360a88e88 --- /dev/null +++ b/buildSrc/src/main/java/net/neoforged/neodev/e2e/InstallProductionServer.java @@ -0,0 +1,63 @@ +package net.neoforged.neodev.e2e; + +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.JavaExec; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; + +import javax.inject.Inject; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; + +/** + * Runs the installer produced by the main build to install a dedicated server in a chosen directory. + */ +public abstract class InstallProductionServer extends JavaExec { + /** + * The NeoForge installer jar is expected to be the only file in this file collection. + */ + @InputFiles + public abstract ConfigurableFileCollection getInstaller(); + + /** + * Where the server should be installed. + */ + @OutputDirectory + public abstract DirectoryProperty getInstallationDir(); + + /** + * Points to the server.jar produced by the installer. + */ + @OutputFile + public abstract RegularFileProperty getServerLauncher(); + + @Inject + public InstallProductionServer() { + classpath(getInstaller()); + getServerLauncher().set(getInstallationDir().map(id -> id.file("server.jar"))); + getServerLauncher().finalizeValueOnRead(); + } + + @TaskAction + @Override + public void exec() { + var installDir = getInstallationDir().getAsFile().get().toPath().toAbsolutePath(); + + setWorkingDir(installDir.toFile()); + args("--install-server", installDir.toString()); + args("--server.jar"); + try { + setStandardOutput(new BufferedOutputStream(Files.newOutputStream(installDir.resolve("install.log")))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + super.exec(); + } +} diff --git a/buildSrc/src/main/java/net/neoforged/neodev/e2e/RunProductionClient.java b/buildSrc/src/main/java/net/neoforged/neodev/e2e/RunProductionClient.java new file mode 100644 index 0000000000..f279952f78 --- /dev/null +++ b/buildSrc/src/main/java/net/neoforged/neodev/e2e/RunProductionClient.java @@ -0,0 +1,362 @@ +package net.neoforged.neodev.e2e; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import net.neoforged.neodev.installer.IdentifiedFile; +import net.neoforged.neodev.utils.MavenIdentifier; +import org.apache.tools.ant.taskdefs.condition.Os; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.JavaExec; +import org.gradle.api.tasks.Nested; +import org.gradle.api.tasks.TaskAction; +import org.gradle.process.ExecOperations; +import org.gradle.process.JavaExecSpec; + +import javax.inject.Inject; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Runs a production client previously installed by {@link InstallProductionClient}. + *

+ * This task has to extend from {@link JavaExec} instead of using {@link org.gradle.process.ExecOperations} internally + * to allow debugging it via IntelliJ directly. + * (Technically, implementing {@link org.gradle.process.JavaForkOptions} would suffice). + *

+ * The main complication of this task is evaluating the Vanilla version manifest and building a libraries + * directory and classpath as the Vanilla launcher would. + */ +public abstract class RunProductionClient extends JavaExec { + private final ExecOperations execOperations; + + /** + * The folder where the game was installed. + */ + @InputDirectory + public abstract DirectoryProperty getInstallationDir(); + + /** + * The pre-processed libraries as a file collection. + */ + @Nested + public abstract ListProperty getLibraryFiles(); + + /** + * The asset properties file produced by {@link net.neoforged.nfrtgradle.DownloadAssets}. + */ + @InputFile + public abstract RegularFileProperty getAssetPropertiesFile(); + + /** + * The Minecraft version matching the NeoForge version to install. + */ + @Input + public abstract Property getMinecraftVersion(); + + /** + * The NeoForge version, used for placeholders when launching the game. + * It needs to match the installer used. + */ + @Input + public abstract Property getNeoForgeVersion(); + + /** + * The original, unmodified client jar. + * The Vanilla launcher puts this on the classpath when it launches the game. + */ + @InputFile + public abstract RegularFileProperty getOriginalClientJar(); + + @Inject + public RunProductionClient(ExecOperations execOperations) { + this.execOperations = execOperations; + } + + @TaskAction + @Override + public void exec() { + var installDir = getInstallationDir().getAsFile().get().toPath(); + var nativesDir = installDir.resolve("natives"); + try { + Files.createDirectories(nativesDir); + } catch (IOException e) { + throw new GradleException("Failed to pre-create natives directory " + nativesDir, e); + } + var librariesDir = installDir.resolve("libraries"); + + var minecraftVersion = getMinecraftVersion().get(); + var versionId = "neoforge-" + getNeoForgeVersion().get(); + + var assetProperties = new Properties(); + try (var in = new FileInputStream(getAssetPropertiesFile().getAsFile().get())) { + assetProperties.load(in); + } catch (IOException e) { + throw new GradleException("Failed to read asset properties " + getAssetPropertiesFile(), e); + } + + var assetIndex = Objects.requireNonNull(assetProperties.getProperty("asset_index"), "asset_index"); + var assetsRoot = Objects.requireNonNull(assetProperties.getProperty("assets_root"), "assets_root"); + + // Set up the placeholders generally used by Vanilla profiles in their argument definitions. + var placeholders = new HashMap(); + placeholders.put("auth_player_name", "Dev"); + placeholders.put("version_name", minecraftVersion); + placeholders.put("game_directory", installDir.toAbsolutePath().toString()); + placeholders.put("auth_uuid", "00000000-0000-4000-8000-000000000000"); + placeholders.put("auth_access_token", "0"); + placeholders.put("clientid", "0"); + placeholders.put("auth_xuid", "0"); + placeholders.put("user_type", "legacy"); + placeholders.put("version_type", "release"); + placeholders.put("assets_index_name", assetIndex); + placeholders.put("assets_root", assetsRoot); + placeholders.put("launcher_name", "NeoForgeProdInstallation"); + placeholders.put("launcher_version", "1.0"); + placeholders.put("natives_directory", nativesDir.toAbsolutePath().toString()); + // These are used by NF but provided by the launcher + placeholders.put("library_directory", librariesDir.toAbsolutePath().toString()); + placeholders.put("classpath_separator", File.pathSeparator); + + execOperations.javaexec(spec -> { + // The JVM args at this point may include debugging options when started through IntelliJ + spec.jvmArgs(getJvmArguments().get()); + spec.workingDir(installDir); + + spec.environment(getEnvironment()); + applyVersionManifest(installDir, versionId, placeholders, librariesDir, spec); + }); + } + + /** + * Applies a Vanilla Launcher version manifest to the JavaForkOptions. + */ + private void applyVersionManifest(Path installDir, + String versionId, + Map placeholders, + Path librariesDir, + JavaExecSpec spec) { + var manifests = loadVersionManifests(installDir, versionId); + + var mergedProgramArgs = new ArrayList(); + var mergedJvmArgs = new ArrayList(); + + for (var manifest : manifests) { + var mainClass = manifest.getAsJsonPrimitive("mainClass"); + if (mainClass != null) { + spec.getMainClass().set(mainClass.getAsString()); + } + + mergedProgramArgs.addAll(getArguments(manifest, "game")); + mergedJvmArgs.addAll(getArguments(manifest, "jvm")); + } + + // Index all available libraries + var availableLibraries = new HashMap(); + for (var identifiedFile : getLibraryFiles().get()) { + availableLibraries.put( + identifiedFile.getIdentifier().get(), + identifiedFile.getFile().get().getAsFile().toPath() + ); + } + + // The libraries are built in reverse, and libraries already added are not added again from parent manifests + var librariesAdded = new HashSet(); + var classpathItems = new ArrayList(); + for (var i = manifests.size() - 1; i >= 0; i--) { + var manifest = manifests.get(i); + + var libraries = manifest.getAsJsonArray("libraries"); + for (var library : libraries) { + var libraryObj = library.getAsJsonObject(); + + // Skip if disabled by rule + if (isDisabledByRules(libraryObj)) { + getLogger().info("Skipping library {} since it's condition is not met.", libraryObj); + continue; + } + + var id = MavenIdentifier.parse(libraryObj.get("name").getAsString()); + + // We use this to deduplicate the same library in different versions across manifests + var idWithoutVersion = new MavenIdentifier( + id.group(), + id.artifact(), + "", + id.classifier(), + id.extension() + ); + + if (!librariesAdded.add(idWithoutVersion)) { + continue; // The library was overridden by a child profile + } + + // Try finding the library in the classpath we got from Gradle + var availableLibrary = availableLibraries.get(id); + if (availableLibrary == null) { + throw new GradleException("Version manifest asks for " + id + " but this library is not available through Gradle."); + } + + // Copy over the library to the libraries directory, since our loader only deduplicates class-path + // items with module-path items when they are at the same location (and the module-path is defined + // relative to the libraries directory). + Path destination = librariesDir.resolve(id.repositoryPath()); + copyIfNeeded(availableLibrary, destination); + classpathItems.add(destination.toAbsolutePath().toString()); + } + } + + // The Vanilla launcher adds the actual game jar (obfuscated) as the last classpath item + var gameJar = installDir.resolve("versions").resolve(versionId).resolve(versionId + ".jar"); + copyIfNeeded(getOriginalClientJar().get().getAsFile().toPath(), gameJar); + classpathItems.add(gameJar.toAbsolutePath().toString()); + + var classpath = String.join(File.pathSeparator, classpathItems); + placeholders.putIfAbsent("classpath", classpath); + + expandPlaceholders(mergedProgramArgs, placeholders); + spec.args(mergedProgramArgs); + expandPlaceholders(mergedJvmArgs, placeholders); + spec.jvmArgs(mergedJvmArgs); + } + + // Returns the inherited manifests first + private static List loadVersionManifests(Path installDir, String versionId) { + // Read back the version manifest and get the startup arguments + var manifestPath = installDir.resolve("versions").resolve(versionId).resolve(versionId + ".json"); + JsonObject manifest; + try { + manifest = readJson(manifestPath); + } catch (IOException e) { + throw new GradleException("Failed to read launcher profile " + manifestPath, e); + } + + var result = new ArrayList(); + var inheritsFrom = manifest.getAsJsonPrimitive("inheritsFrom"); + if (inheritsFrom != null) { + result.addAll(loadVersionManifests(installDir, inheritsFrom.getAsString())); + } + + result.add(manifest); + + return result; + } + + private static void expandPlaceholders(List args, Map variables) { + var pattern = Pattern.compile("\\$\\{([^}]+)}"); + + args.replaceAll(s -> { + var matcher = pattern.matcher(s); + return matcher.replaceAll(match -> { + var variable = match.group(1); + return Matcher.quoteReplacement(variables.getOrDefault(variable, matcher.group())); + }); + }); + } + + private static List getArguments(JsonObject manifest, String kind) { + var result = new ArrayList(); + + var gameArgs = manifest.getAsJsonObject("arguments").getAsJsonArray(kind); + for (var gameArg : gameArgs) { + if (gameArg.isJsonObject()) { + var conditionalArgument = gameArg.getAsJsonObject(); + if (!isDisabledByRules(conditionalArgument)) { + var value = conditionalArgument.get("value"); + if (value.isJsonPrimitive()) { + result.add(value.getAsString()); + } else { + for (var valueEl : value.getAsJsonArray()) { + result.add(valueEl.getAsString()); + } + } + } + } else { + result.add(gameArg.getAsString()); + } + } + + return result; + } + + private static boolean isDisabledByRules(JsonObject ruleObject) { + var rules = ruleObject.getAsJsonArray("rules"); + if (rules == null) { + return false; + } + + for (var ruleEl : rules) { + var rule = ruleEl.getAsJsonObject(); + boolean allow = "allow".equals(rule.getAsJsonPrimitive("action").getAsString()); + // We only care about "os" rules + if (rule.has("os")) { + var os = rule.getAsJsonObject("os"); + var name = os.getAsJsonPrimitive("name"); + var arch = os.getAsJsonPrimitive("arch"); + boolean ruleMatches = (name == null || isCurrentOsName(name.getAsString())) && (arch == null || isCurrentOsArch(arch.getAsString())); + if (ruleMatches != allow) { + return true; + } + } else { + // We assume unknown rules do not apply + return true; + } + } + return false; + } + + private static boolean isCurrentOsName(String os) { + return switch (os) { + case "windows" -> Os.isFamily(Os.FAMILY_WINDOWS); + case "osx" -> Os.isFamily(Os.FAMILY_MAC); + case "linux" -> Os.isFamily(Os.FAMILY_UNIX); + default -> false; + }; + } + + private static boolean isCurrentOsArch(String arch) { + return switch (arch) { + case "x86" -> System.getProperty("os.arch").equals("x86"); + default -> false; + }; + } + + private static JsonObject readJson(Path path) throws IOException { + try (var reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + return new Gson().fromJson(reader, JsonObject.class); + } + } + + private static void copyIfNeeded(Path source, Path destination) { + try { + if (!Files.exists(destination) + || !Objects.equals(Files.getLastModifiedTime(destination), Files.getLastModifiedTime(source)) + || Files.size(destination) != Files.size(source)) { + Files.createDirectories(destination.getParent()); + Files.copy(source, destination, StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + throw new GradleException("Failed to copy " + source + " to " + destination + ": " + e, e); + } + } +} diff --git a/buildSrc/src/main/java/net/neoforged/neodev/e2e/RunProductionServer.java b/buildSrc/src/main/java/net/neoforged/neodev/e2e/RunProductionServer.java new file mode 100644 index 0000000000..e65ed58ecb --- /dev/null +++ b/buildSrc/src/main/java/net/neoforged/neodev/e2e/RunProductionServer.java @@ -0,0 +1,47 @@ +package net.neoforged.neodev.e2e; + +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.JavaExec; +import org.gradle.api.tasks.TaskAction; +import org.gradle.process.ExecOperations; + +import javax.inject.Inject; + +/** + * Runs the {@code server.jar} installed by our installer using {@link InstallProductionServer}. + *

+ * This task has to extend from {@link JavaExec} instead of using {@link ExecOperations} internally + * to allow debugging the launched server with IntelliJ. + * (Technically, implementing {@link org.gradle.process.JavaForkOptions} would suffice). + */ +public abstract class RunProductionServer extends JavaExec { + private final ExecOperations execOperations; + + /** + * The folder where the game was installed. + */ + @InputDirectory + public abstract DirectoryProperty getInstallationDir(); + + @Inject + public RunProductionServer(ExecOperations execOperations) { + this.execOperations = execOperations; + } + + @TaskAction + @Override + public void exec() { + var installDir = getInstallationDir().getAsFile().get().toPath(); + + execOperations.javaexec(spec -> { + // The JVM args at this point may include debugging options when started through IntelliJ + spec.jvmArgs(getJvmArguments().get()); + spec.workingDir(installDir); + + spec.environment(getEnvironment()); + spec.classpath(installDir.resolve("server.jar")); + }); + + } +} diff --git a/buildSrc/src/main/java/net/neoforged/neodev/e2e/TestProductionClient.java b/buildSrc/src/main/java/net/neoforged/neodev/e2e/TestProductionClient.java new file mode 100644 index 0000000000..0c7ec7fde3 --- /dev/null +++ b/buildSrc/src/main/java/net/neoforged/neodev/e2e/TestProductionClient.java @@ -0,0 +1,38 @@ +package net.neoforged.neodev.e2e; + +import org.gradle.api.GradleException; +import org.gradle.process.ExecOperations; + +import javax.inject.Inject; +import java.io.File; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +/** + * Runs a production client using {@link RunProductionClient} and passes the environment variable + * to enable the {@link net.neoforged.neoforge.common.util.SelfTest self test}. + *

+ * Once the client exits, it validates that the self-test file was created, indicating the client successfully + * launched and started ticking. + */ +public abstract class TestProductionClient extends RunProductionClient { + @Inject + public TestProductionClient(ExecOperations execOperations) { + super(execOperations); + + getTimeout().set(Duration.of(5, ChronoUnit.MINUTES)); + } + + @Override + public void exec() { + var selfTestReport = new File(getTemporaryDir(), "client_self_test.txt"); + + environment("NEOFORGE_CLIENT_SELFTEST", selfTestReport.getAbsolutePath()); + + super.exec(); + + if (!selfTestReport.exists()) { + throw new GradleException("Missing self test report file after running client: " + selfTestReport); + } + } +} diff --git a/buildSrc/src/main/java/net/neoforged/neodev/e2e/TestProductionServer.java b/buildSrc/src/main/java/net/neoforged/neodev/e2e/TestProductionServer.java new file mode 100644 index 0000000000..9fc6ed293c --- /dev/null +++ b/buildSrc/src/main/java/net/neoforged/neodev/e2e/TestProductionServer.java @@ -0,0 +1,55 @@ +package net.neoforged.neodev.e2e; + +import org.gradle.api.GradleException; +import org.gradle.process.ExecOperations; + +import javax.inject.Inject; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +/** + * Runs a production server using {@link RunProductionServer} and passes the environment variable + * to enable the {@link net.neoforged.neoforge.common.util.SelfTest self test}. + *

+ * Once the server exits, it validates that the self-test file was created, indicating the server successfully + * launched and started ticking. + */ +public abstract class TestProductionServer extends RunProductionServer { + @Inject + public TestProductionServer(ExecOperations execOperations) { + super(execOperations); + + getTimeout().set(Duration.of(5, ChronoUnit.MINUTES)); + } + + @Override + public void exec() { + var selfTestReport = new File(getTemporaryDir(), "server_self_test.txt"); + + environment("NEOFORGE_DEDICATED_SERVER_SELFTEST", selfTestReport.getAbsolutePath()); + + var eulaFile = getInstallationDir().file("eula.txt").get().getAsFile().toPath(); + try { + Files.writeString(eulaFile, "eula=true", StandardCharsets.UTF_8); + } catch (IOException e) { + throw new GradleException("Failed writing eula acceptable to eula.txt", e); + } + + try { + super.exec(); + } finally { + try { + Files.deleteIfExists(eulaFile); + } catch (IOException ignored) { + } + } + + if (!selfTestReport.exists()) { + throw new GradleException("Missing self test report file after running server: " + selfTestReport); + } + } +} diff --git a/buildSrc/src/main/java/net/neoforged/neodev/installer/IdentifiedFile.java b/buildSrc/src/main/java/net/neoforged/neodev/installer/IdentifiedFile.java index a7f685236a..ff75e1ba75 100644 --- a/buildSrc/src/main/java/net/neoforged/neodev/installer/IdentifiedFile.java +++ b/buildSrc/src/main/java/net/neoforged/neodev/installer/IdentifiedFile.java @@ -21,8 +21,8 @@ * Combines a {@link File} and its {@link MavenIdentifier maven identifier}, * for usage as task inputs that will be passed to {@link LibraryCollector}. */ -abstract class IdentifiedFile { - static Provider> listFromConfiguration(Project project, Configuration configuration) { +public abstract class IdentifiedFile { + public static Provider> listFromConfiguration(Project project, Configuration configuration) { return configuration.getIncoming().getArtifacts().getResolvedArtifacts().map( artifacts -> artifacts.stream() .map(artifact -> IdentifiedFile.of(project, artifact)) @@ -41,8 +41,8 @@ public IdentifiedFile() {} @InputFile @PathSensitive(PathSensitivity.NONE) - protected abstract RegularFileProperty getFile(); + public abstract RegularFileProperty getFile(); @Input - protected abstract Property getIdentifier(); + public abstract Property getIdentifier(); } diff --git a/buildSrc/src/main/java/net/neoforged/neodev/utils/MavenIdentifier.java b/buildSrc/src/main/java/net/neoforged/neodev/utils/MavenIdentifier.java index 6a22312fd0..21dc04655b 100644 --- a/buildSrc/src/main/java/net/neoforged/neodev/utils/MavenIdentifier.java +++ b/buildSrc/src/main/java/net/neoforged/neodev/utils/MavenIdentifier.java @@ -10,4 +10,44 @@ public String artifactNotation() { public String repositoryPath() { return group.replace(".", "/") + "/" + artifact + "/" + version + "/" + artifact + "-" + version + (classifier.isEmpty() ? "" : "-" + classifier) + "." + extension; } + + /** + * Valid forms: + *

    + *
  • {@code groupId:artifactId:version}
  • + *
  • {@code groupId:artifactId:version:classifier}
  • + *
  • {@code groupId:artifactId:version:classifier@extension}
  • + *
  • {@code groupId:artifactId:version@extension}
  • + *
+ */ + public static MavenIdentifier parse(String coordinate) { + var coordinateAndExt = coordinate.split("@"); + String extension = "jar"; + if (coordinateAndExt.length > 2) { + throw new IllegalArgumentException("Malformed Maven coordinate: " + coordinate); + } else if (coordinateAndExt.length == 2) { + extension = coordinateAndExt[1]; + coordinate = coordinateAndExt[0]; + } + + var parts = coordinate.split(":"); + if (parts.length != 3 && parts.length != 4) { + throw new IllegalArgumentException("Malformed Maven coordinate: " + coordinate); + } + + var groupId = parts[0]; + var artifactId = parts[1]; + var version = parts[2]; + var classifier = parts.length == 4 ? parts[3] : ""; + return new MavenIdentifier(groupId, artifactId, version, classifier, extension); + } + + @Override + public String toString() { + if (classifier != null) { + return group + ":" + artifact + ":" + version + ":" + classifier + "@" + extension; + } else { + return group + ":" + artifact + ":" + version + "@" + extension; + } + } } diff --git a/src/main/java/net/neoforged/neoforge/client/ClientNeoForgeMod.java b/src/main/java/net/neoforged/neoforge/client/ClientNeoForgeMod.java index d55c15caec..f8526f30d3 100644 --- a/src/main/java/net/neoforged/neoforge/client/ClientNeoForgeMod.java +++ b/src/main/java/net/neoforged/neoforge/client/ClientNeoForgeMod.java @@ -42,6 +42,7 @@ import net.neoforged.neoforge.common.ModConfigSpec; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.common.NeoForgeMod; +import net.neoforged.neoforge.common.util.SelfTest; import net.neoforged.neoforge.internal.versions.neoforge.NeoForgeVersion; import org.jetbrains.annotations.ApiStatus; @@ -49,6 +50,8 @@ @Mod(value = "neoforge", dist = Dist.CLIENT) public class ClientNeoForgeMod { public ClientNeoForgeMod(IEventBus modEventBus, ModContainer container) { + SelfTest.initClient(); + ClientCommandHandler.init(); TagConventionLogWarningClient.init(); diff --git a/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java b/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java index d9ad69fcf7..0e22d8d00d 100644 --- a/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java +++ b/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java @@ -123,6 +123,7 @@ import net.neoforged.neoforge.common.loot.CanItemPerformAbility; import net.neoforged.neoforge.common.loot.IGlobalLootModifier; import net.neoforged.neoforge.common.loot.LootTableIdCondition; +import net.neoforged.neoforge.common.util.SelfTest; import net.neoforged.neoforge.common.world.BiomeModifier; import net.neoforged.neoforge.common.world.BiomeModifiers; import net.neoforged.neoforge.common.world.BiomeModifiers.AddFeaturesBiomeModifier; @@ -527,6 +528,8 @@ public NeoForgeMod(IEventBus modEventBus, Dist dist, ModContainer container) { LOGGER.info(NEOFORGEMOD, "NeoForge mod loading, version {}, for MC {}", NeoForgeVersion.getVersion(), DetectedVersion.BUILT_IN.getName()); ForgeSnapshotsMod.logStartupWarning(); + SelfTest.initCommon(); + CrashReportCallables.registerCrashCallable("Crash Report UUID", () -> { final UUID uuid = UUID.randomUUID(); LOGGER.fatal("Preparing crash report with UUID {}", uuid); diff --git a/src/main/java/net/neoforged/neoforge/common/util/SelfTest.java b/src/main/java/net/neoforged/neoforge/common/util/SelfTest.java new file mode 100644 index 0000000000..734294cdf0 --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/common/util/SelfTest.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.common.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.LoadingOverlay; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.fml.loading.FMLLoader; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import org.jetbrains.annotations.ApiStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * We use a "self-test" to launch a client and dedicated server from within our CI and exit. + * This allows us to do an "end-to-end" test that actually uses the installer we produce to + * install a client&server and test it. + * The self-test writes a file so that the build script can detect that the game actually + * loaded up enough to start ticking the game loop. + */ +@ApiStatus.Internal +public final class SelfTest { + private static final Logger LOGGER = LoggerFactory.getLogger(SelfTest.class); + + private SelfTest() {} + + public static void initClient() { + var clientSelfTestDestination = System.getenv("NEOFORGE_CLIENT_SELFTEST"); + if (clientSelfTestDestination != null) { + NeoForge.EVENT_BUS.addListener((ClientTickEvent.Pre e) -> { + if (Minecraft.getInstance().getOverlay() instanceof LoadingOverlay) { + return; + } + writeSelfTestReport(clientSelfTestDestination); + Minecraft.getInstance().stop(); + }); + } + } + + public static void initCommon() { + var serverSelfTestDestination = System.getenv("NEOFORGE_DEDICATED_SERVER_SELFTEST"); + if (serverSelfTestDestination != null) { + if (FMLLoader.getDist() != Dist.DEDICATED_SERVER) { + LOGGER.error("The server self-test ran with a dist of {} instead of dedicated server!", FMLLoader.getDist()); + System.exit(1); + } + NeoForge.EVENT_BUS.addListener((ServerTickEvent.Pre e) -> { + writeSelfTestReport(serverSelfTestDestination); + e.getServer().halt(false); + }); + } + } + + /** + * This is used by our GitHub Actions pipeline to run an E2E test for PRs. + * It writes a small self-test report to the file indicated by the system property and exits. + */ + private static void writeSelfTestReport(String path) { + try { + Files.createFile(Paths.get(path)); + } catch (IOException e) { + LOGGER.error("Failed to write self-test to '{}'", path, e); + System.exit(1); + } + + LOGGER.info("Write self-test report to '{}'", path); + } +}