diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a84354..6c0a129 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,24 +6,134 @@ 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 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: capture build artifacts + - 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: 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: 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 + 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: Upload game logs and screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: client-launch-test + path: | + run/logs/ + run/screenshots/ + if-no-files-found: ignore + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: name: Artifacts diff --git a/build.gradle b/build.gradle index d0d242b..74726d9 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,39 @@ 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}" +} + +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 + 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/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 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..95c29ac --- /dev/null +++ b/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java @@ -0,0 +1,39 @@ +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; +import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState; + +@SuppressWarnings("UnstableApiUsage") +public class BetterHudClientGameTest implements FabricClientGameTest { + @Override + public void runTest(ClientGameTestContext context) { + assertScreenshotSaved(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); + 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); + } + } +} 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": "*" + } +}