Add XVFB support for headless client execution (#1432)

* Implement XVFB support

* Remove unused import

* Fix test

* Fix test v2

* Explicitly install xvfb into the test environment

* Rework xfvb execution

* Fix compile error

* Fix compile error v2

* We love testing with github ci

* Fix code-style
Build time speedup

* Build time speedup

* Fix java executable access
Fix caching

* Fix styling

* Fix xvfb again

* Fix xvfb again again

* Fix xvfb again again again

* Revert mistaken change

* Fix MC-DEV

* Update src/test/groovy/net/fabricmc/loom/test/integration/XvfbRunTest.groovy

Co-authored-by: modmuss <modmuss50@gmail.com>

* Cleanup

* Fix test

* Implement recommendations

* Implement recommendations v2

* Fix left over code

---------

Co-authored-by: modmuss <modmuss50@gmail.com>
This commit is contained in:
Finn Rades
2025-11-09 11:44:31 +01:00
committed by GitHub
parent 91d2edefbf
commit 1a70c3703b
4 changed files with 84 additions and 2 deletions

View File

@@ -224,6 +224,8 @@ public interface LoomGradleExtensionAPI {
/**
* Returns the tiny mappings file used to remap the game and mods.
*
* @return the mappings file, or null if in a non-obfuscated environment
*/
File getMappingsFile();

View File

@@ -366,7 +366,7 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA
@Override
public File getMappingsFile() {
if (notObfuscated()) {
throw new UnsupportedOperationException("Cannot get mappings file in a non-obfuscated environment");
return null;
}
return LoomGradleExtension.get(getProject()).getMappingConfiguration().tinyMappings.toFile();

View File

@@ -37,6 +37,8 @@ import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.inject.Inject;
import org.gradle.api.Project;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.FileCollection;
@@ -48,6 +50,7 @@ import org.gradle.api.specs.Spec;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.JavaExec;
import org.gradle.process.ExecOperations;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -55,11 +58,15 @@ import org.slf4j.LoggerFactory;
import net.fabricmc.loom.LoomGradleExtension;
import net.fabricmc.loom.configuration.ide.RunConfig;
import net.fabricmc.loom.util.Constants;
import net.fabricmc.loom.util.Platform;
public abstract class AbstractRunTask extends JavaExec {
private static final CharsetEncoder ASCII_ENCODER = StandardCharsets.US_ASCII.newEncoder();
private static final Logger LOGGER = LoggerFactory.getLogger(AbstractRunTask.class);
@Inject
protected abstract ExecOperations getExecOperations();
@Input
protected abstract Property<String> getInternalRunDir();
@Input
@@ -73,6 +80,8 @@ public abstract class AbstractRunTask extends JavaExec {
@Input
// We use a string here, as it's technically an output, but we don't want to cache runs of this task by default.
protected abstract Property<String> getArgFilePath();
@Input
protected abstract Property<Boolean> getUseXvfb();
// We control the classpath, as we use a ArgFile to pass it over the command line: https://docs.oracle.com/javase/7/docs/technotes/tools/windows/javac.html#commandlineargfile
@InputFiles
@@ -100,6 +109,13 @@ public abstract class AbstractRunTask extends JavaExec {
getUseArgFile().set(getProject().provider(this::canUseArgFile));
getProjectDir().set(getProject().getProjectDir().getAbsolutePath());
// Set up useXvfb: convention is CI + Linux
getUseXvfb().convention(
getProject().getProviders().environmentVariable("CI")
.map(value -> Platform.CURRENT.getOperatingSystem().isLinux())
.orElse(false)
);
File buildCache = LoomGradleExtension.get(getProject()).getFiles().getProjectBuildCache();
File argFile = new File(buildCache, "argFiles/" + getName());
getArgFilePath().set(argFile.getAbsolutePath());
@@ -135,7 +151,34 @@ public abstract class AbstractRunTask extends JavaExec {
setWorkingDir(new File(getProjectDir().get(), getInternalRunDir().get()));
environment(getInternalEnvironmentVars().get());
super.exec();
// Wrap with XVFB if enabled and on Linux
if (getUseXvfb().get()) {
LOGGER.info("Using XVFB for headless client execution");
execWithXvfb();
} else {
super.exec();
}
}
private void execWithXvfb() {
String xvfbRunPath = "/usr/bin/xvfb-run";
String javaExec = getJavaLauncher().get().getExecutablePath().getAsFile().getAbsolutePath();
// Build the complete command line: xvfb-run --auto-servernum java [jvm-args] mainclass [program-args]
List<String> commandLine = new ArrayList<>();
commandLine.add(xvfbRunPath);
commandLine.add("--auto-servernum");
commandLine.add(javaExec);
commandLine.addAll(getJvmArguments().get());
commandLine.add(getMainClass().get());
commandLine.addAll(getArgs());
getExecOperations().exec(execSpec -> {
execSpec.setCommandLine(commandLine);
execSpec.setWorkingDir(getWorkingDir());
execSpec.setEnvironment(getEnvironment());
});
}
@Override

View File

@@ -208,4 +208,41 @@ class RunConfigTest extends Specification implements GradleProjectTestTrait {
where:
version << STANDARD_TEST_VERSIONS
}
@Unroll
@IgnoreIf({ !os.linux })
def "client game tests with XVFB (gradle #version)"() {
setup:
def gradle = gradleProject(project: "minimalBase", version: version)
gradle.buildGradle << '''
dependencies {
minecraft "com.mojang:minecraft:1.21.4"
mappings "net.fabricmc:yarn:1.21.4+build.4:v2"
modImplementation "net.fabricmc:fabric-loader:0.16.9"
modImplementation "net.fabricmc.fabric-api:fabric-api:0.114.0+1.21.4"
}
fabricApi {
configureTests {
createSourceSet = true
modId = "example-test"
eula = true
}
}
tasks.named("runClientGameTest") {
useXvfb.set(true)
}
'''
when:
def result = gradle.run(task: "runClientGameTest")
def eula = new File(gradle.projectDir, "build/run/clientGameTest/eula.txt")
then:
result.task(":runClientGameTest").outcome == SUCCESS
eula.text.contains("eula=true")
where:
version << STANDARD_TEST_VERSIONS
}
}