From 26a4d60072fd1752ff598acdc6cde5655d9def15 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 11:11:54 +0000 Subject: [PATCH 1/4] Add CI smoke test that launches the Minecraft client with the mod Registers a runProductionClientGameTest Loom task that boots a real production Fabric client (fabric-loader + the built jar + fabric-api, modmenu and cloth-config via productionRuntimeMods) under XVFB and fails if the game does not reach the title screen and exit cleanly. The build workflow now runs it after the build and uploads the game logs and screenshots as artifacts. Also bumps modmenu to 20.0.0-alpha.1 and cloth-config to 26.2.155: the launch test caught that modmenu 18.x/19.x crash on Minecraft 26.2 at the title screen (NoSuchMethodError: I18n.exists in its TitleScreen mixin). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HqYsmitMrdZZBkd7zHTgYA --- .github/workflows/build.yml | 16 ++++++++++++++++ build.gradle | 15 +++++++++++++++ gradle.properties | 4 ++-- gradlew | 0 4 files changed, 33 insertions(+), 2 deletions(-) mode change 100644 => 100755 gradlew diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a84354..eeac527 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,22 @@ jobs: run: chmod +x ./gradlew - name: build run: ./gradlew build + - name: install xvfb + # Provides a virtual display so the Minecraft client can launch headlessly. + run: sudo apt-get update && sudo apt-get install -y xvfb + - name: launch minecraft client with mod + # Boots a production Fabric client with the built jar and all dependency + # mods, and fails the build if the game does not reach the title screen. + run: ./gradlew runProductionClientGameTest + - name: capture game logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: client-launch-test + path: | + run/logs/ + run/screenshots/ + if-no-files-found: ignore - name: capture build artifacts uses: actions/upload-artifact@v7 with: diff --git a/build.gradle b/build.gradle index d0d242b..e0b9979 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,21 @@ dependencies { implementation("me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}") { exclude(group: "net.fabricmc.fabric-api") } + + // Mods loaded alongside the built jar by runProductionClientGameTest. + productionRuntimeMods "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + productionRuntimeMods "com.terraformersmc:modmenu:${project.modmenu_version}" + productionRuntimeMods "me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}" +} + +// Launches a real production Fabric client (fabric-loader + the built mod jar + +// productionRuntimeMods) and exits once the game reaches the title screen. +// -Dfabric.client.gametest makes Fabric API shut the game down after running the +// registered client gametests (none here, so it is a pure launch smoke test). +// On Linux CI (CI env var set) Loom automatically runs the game under XVFB. +tasks.register("runProductionClientGameTest", net.fabricmc.loom.task.prod.ClientProductionRunTask) { + jvmArgs.add("-Dfabric.client.gametest") + jvmArgs.add("-Dfabric.client.gametest.disableNetworkSynchronizer=true") } processResources { diff --git a/gradle.properties b/gradle.properties index 6593c63..ea9ea3c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,5 +18,5 @@ archives_base_name=betterhud # Dependencies fabric_api_version=0.154.0+26.2 -modmenu_version=18.0.0-alpha.8 -cloth_config_version=26.1.154 \ No newline at end of file +modmenu_version=20.0.0-alpha.1 +cloth_config_version=26.2.155 \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 885fcf5b0ed797a9f6134f7ff1f1172dedb7d690 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 11:30:56 +0000 Subject: [PATCH 2/4] Make the CI launch test enter a survival singleplayer world Adds a client gametest mod (gametest source set) that the production launch task now loads alongside the built jar. The test screenshots the title screen, creates a survival singleplayer world, waits for chunks to render and the world to tick with the HUD active, screenshots it, then exits. Any crash on the way fails the build, and the screenshots are uploaded with the existing client-launch-test artifact. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HqYsmitMrdZZBkd7zHTgYA --- build.gradle | 24 ++++++++++++++++--- .../gametest/BetterHudClientGameTest.java | 24 +++++++++++++++++++ src/gametest/resources/fabric.mod.json | 16 +++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java create mode 100644 src/gametest/resources/fabric.mod.json diff --git a/build.gradle b/build.gradle index e0b9979..74726d9 100644 --- a/build.gradle +++ b/build.gradle @@ -42,14 +42,32 @@ dependencies { productionRuntimeMods "me.shedaniel.cloth:cloth-config-fabric:${project.cloth_config_version}" } +fabricApi { + configureTests { + createSourceSet = true + modId = "betterhud-gametest" + enableGameTests = false + enableClientGameTests = true + eula = true + } +} + +// Packages the gametest source set as a mod so the client gametests can also run +// inside the production client launched by runProductionClientGameTest. +tasks.register("gametestJar", Jar) { + from sourceSets.gametest.output + archiveClassifier = "gametest" +} + // Launches a real production Fabric client (fabric-loader + the built mod jar + -// productionRuntimeMods) and exits once the game reaches the title screen. -// -Dfabric.client.gametest makes Fabric API shut the game down after running the -// registered client gametests (none here, so it is a pure launch smoke test). +// productionRuntimeMods + the gametest mod) and exits once the client gametests +// have run: the game must reach the title screen, generate and enter a survival +// singleplayer world, and shut down without crashing. // On Linux CI (CI env var set) Loom automatically runs the game under XVFB. tasks.register("runProductionClientGameTest", net.fabricmc.loom.task.prod.ClientProductionRunTask) { jvmArgs.add("-Dfabric.client.gametest") jvmArgs.add("-Dfabric.client.gametest.disableNetworkSynchronizer=true") + mods.from(tasks.named("gametestJar")) } processResources { diff --git a/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java b/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java new file mode 100644 index 0000000..5a71cb1 --- /dev/null +++ b/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java @@ -0,0 +1,24 @@ +package dsns.betterhud.gametest; + +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext; +import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState; + +@SuppressWarnings("UnstableApiUsage") +public class BetterHudClientGameTest implements FabricClientGameTest { + @Override + public void runTest(ClientGameTestContext context) { + context.takeScreenshot("betterhud-title-screen"); + + try (TestSingleplayerContext singleplayer = context.worldBuilder() + .adjustSettings(creator -> creator.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL)) + .create()) { + singleplayer.getClientLevel().waitForChunksRender(); + + // Let the world tick a little with the HUD rendering before capturing evidence. + context.waitTicks(40); + context.takeScreenshot("betterhud-survival-world"); + } + } +} diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json new file mode 100644 index 0000000..e432019 --- /dev/null +++ b/src/gametest/resources/fabric.mod.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "id": "betterhud-gametest", + "version": "1.0.0", + "name": "BetterHUD GameTest", + "description": "Client gametests for BetterHUD.", + "license": "AGPL-3.0", + "environment": "client", + "entrypoints": { + "fabric-client-gametest": ["dsns.betterhud.gametest.BetterHudClientGameTest"] + }, + "depends": { + "betterhud": "*", + "fabric-client-gametest-api-v1": "*" + } +} From 4e708b3215b9fee8a20b9e731de727a09764f854 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 11:37:19 +0000 Subject: [PATCH 3/4] Assert screenshots exist and post the in-world capture on the PR The gametest now fails if takeScreenshot did not produce a non-empty PNG. The workflow gains a step that publishes the survival world screenshot to a ci-screenshots branch and keeps a single PR comment updated with it as a visual sanity check. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HqYsmitMrdZZBkd7zHTgYA --- .github/workflows/build.yml | 94 +++++++++++++++++++ .../gametest/BetterHudClientGameTest.java | 19 +++- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eeac527..301f9a3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,12 @@ name: build on: push +# contents: write lets the screenshot step push to the ci-screenshots branch, +# pull-requests: write lets it comment on the associated pull request. +permissions: + contents: write + pull-requests: write + jobs: build: runs-on: ubuntu-24.04 @@ -30,6 +36,94 @@ jobs: # Boots a production Fabric client with the built jar and all dependency # mods, and fails the build if the game does not reach the title screen. run: ./gradlew runProductionClientGameTest + - name: comment screenshot on pr + # Publishes the in-world screenshot to the ci-screenshots branch and + # embeds it in a PR comment as a visual sanity check of the HUD. + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const dir = 'run/screenshots'; + const shot = fs.existsSync(dir) + && fs.readdirSync(dir).find(f => f.endsWith('_betterhud-survival-world.png')); + if (!shot) { + core.setFailed('The survival world screenshot was not created'); + return; + } + + const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha, + }); + const pr = prs.find(p => p.state === 'open'); + if (!pr) { + core.info('No open pull request for this commit, skipping comment'); + return; + } + + // Make sure the ci-screenshots branch exists so the contents API can commit to it. + const branch = 'ci-screenshots'; + try { + await github.rest.git.getRef({ + owner: context.repo.owner, repo: context.repo.repo, ref: `heads/${branch}`, + }); + } catch (e) { + if (e.status !== 404) throw e; + await github.rest.git.createRef({ + owner: context.repo.owner, repo: context.repo.repo, + ref: `refs/heads/${branch}`, sha: context.sha, + }); + } + + // Commit the screenshot under the commit sha so old PR comments keep working. + const dest = `${context.sha}/betterhud-survival-world.png`; + let existingFileSha; + try { + const { data: existingFile } = await github.rest.repos.getContent({ + owner: context.repo.owner, repo: context.repo.repo, path: dest, ref: branch, + }); + existingFileSha = existingFile.sha; + } catch (e) { + if (e.status !== 404) throw e; + } + await github.rest.repos.createOrUpdateFileContents({ + owner: context.repo.owner, repo: context.repo.repo, branch, + path: dest, + message: `CI screenshot for ${context.sha}`, + content: fs.readFileSync(path.join(dir, shot)).toString('base64'), + ...(existingFileSha && { sha: existingFileSha }), + }); + + const url = `https://raw.githubusercontent.com/${context.repo.owner}/${context.repo.repo}/${branch}/${dest}`; + const marker = ''; + const body = [ + marker, + '### Client launch test', + `The game launched and entered a survival singleplayer world on \`${context.sha.slice(0, 7)}\`:`, + '', + `![BetterHUD in a survival world](${url})`, + ].join('\n'); + + // Keep a single comment up to date instead of posting one per push. + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: pr.number, per_page: 100, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, repo: context.repo.repo, + comment_id: existing.id, body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: pr.number, body, + }); + } - name: capture game logs if: always() uses: actions/upload-artifact@v7 diff --git a/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java b/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java index 5a71cb1..95c29ac 100644 --- a/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java +++ b/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java @@ -1,5 +1,10 @@ package dsns.betterhud.gametest; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; + import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext; @@ -9,7 +14,7 @@ public class BetterHudClientGameTest implements FabricClientGameTest { @Override public void runTest(ClientGameTestContext context) { - context.takeScreenshot("betterhud-title-screen"); + assertScreenshotSaved(context.takeScreenshot("betterhud-title-screen")); try (TestSingleplayerContext singleplayer = context.worldBuilder() .adjustSettings(creator -> creator.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL)) @@ -18,7 +23,17 @@ public void runTest(ClientGameTestContext context) { // Let the world tick a little with the HUD rendering before capturing evidence. context.waitTicks(40); - context.takeScreenshot("betterhud-survival-world"); + assertScreenshotSaved(context.takeScreenshot("betterhud-survival-world")); + } + } + + private static void assertScreenshotSaved(Path screenshot) { + try { + if (!Files.isRegularFile(screenshot) || Files.size(screenshot) == 0) { + throw new AssertionError("Screenshot was not saved: " + screenshot); + } + } catch (IOException e) { + throw new UncheckedIOException("Could not verify screenshot " + screenshot, e); } } } From efad29b78256b4174702ff96de9b2afa82e876fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 11:40:46 +0000 Subject: [PATCH 4/4] Use consistent title-case names for workflow steps Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HqYsmitMrdZZBkd7zHTgYA --- .github/workflows/build.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 301f9a3..6c0a129 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,27 +16,27 @@ jobs: build: runs-on: ubuntu-24.04 steps: - - name: checkout repository + - name: Check out repository uses: actions/checkout@v6 - - name: validate gradle wrapper + - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@v6 - - name: setup jdk + - name: Set up JDK 25 uses: actions/setup-java@v5 with: java-version: '25' distribution: 'microsoft' - - name: make gradle wrapper executable + - name: Make Gradle wrapper executable run: chmod +x ./gradlew - - name: build + - name: Build mod run: ./gradlew build - - name: install xvfb + - name: Install Xvfb # Provides a virtual display so the Minecraft client can launch headlessly. run: sudo apt-get update && sudo apt-get install -y xvfb - - name: launch minecraft client with mod + - name: Run Minecraft client launch test # Boots a production Fabric client with the built jar and all dependency # mods, and fails the build if the game does not reach the title screen. run: ./gradlew runProductionClientGameTest - - name: comment screenshot on pr + - name: Post launch screenshot to pull request # Publishes the in-world screenshot to the ci-screenshots branch and # embeds it in a PR comment as a visual sanity check of the HUD. uses: actions/github-script@v8 @@ -124,7 +124,7 @@ jobs: issue_number: pr.number, body, }); } - - name: capture game logs + - name: Upload game logs and screenshots if: always() uses: actions/upload-artifact@v7 with: @@ -133,7 +133,7 @@ jobs: run/logs/ run/screenshots/ if-no-files-found: ignore - - name: capture build artifacts + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: name: Artifacts