Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 116 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<!-- betterhud-launch-screenshot -->';
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
Expand Down
33 changes: 33 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
modmenu_version=20.0.0-alpha.1
cloth_config_version=26.2.155
Empty file modified gradlew
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
16 changes: 16 additions & 0 deletions src/gametest/resources/fabric.mod.json
Original file line number Diff line number Diff line change
@@ -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": "*"
}
}