Skip to content
Open
Show file tree
Hide file tree
Changes from 16 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 });
}
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
## Unreleased

- Fix dedicated server crash from block tint function client types (#1159)
- Fix `DetectorBlock` failing to register (and crashing world load) because its block id was never set (#1153)
- Add game tests covering block and entity events (#1153)
- Add typed script-side `TestRuntime.assertThat` entry points and game tests asserting on event objects (#1153)
- Expand game-test and unit-test coverage: level/server/item/player events and util classes (#1153)
- Add a bootstrapped JUnit harness and expand unit + game-test coverage of wrappers, recipe components, util, builders, and script-exposed `*KJS` getters (#1153)

## [8.0.3] - 2026-06-22

Expand Down
123 changes: 121 additions & 2 deletions 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("com.almostreliable.almostgradle") version "2.2.0"
id("idea")
jacoco
// id("me.shedaniel.unified-publishing") version "0.1.+"
}

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

tests {

testMod = true
gameTests = true
testFramework = true
jUnit = true
}

recipeViewers {
Expand Down Expand Up @@ -119,6 +126,118 @@ dependencies {
prefer(batVersion)
}
})

testImplementation("org.assertj:assertj-core:3.27.3")
}

// 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 {
// When both run (the coverage flow), order the cheap unit tests first so the heavy game-test
// server never shares a heap window with the JUnit fork, even under --parallel.
mustRunAfter("test")
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 {
Expand Down
2 changes: 1 addition & 1 deletion interfaces.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,6 @@
"dev/latvian/mods/kubejs/core/EntityTypeKJS"
],
"net/minecraft/tags/TagLoader":[
"dev/latvian/mods/kubejs/core/TagLoaderKJS<T>"
"dev/latvian/mods/kubejs/core/TagLoaderKJS"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import dev.latvian.mods.kubejs.plugin.builtin.event.BlockEvents;
import dev.latvian.mods.rhino.util.ReturnsSelf;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
Expand Down Expand Up @@ -66,7 +68,7 @@ protected void generateItemModel(ModelGenerator m) {
private final Builder builder;

public DetectorBlock(Builder b) {
super(Properties.ofFullCopy(Blocks.BEDROCK));
super(Properties.ofFullCopy(Blocks.BEDROCK).setId(ResourceKey.create(BuiltInRegistries.BLOCK.key(), b.id)));
builder = b;
registerDefaultState(stateDefinition.any().setValue(BlockStateProperties.POWERED, false));
}
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);
}
}
Loading
Loading