Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
27525cc
Add headless game test harness with a BlockEvents.broken test
hspragg-godaddy Jun 16, 2026
1301955
Add JaCoCo code-coverage reporting across unit and game tests
hspragg-godaddy Jun 16, 2026
4cd4470
Restructure game tests to use NeoForge's test framework
hspragg-godaddy Jun 17, 2026
9b19b4e
Remove generic type from TagLoader interface injection entry
hspragg-godaddy Jun 18, 2026
84ee77a
Remove redundant tests.enabled flag
hspragg-godaddy Jun 18, 2026
eb09321
Updated build script such that unit tests run before game test
hspragg-godaddy Jun 18, 2026
9ddaf10
Bump AlmostGradle to 2.2.0 and drop redundant gametest run config
hspragg-godaddy Jun 18, 2026
3c51c74
Add game tests for block and entity events; fix DetectorBlock id
hspragg-godaddy Jul 6, 2026
1fa06a8
Merge branch '2601' into feat/headless-game-tests
MaxNeedsSnacks Jul 6, 2026
847baf5
Add script-side assertThat game tests for event objects
hspragg-godaddy Jul 6, 2026
00c1f2d
Add AssertJ test dependency for game-test assertions
hspragg-godaddy Jul 6, 2026
3db42ce
Expand game-test and unit-test coverage
hspragg-godaddy Jul 7, 2026
70d1bdc
Add script-API coverage gate and bootstrapped unit-test harness
hspragg-godaddy Jul 7, 2026
22c0127
Add builder startup fixtures for game-test coverage
hspragg-godaddy Jul 7, 2026
b696ab2
Add *KJS getter coverage via property-reading game-test fixtures
hspragg-godaddy Jul 7, 2026
189ab72
Assert *KJS reads resolve; add wrapper/map-color tests; drop coverage…
hspragg-godaddy Jul 7, 2026
4ed6a1d
Rework test harness into JS-first categories
hspragg-godaddy Jul 10, 2026
cbcd0d8
Add game tests running wiki recipe, tag, modification and builder exa…
hspragg-godaddy Jul 10, 2026
b94742c
Add game tests for Item, Ingredient, Text, NBT and JsonUtils bindings
hspragg-godaddy Jul 10, 2026
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
84 changes: 84 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Coverage

on:
workflow_dispatch:
pull_request:
branches:
- "2601"
paths:
- "gradle/**"
- "**.java"
- "**.kts"
- "**.properties"
- "**/coverage.yml"
- "src/test/**"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: write

jobs:
coverage:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup JDK 25
uses: actions/setup-java@v4
with:
distribution: "microsoft"
java-version: "25"

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

# Runs the JUnit (`test`) and headless game-test (`runGametest`) JVMs, merges their JaCoCo
# data into one report, and derives build/reports/coverage/coverage-summary.json.
- name: Generate coverage
run: ./gradlew coverageSummaryJson

- name: Upload HTML report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report-html
path: build/reports/coverage/html

# Same-repo PRs only: PRs from forks get a read-only token and cannot comment. For fork
# coverage comments, split this into a companion `workflow_run` job that has write access.
- name: Comment coverage on PR
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const s = JSON.parse(fs.readFileSync('build/reports/coverage/coverage-summary.json', 'utf8'));
const row = (label, m) => `| ${label} | ${m.percent}% | ${m.covered} | ${m.missed} |`;
const marker = '<!-- kubejs-coverage -->';
const body = [
marker,
'## Coverage report',
'',
'| Metric | Coverage | Covered | Missed |',
'| --- | --- | --- | --- |',
row('Instructions', s.instruction),
row('Branches', s.branch),
row('Lines', s.line),
'',
'_Merged from the JUnit unit tests and the headless game test. Full HTML report is in the `coverage-report-html` artifact._',
].join('\n');

const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
const existing = comments.data.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
126 changes: 125 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import com.almostreliable.almostgradle.dependency.LoadingMode
import java.util.Locale
import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
import org.gradle.testing.jacoco.tasks.JacocoReport

plugins {
id("net.neoforged.moddev") version "2.0.138"
id("com.almostreliable.almostgradle") version "2.1.1"
id("idea")
jacoco
// id("me.shedaniel.unified-publishing") version "0.1.+"
}

