ci: report test counts and keep the run log clean

Upload functional test reports, JUnit XML and server logs on every run so a
CI failure has something to read beyond aggregate counts. Print unit and
functional test summaries, naming each failed functional test.

Repackage the JUnit stack into the test mod jar without module descriptors so
FML stops logging a LoaderException per jar while scanning the classpath, and
silence the two ERROR lines that passing tests drive on purpose.

Refresh the handoff counts and record which migration documents are deleted
when the port closes.
This commit is contained in:
Algent committed 2026-09-14 16:32:11 +02:00
1 parent 6a28fff946
commit 78be94b0b3
8 files changed
+132 -7

No files matched your search

+13
View File
@@ -46,3 +46,16 @@ jobs:
- name: Run Forge functional tests
run: ./gradlew --build-cache --stacktrace runFunctionalTestServer
# The server runs out of process; without this a failure leaves only the counts.
- name: Upload functional test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: forge-functional-test-reports
path: |
run/server/junit-out/
run/server/logs/
run/server/crash-reports/
if-no-files-found: warn
retention-days: 14
+39 -3
View File
@@ -26,8 +26,25 @@ configurations {
}
}
// FML scans classpath jars for @Mod with an ASM that predates Java 9, so every JUnit jar logs a LoaderException
// stack trace for its module-info. Repackage them into the test mod jar and keep the originals off the classpath.
def junitArtifactPrefixes = ["junit-jupiter", "junit-platform", "opentest4j", "apiguardian"]
def isJunitArtifact = { File f -> junitArtifactPrefixes.any { f.name.startsWith(it) } }
def functionalTestRuntime = configurations.named(functionalTestSet.runtimeClasspathConfigurationName)
tasks.register(functionalTestSet.jarTaskName, Jar) {
from(functionalTestSet.output)
from(functionalTestRuntime.map { cfg -> cfg.files.findAll(isJunitArtifact).collect { zipTree(it) } }) {
// META-INF/versions is the Java 9+ half of the multi-release jars, never loaded on this Java 8 server.
// Signatures do not survive repackaging; the engine is still found through META-INF/services.
exclude(
"module-info.class",
"META-INF/versions/**",
"META-INF/*.SF",
"META-INF/*.DSA",
"META-INF/*.RSA")
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
archiveClassifier.set("functionalTests")
archiveVersion.set("1.0")
destinationDirectory.set(layout.buildDirectory.dir("tmp"))
@@ -64,7 +81,8 @@ def functionalTestProfileResults = layout.projectDirectory.file("run/server/forg
def addFunctionalTestsToRun = { JavaExec task ->
task.dependsOn(functionalTestSet.jarTaskName)
task.classpath(
configurations.named(functionalTestSet.runtimeClasspathConfigurationName),
// JUnit is repackaged into the test mod jar above.
functionalTestRuntime.map { cfg -> cfg.files.findAll { !isJunitArtifact(it) } },
tasks.named(functionalTestSet.jarTaskName))
}
@@ -92,15 +110,33 @@ tasks.register("runFunctionalTestServer", GradleBuild) {
int tests = 0
int failures = 0
int errors = 0
int skipped = 0
def failed = []
reports.each { report ->
def suite = new XmlSlurper(false, false).parse(report)
tests += suite.@tests.toInteger()
failures += suite.@failures.toInteger()
errors += suite.@errors.toInteger()
skipped += suite.@skipped.toString().isEmpty() ? 0 : suite.@skipped.toInteger()
suite.testcase.each { testcase ->
if (!testcase.failure.isEmpty() || !testcase.error.isEmpty()) {
failed << "${testcase.@classname}.${testcase.@name}"
}
}
}
def summary = "Forge functional tests: ${tests} tests, ${tests - failures - errors - skipped} passed, " +
"${failures} failures, ${errors} errors, ${skipped} skipped"
logger.lifecycle(summary)
failed.each { logger.lifecycle(" FAILED ${it}") }
def stepSummary = System.getenv("GITHUB_STEP_SUMMARY")
if (stepSummary) {
new File(stepSummary) << "### ${summary}\n" + failed.collect { "- FAILED `${it}`\n" }.join()
}
if (tests == 0 || failures > 0 || errors > 0) {
throw new GradleException(
"ForgeMultipart functional tests failed: ${tests} tests, ${failures} failures, ${errors} errors")
throw new GradleException("ForgeMultipart functional tests failed: ${summary}")
}
if (profileFunctionalTests
&& (!functionalTestProfileRecording.asFile.isFile() || !functionalTestProfileResults.asFile.isFile())) {
+17
View File
@@ -6,6 +6,23 @@ plugins {
tasks.test {
useJUnitPlatform()
testLogging {
events("failed")
exceptionFormat = "full"
}
// Gradle prints nothing on success, so a green run never says how much ran.
afterSuite { desc, result ->
if (desc.parent != null) {
return
}
def summary = "Unit tests: ${result.testCount} tests, ${result.successfulTestCount} passed, " +
"${result.failedTestCount} failed, ${result.skippedTestCount} skipped"
logger.lifecycle(summary)
def stepSummary = System.getenv("GITHUB_STEP_SUMMARY")
if (stepSummary) {
new File(stepSummary) << "### ${summary}\n"
}
}
}
// Scope modern syntax to these helpers; retained Scala and joint Java stay on Java 8.
+2 -2
View File
@@ -16,8 +16,8 @@ migration checkout; `codex/tile-compatibility-fixes` was deleted after its fixes
## Current state and next target
**577 plain-JVM tests and 290 Java 8 Forge tests pass, with zero failures/errors/skips.** Sources total **230 Java
files and 9 Scala files / 747 nonblank Scala lines**. The packaged inventory has 451 classes.
**581 plain-JVM tests and 291 Java 8 Forge tests pass, with zero failures/errors/skips.** Sources total **231 Java
files and 9 Scala files / 747 nonblank Scala lines**. The packaged inventory has 452 classes.
The agreed milestone order is the documented consumer-facing Java API, then consumer release and adoption, then final
Scala removal; see the plan's API migration design and Phases 8-10. Per-change fixes, their regression cases and their
+17
View File
@@ -4,6 +4,23 @@ Status: living document
Initial audit: 2026-08-14
Audited revision: `f10595d` (`master`)
## Document disposition
`docs/migration/` is working material for the port, not permanent documentation. Phase language here describes the
migration in progress; do not read it as the current state of the mod. These files are deleted once the migration
closes:
| File | Disposition |
| --- | --- |
| `README.md` (this file) | Delete. Phase gates stop meaning anything once the phases close. |
| `HANDOFF.md` | Delete. Working handoff, including branch and checkout instructions. |
| `HISTORY.md` | Delete. Dated port diary; the narrative stays in git history. |
| `COMPATIBILITY.md` | **Keep.** ABI inventory and consumer audit; `DIVERGENCES.md` requires checking it. |
| `DIVERGENCES.md` | **Keep.** The durable compatibility ledger. |
| `MANUAL_CHECKS.md` | **Keep.** Becomes the recurring client release checklist. |
`docs/api/`, `docs/API.md` and `docs/RELEASE_NOTES.md` are consumer-facing and unaffected.
## Decision summary
Converting ForgeMultipart to Java is realistic. It should be treated as a staged compatibility migration, not as an automatic source conversion or a ground-up rewrite.
@@ -0,0 +1,40 @@
package codechicken.multipart.test;
import java.lang.reflect.Array;
import java.lang.reflect.Proxy;
import org.apache.logging.log4j.Logger;
import codechicken.multipart.handler.MultipartProxy;
/**
* Runs an action with ForgeMultipart's logger detached, for tests that deliberately drive a path which logs at ERROR.
* Those lines are correct behaviour, but in a green run they read as failures.
*
* <p>
* The swap is global and not thread safe; callers run on the server thread inside one synchronous call.
*/
final class ExpectedErrorLog {
/** A reflective proxy rather than a subclass, so it does not depend on Forge's log4j-api version. */
private static final Logger SILENT = (Logger) Proxy
.newProxyInstance(Logger.class.getClassLoader(), new Class<?>[] { Logger.class }, (proxy, method, args) -> {
Class<?> returnType = method.getReturnType();
if (returnType == String.class) return "";
if (returnType == void.class || !returnType.isPrimitive()) return null;
// Boxed zero/false for any primitive return, without enumerating them.
return Array.get(Array.newInstance(returnType, 1), 0);
});
private ExpectedErrorLog() {}
static void silence(Runnable action) {
Logger original = MultipartProxy.logger();
MultipartProxy.logger_$eq(SILENT);
try {
action.run();
} finally {
MultipartProxy.logger_$eq(original);
}
}
}
@@ -43,7 +43,8 @@ class MultipartCompatiblityFunctionalTest {
Function4<World, Object, Object, Object, Object> replacement = new DenyPlacement();
try {
MultipartCompatiblity.canAddPart_$eq(replacement);
assertDoesNotThrow(MCPCCompatModule::load);
// The absent hook is reported at ERROR; that is the behaviour under test.
ExpectedErrorLog.silence(() -> assertDoesNotThrow(MCPCCompatModule::load));
assertSame(replacement, MultipartCompatiblity.canAddPart());
} finally {
MultipartCompatiblity.canAddPart_$eq(original);
@@ -59,7 +59,8 @@ public class MultipartGeneratorFunctionalTest {
server,
generator.codechicken$multipart$MultipartGenerator$$traitsForPart(new HierarchyPart(), false));
MultipartGenerator.registerTrait(root, slotted, redstone);
// Deliberate duplicate: both sides log at ERROR and keep the first registration.
ExpectedErrorLog.silence(() -> MultipartGenerator.registerTrait(root, slotted, redstone));
assertEquals(slotted.replace('.', '/'), map(false).apply(root.replace('.', '/')));
assertEquals(redstone.replace('.', '/'), map(true).apply(root.replace('.', '/')));
MultipartGenerator.registerTrait(LateMarker.class.getName(), tileChange, null);