Expand All @@ -23,7 +27,11 @@ almostgradle.setup {
withAccessTransformerValidation = !runningInCI

tests {

enabled = true
Comment thread
hspragg-godaddy marked this conversation as resolved.
Outdated
testMod = true
gameTests = true
testFramework = true
jUnit = true
}

recipeViewers {
Expand Down Expand Up @@ -61,6 +69,15 @@ neoForge {
from(file("interfaces.json"))
publish(file("interfaces.json"))
}

runs {
// AlmostGradle's gametest run launches as a plain server here; use Neo's headless type and run only our tests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this required because you want to run the unit tests when the game tests are running? I could add a flag to AG that enables this automatically if required.

@hspragg-godaddy hspragg-godaddy Jun 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this section it starts up a regular minecraft server rather than a gametest server. Even kicks off the old server GUI.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have now fixed this in Almost Gradle 2.2.0. When you update, this should no longer be necessary. The game test property name was updated, which I must have missed. Please report it the next time you discover something like this. 😄

I also added a new option to run the unit tests in the game test run config, which you configured here as well.
https://github.com/AlmostReliable/almostgradle#unit-tests-in-game-tests

However, as written in the README, I would not encourage enabling this behavior. Unit tests are invoked in the test Gradle task, which is part of the check Gradle task. And check is invoked when build is run. So a successful build already guarantees that all unit tests passed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just pushed changes for the latest version and removed the section like you had suggested.
I also isolated the game tests to not run on build in favor of just the tests running on build.
That way builds don't trigger game tests.

matching { it.name == "gametest" }.configureEach {
type.set("gameTestServer")
programArgument("--tests")
programArgument("testmod:*")
}
}
}

repositories {
Expand Down Expand Up @@ -121,6 +138,113 @@ dependencies {
})
}

// Make the game tests' KubeJS scripts available in the gametest run's game directory.
val copyGameTestScripts by tasks.registering(Copy::class) {
mustRunAfter("prepareGametestRun")
from(layout.projectDirectory.dir("src/test/kubejs"))
into(layout.buildDirectory.dir("tmp/gametestRuns/gametest/kubejs"))
}

tasks.matching { it.name == "runGametest" }.configureEach {
dependsOn(copyGameTestScripts)
}

// Code coverage. JaCoCo's agent is attached to both test JVMs and scoped to KubeJS' own classes;
// `coverageReport` merges the two execution-data files into one report.
val coverageIncludes = listOf("dev.latvian.mods.kubejs.*")

jacoco {
toolVersion = "0.8.15" // 0.8.14+ supports Java 25 class files
// Gradle's JaCoCo plugin only instruments `Test` tasks by default; opt the gametest JavaExec in.
applyTo(tasks.withType<JavaExec>().matching { it.name == "runGametest" })
}

// When a coverage task is requested, force a fresh run of both test JVMs so the report always
// reflects a real execution (otherwise Gradle's up-to-date check skips them and reuses old data).
val coverageRequested = gradle.startParameter.taskNames.any {
it.substringAfterLast(':').startsWith("coverage")
}

// The test source set holds both JUnit unit tests (...unittest, run by `test`) and the game-test
// mod (...testmod, run by `runGametest`). Allow `test` to pass before any unit tests are present.
tasks.named<Test>("test") {
failOnNoDiscoveredTests = false
if (coverageRequested) {
outputs.upToDateWhen { false }
}
extensions.configure<JacocoTaskExtension> {
includes = coverageIncludes
}
}

tasks.withType<JavaExec>().matching { it.name == "runGametest" }.configureEach {
if (coverageRequested) {
outputs.upToDateWhen { false }
}
extensions.configure<JacocoTaskExtension> {
isEnabled = true
includes = coverageIncludes
}
}

val coverageReport by tasks.registering(JacocoReport::class) {
group = "verification"
description = "Merges coverage from the JUnit (test) and game-test (runGametest) JVMs."
dependsOn("test", "runGametest")
sourceSets(sourceSets["main"])
executionData(fileTree(layout.buildDirectory) {
include("jacoco/test.exec", "jacoco/runGametest.exec")
})
reports {
html.required = true
csv.required = true
xml.required = false
html.outputLocation = layout.buildDirectory.dir("reports/coverage/html")
csv.outputLocation = layout.buildDirectory.file("reports/coverage/coverage.csv")
}
}

// Derive a small JSON summary (overall instruction/branch/line %) from the JaCoCo CSV, for CI to
// read when posting the coverage PR comment. JaCoCo has no native JSON report.
val coverageSummaryJson by tasks.registering {
group = "verification"
description = "Writes coverage-summary.json from the JaCoCo CSV report."
dependsOn(coverageReport)
val csvFile = layout.buildDirectory.file("reports/coverage/coverage.csv")
val jsonFile = layout.buildDirectory.file("reports/coverage/coverage-summary.json")
inputs.file(csvFile)
outputs.file(jsonFile)
doLast {
val rows = csvFile.get().asFile.readLines().drop(1).filter { it.isNotBlank() }
var instrMissed = 0L; var instrCovered = 0L
var branchMissed = 0L; var branchCovered = 0L
var lineMissed = 0L; var lineCovered = 0L
for (row in rows) {
val c = row.split(',')
if (c.size < 9) continue
instrMissed += c[3].toLong(); instrCovered += c[4].toLong()
branchMissed += c[5].toLong(); branchCovered += c[6].toLong()
lineMissed += c[7].toLong(); lineCovered += c[8].toLong()
}
fun pct(covered: Long, missed: Long): String {
val total = covered + missed
val v = if (total == 0L) 0.0 else covered * 100.0 / total
return String.format(Locale.ROOT, "%.2f", v)
}
fun metric(name: String, covered: Long, missed: Long) =
""" "$name": { "covered": $covered, "missed": $missed, "percent": ${pct(covered, missed)} }"""
val json = buildString {
appendLine("{")
appendLine(metric("instruction", instrCovered, instrMissed) + ",")
appendLine(metric("branch", branchCovered, branchMissed) + ",")
appendLine(metric("line", lineCovered, lineMissed))
appendLine("}")
}
jsonFile.get().asFile.writeText(json)
logger.lifecycle("Coverage (instructions): ${pct(instrCovered, instrMissed)}% -> ${jsonFile.get().asFile}")
}
}

publishing {
repositories {
val mavenUrl = System.getenv("MAVEN_URL") ?: return@repositories
Expand Down
24 changes: 24 additions & 0 deletions src/test/java/dev/latvian/mods/kubejs/testmod/KubeJSGameTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package dev.latvian.mods.kubejs.testmod;

import net.minecraft.resources.Identifier;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod;
import net.neoforged.testframework.conf.FrameworkConfiguration;

/// Entry point for the {@code testmod} game-test mod. Stands up a NeoForge test framework that collects
/// the mod's annotated tests and registers them on the mod bus. Loaded only by the headless
/// {@code runGametest} ({@code gameTestServer}) run.
@Mod("testmod")
public class KubeJSGameTests {
public static final String MOD_ID = "testmod";

public KubeJSGameTests(IEventBus modBus, ModContainer container) {
var framework = FrameworkConfiguration
.builder(Identifier.fromNamespaceAndPath(MOD_ID, "tests"))
.build()
.create();

framework.init(modBus, container);
}
}
22 changes: 22 additions & 0 deletions src/test/java/dev/latvian/mods/kubejs/testmod/TestRuntime.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package dev.latvian.mods.kubejs.testmod;

import java.util.HashSet;
import java.util.Set;

/// Bound into scripts as {@code TestRuntime} so a script can report a passing condition back to
/// the game test that drives it, e.g. {@code TestRuntime.pass('block.break.dirt')}.
public class TestRuntime {
private static final Set<String> PASSED = new HashSet<>();

public static void pass(String id) {
PASSED.add(id);
}

public static boolean passed(String id) {
return PASSED.contains(id);
}

public static void reset() {
PASSED.clear();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package dev.latvian.mods.kubejs.testmod;

import dev.latvian.mods.kubejs.plugin.KubeJSPlugin;
import dev.latvian.mods.kubejs.script.BindingRegistry;

public class TestRuntimePlugin implements KubeJSPlugin {
@Override
public void registerBindings(BindingRegistry bindings) {
bindings.add("TestRuntime", TestRuntime.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dev.latvian.mods.kubejs.testmod.block;

import dev.latvian.mods.kubejs.testmod.TestRuntime;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.neoforged.testframework.DynamicTest;
import net.neoforged.testframework.annotation.ForEachTest;
import net.neoforged.testframework.annotation.TestHolder;
import net.neoforged.testframework.gametest.EmptyTemplate;
import net.neoforged.testframework.gametest.GameTest;

@ForEachTest(groups = "kubejs.block.event")
public class BlockEventTests {
@GameTest
@EmptyTemplate(floor = true)
@TestHolder(value = "block_broken_dirt", description = "KubeJS BlockEvents.broken fires when a player breaks dirt")
Comment thread
hspragg-godaddy marked this conversation as resolved.
static void blockBrokenDirt(final DynamicTest test) {
test.onGameTest(helper -> helper.startSequence(() -> helper.makeTickingMockServerPlayerInCorner(GameType.SURVIVAL))
.thenExecute(() -> TestRuntime.reset())
.thenExecute(() -> helper.setBlock(1, 2, 1, Blocks.DIRT.defaultBlockState()))
.thenExecute(player -> player.gameMode.destroyBlock(helper.absolutePos(new BlockPos(1, 2, 1))))
.thenWaitUntil(() -> helper.assertTrue(TestRuntime.passed("block.break.dirt"), "script did not report block.break.dirt"))
.thenSucceed());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@NullMarked
package dev.latvian.mods.kubejs.testmod;

import org.jspecify.annotations.NullMarked;
45 changes: 45 additions & 0 deletions src/test/java/dev/latvian/mods/kubejs/unittest/ListJSTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package dev.latvian.mods.kubejs.unittest;

import dev.latvian.mods.kubejs.util.ListJS;
import org.junit.jupiter.api.Test;

import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class ListJSTest {
@Test
void primitiveArrayBecomesBoxedList() {
assertEquals(List.of(1, 2, 3), ListJS.of(new int[]{1, 2, 3}));
}

@Test
void existingListIsReturnedAsIs() {
var list = List.of("a", "b");
assertEquals(list, ListJS.of(list));
}

@Test
void orSelfWrapsScalar() {
assertEquals(List.of("hello"), ListJS.orSelf("hello"));
}

@Test
void orSelfOfNullIsEmpty() {
assertTrue(ListJS.orSelf(null).isEmpty());
}

@Test
void ofScalarIsNull() {
assertNull(ListJS.of("hello"));
}

@Test
void ofSetDeduplicates() {
var set = ListJS.ofSet(Arrays.asList(1, 1, 2, 3, 3));
assertEquals(3, set.size());
}
}
Loading
Loading