From d5d8fc291d54b0932718e4bdda0166eafb450c47 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Thu, 22 Oct 2026 20:12:54 +0100 Subject: [PATCH 1/4] Replace ItemStack.isSameItemSameTags with matches How have I never noticed this existed before :/. --- .../api/upgrades/UpgradeData.java | 26 +++++++------------ .../computercraft/impl/package-info.java | 2 ++ .../impl/AbstractUpgradeHolder.java | 6 ++--- .../computercraft/impl/UpgradeHandleImpl.java | 2 +- .../diskdrive/DiskDriveBlockEntity.java | 4 +-- .../shared/turtle/upgrades/TurtleTool.java | 2 +- .../test/shared/ItemStackMatcher.java | 2 +- .../test/shared/MinecraftEqualities.java | 2 +- .../computercraft/gametest/Recipe_Test.kt | 2 +- .../gametest/api/TestExtensions.kt | 2 +- 10 files changed, 22 insertions(+), 28 deletions(-) diff --git a/projects/common-api/src/main/java/dan200/computercraft/api/upgrades/UpgradeData.java b/projects/common-api/src/main/java/dan200/computercraft/api/upgrades/UpgradeData.java index db219f80d..e9bfe90bc 100644 --- a/projects/common-api/src/main/java/dan200/computercraft/api/upgrades/UpgradeData.java +++ b/projects/common-api/src/main/java/dan200/computercraft/api/upgrades/UpgradeData.java @@ -12,8 +12,6 @@ import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Unmodifiable; import org.jspecify.annotations.Nullable; -import java.util.Objects; - /** * An upgrade (i.e. a {@link ITurtleUpgrade}) and its current upgrade data. *

@@ -136,7 +134,11 @@ public final class UpgradeData { * @return This upgrade's item. */ public ItemStack getUpgradeItem() { - return (stack == null ? upgrade.getUpgradeItem(data) : stack).copy(); + return getStack().copy(); + } + + private ItemStack getStack() { + return stack == null ? upgrade().getUpgradeItem(data) : stack; } /** @@ -152,24 +154,14 @@ public final class UpgradeData { @Override public boolean equals(@Nullable Object obj) { if (obj == this) return true; - return obj instanceof UpgradeData that && upgrade == that.upgrade && data.equals(that.data) && areStacksEqual(stack, that.stack); - } - - private static boolean areStacksEqual(@Nullable ItemStack left, @Nullable ItemStack right) { - if (left == null) return right == null; - return right != null && ItemStack.isSameItemSameTags(left, right); + return obj instanceof UpgradeData that + && upgrade == that.upgrade && data.equals(that.data) + && ((stack == null && that.stack == null) || ItemStack.matches(getStack(), that.getStack())); } @Override public int hashCode() { - var result = upgrade.hashCode(); - result = 31 * result + data.hashCode(); - result = 31 * result + hashStack(stack); - return result; - } - - private static int hashStack(@Nullable ItemStack stack) { - return stack == null ? 0 : stack.getItem().hashCode() * 31 + Objects.hash(stack.getTag()); + return 31 * upgrade.hashCode() + data.hashCode(); } @Override diff --git a/projects/common-api/src/main/java/dan200/computercraft/impl/package-info.java b/projects/common-api/src/main/java/dan200/computercraft/impl/package-info.java index 89989c749..d7dea23f9 100644 --- a/projects/common-api/src/main/java/dan200/computercraft/impl/package-info.java +++ b/projects/common-api/src/main/java/dan200/computercraft/impl/package-info.java @@ -3,6 +3,8 @@ // SPDX-License-Identifier: MPL-2.0 @NullMarked +@ApiStatus.Internal package dan200.computercraft.impl; +import org.jetbrains.annotations.ApiStatus; import org.jspecify.annotations.NullMarked; diff --git a/projects/common/src/main/java/dan200/computercraft/impl/AbstractUpgradeHolder.java b/projects/common/src/main/java/dan200/computercraft/impl/AbstractUpgradeHolder.java index e5077ac3f..3ed4b6352 100644 --- a/projects/common/src/main/java/dan200/computercraft/impl/AbstractUpgradeHolder.java +++ b/projects/common/src/main/java/dan200/computercraft/impl/AbstractUpgradeHolder.java @@ -26,7 +26,7 @@ import org.jspecify.annotations.Nullable; public abstract class AbstractUpgradeHolder { private final U upgrade; private @Unmodifiable ItemStack item; - private CompoundTag data; + private final CompoundTag data; private @Nullable UpgradeData upgradeData; @@ -58,7 +58,7 @@ public abstract class AbstractUpgradeHolder { */ protected abstract void remove(); - public final @Unmodifiable U getUpgrade() { + public final U getUpgrade() { return upgrade; } @@ -66,7 +66,7 @@ public abstract class AbstractUpgradeHolder { return upgradeData != null ? upgradeData : (upgradeData = UpgradeData.of(upgrade, item, data)); } - public final ItemStack getItem() { + public final @Unmodifiable ItemStack getItem() { return item; } diff --git a/projects/common/src/main/java/dan200/computercraft/impl/UpgradeHandleImpl.java b/projects/common/src/main/java/dan200/computercraft/impl/UpgradeHandleImpl.java index 4153b8698..fedec7637 100644 --- a/projects/common/src/main/java/dan200/computercraft/impl/UpgradeHandleImpl.java +++ b/projects/common/src/main/java/dan200/computercraft/impl/UpgradeHandleImpl.java @@ -45,7 +45,7 @@ final class UpgradeHandleImpl implements UpgradeHandle ); } - if (ItemStack.isSameItemSameTags(holder.getItem(), newItem)) return; + if (ItemStack.matches(holder.getItem(), newItem)) return; if (!upgrade.getCraftingIngredient().test(newItem) || !upgrade.isItemSuitable(newItem)) { throw new IllegalArgumentException( diff --git a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java index cf8014c56..720e92520 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java @@ -165,7 +165,7 @@ public final class DiskDriveBlockEntity extends AbstractContainerBlockEntity { */ private synchronized void updateMedia() { var newStack = getDiskStack(); - if (ItemStack.isSameItemSameTags(newStack, media.stack())) return; + if (ItemStack.matches(newStack, media.stack())) return; var newMedia = MediaStack.of(newStack); @@ -236,7 +236,7 @@ public final class DiskDriveBlockEntity extends AbstractContainerBlockEntity { */ @GuardedBy("this") private void updateMediaStack(ItemStack stack, boolean immediate) { - if (ItemStack.isSameItemSameTags(media.stack(), stack)) return; + if (ItemStack.matches(media.stack(), stack)) return; media = new MediaStack(stack, media.media()); if (immediate) { diff --git a/projects/common/src/main/java/dan200/computercraft/shared/turtle/upgrades/TurtleTool.java b/projects/common/src/main/java/dan200/computercraft/shared/turtle/upgrades/TurtleTool.java index 55bd87a99..63fc4e442 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/turtle/upgrades/TurtleTool.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/turtle/upgrades/TurtleTool.java @@ -114,7 +114,7 @@ public class TurtleTool extends AbstractTurtleUpgrade.StackAware { if (!getCraftingIngredient().test(stack) || !isItemSuitable(stack)) return; // Early return if the item hasn't changed to avoid redundant syncs with the client. - if (ItemStack.isSameItemSameTags(stack, oldStack)) return; + if (ItemStack.matches(stack, oldStack)) return; upgrade.setItem(stack); } diff --git a/projects/common/src/testFixtures/java/dan200/computercraft/test/shared/ItemStackMatcher.java b/projects/common/src/testFixtures/java/dan200/computercraft/test/shared/ItemStackMatcher.java index ace7d0cee..04685ca1e 100644 --- a/projects/common/src/testFixtures/java/dan200/computercraft/test/shared/ItemStackMatcher.java +++ b/projects/common/src/testFixtures/java/dan200/computercraft/test/shared/ItemStackMatcher.java @@ -19,7 +19,7 @@ public class ItemStackMatcher extends TypeSafeMatcher { @Override protected boolean matchesSafely(ItemStack item) { - return ItemStack.isSameItemSameTags(item, stack) && item.getCount() == stack.getCount(); + return ItemStack.matches(item, stack); } @Override diff --git a/projects/common/src/testFixtures/java/dan200/computercraft/test/shared/MinecraftEqualities.java b/projects/common/src/testFixtures/java/dan200/computercraft/test/shared/MinecraftEqualities.java index 8686bb250..357471215 100644 --- a/projects/common/src/testFixtures/java/dan200/computercraft/test/shared/MinecraftEqualities.java +++ b/projects/common/src/testFixtures/java/dan200/computercraft/test/shared/MinecraftEqualities.java @@ -16,7 +16,7 @@ public class MinecraftEqualities { public static final StructuralEquality itemStack = new StructuralEquality<>() { @Override public boolean equals(ItemStack left, ItemStack right) { - return ItemStack.isSameItemSameTags(left, right) && left.getCount() == right.getCount(); + return ItemStack.matches(left, right); } @Override diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt index ae1ce8dae..68f77911f 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt @@ -127,7 +127,7 @@ class Recipe_Test { for (recipe in recipes) { // Ensure the result matches the input. val result = recipe.getResultItem(context.level.registryAccess()) - if (!ItemStack.isSameItemSameTags(result, item) || result.count != item.count) { + if (!ItemStack.matches(result, item)) { context.fail("Expected to craft ${item}${item.tag} but crafted ${result}${result.tag}") } diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt index 19ba6e481..1c42ea3ba 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt @@ -382,7 +382,7 @@ fun GameTestHelper.assertCraftable(items: List, result: ItemStack, wi val actualResult = recipe.get().assemble(container, level.registryAccess()) if (matchNbt) { - if (!ItemStack.isSameItemSameTags(actualResult, result) || actualResult.count != result.count) { + if (!ItemStack.matches(actualResult, result)) { fail("Expected $items to craft ${result}${result.tag}, got ${actualResult}${actualResult.tag}") } } else { From 41ad9cb71f7fc8e044bd7a3acff4defcf68905c9 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Thu, 22 Oct 2026 20:44:09 +0100 Subject: [PATCH 2/4] Update web emulator to use fetch for HTTP requests This will allow us to support streaming in the future. TeaVM doesn't have bindings for this right now, we should upstream this. --- .../java/cc/tweaked/web/builder/Builder.java | 2 +- .../java/cc/tweaked/web/http/HttpRequest.java | 91 +++++++++++-------- .../tweaked/web/js/abort/AbortController.java | 30 ++++++ .../cc/tweaked/web/js/abort/AbortSignal.java | 23 +++++ .../java/cc/tweaked/web/js/fetch/Headers.java | 29 ++++++ .../cc/tweaked/web/js/fetch/RequestInit.java | 50 ++++++++++ .../cc/tweaked/web/js/fetch/Response.java | 38 ++++++++ 7 files changed, 226 insertions(+), 37 deletions(-) create mode 100644 projects/web/src/main/java/cc/tweaked/web/js/abort/AbortController.java create mode 100644 projects/web/src/main/java/cc/tweaked/web/js/abort/AbortSignal.java create mode 100644 projects/web/src/main/java/cc/tweaked/web/js/fetch/Headers.java create mode 100644 projects/web/src/main/java/cc/tweaked/web/js/fetch/RequestInit.java create mode 100644 projects/web/src/main/java/cc/tweaked/web/js/fetch/Response.java diff --git a/projects/web/src/builder/java/cc/tweaked/web/builder/Builder.java b/projects/web/src/builder/java/cc/tweaked/web/builder/Builder.java index c43c1661a..3f741d565 100644 --- a/projects/web/src/builder/java/cc/tweaked/web/builder/Builder.java +++ b/projects/web/src/builder/java/cc/tweaked/web/builder/Builder.java @@ -72,7 +72,7 @@ public class Builder { // Then finally start the compiler! run(output, remapper, TeaVMTargetType.JAVASCRIPT, TeaVMOptimizationLevel.ADVANCED, minify); - run(output, remapper, TeaVMTargetType.WEBASSEMBLY_GC, TeaVMOptimizationLevel.SIMPLE, minify); + run(output, remapper, TeaVMTargetType.WEBASSEMBLY_GC, TeaVMOptimizationLevel.ADVANCED, minify); try (var runtime = Objects.requireNonNull(Builder.class.getClassLoader()).getResourceAsStream("org/teavm/backend/wasm/wasm-gc-module-runtime.js")) { if (runtime == null) throw new IllegalStateException("Cannot find WASM runtime"); diff --git a/projects/web/src/main/java/cc/tweaked/web/http/HttpRequest.java b/projects/web/src/main/java/cc/tweaked/web/http/HttpRequest.java index 03d3cb5c3..512d91d93 100644 --- a/projects/web/src/main/java/cc/tweaked/web/http/HttpRequest.java +++ b/projects/web/src/main/java/cc/tweaked/web/http/HttpRequest.java @@ -5,7 +5,11 @@ package cc.tweaked.web.http; import cc.tweaked.web.Main; -import com.google.common.base.Splitter; +import cc.tweaked.web.js.Console; +import cc.tweaked.web.js.abort.AbortController; +import cc.tweaked.web.js.fetch.Headers; +import cc.tweaked.web.js.fetch.RequestInit; +import cc.tweaked.web.js.fetch.Response; import dan200.computercraft.core.Logging; import dan200.computercraft.core.apis.IAPIEnvironment; import dan200.computercraft.core.apis.handles.ArrayByteChannel; @@ -20,8 +24,10 @@ import io.netty.handler.codec.http.HttpMethod; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.teavm.jso.JSObject; import org.teavm.jso.ajax.XMLHttpRequest; -import org.teavm.jso.typedarrays.ArrayBuffer; +import org.teavm.jso.core.JSError; +import org.teavm.jso.core.JSPromise; import org.teavm.jso.typedarrays.Int8Array; import java.nio.ByteBuffer; @@ -74,10 +80,10 @@ final class HttpRequest extends Resource { } } - private record Fetching(XMLHttpRequest request) implements State { + private record Fetching(AbortController abort) implements State { @Override public void close() { - request.abort(); + abort.abort(); } } @@ -88,18 +94,31 @@ final class HttpRequest extends Resource { private @Nullable State doRequest(HttpUri uri, HttpMethod method) { try { - var request = new XMLHttpRequest(); - request.setOnReadyStateChange(() -> onResponseStateChange(request)); - request.setResponseType("arraybuffer"); - var address = uri.getUri().toASCIIString(); - request.open(method.toString(), Main.CORS_PROXY.isEmpty() ? address : Main.CORS_PROXY.replace("{}", address)); + var abort = new AbortController(); + + var request = RequestInit.create(); + request.setMethod(method.name()); + if (postBuffer != null) request.setBody(Int8Array.fromJavaBuffer(postBuffer)); + request.setSignal(abort.getSignal()); + + var requestHeaders = new Headers(); for (var iterator = headers.iteratorAsString(); iterator.hasNext(); ) { var header = iterator.next(); - request.setRequestHeader(header.getKey(), header.getValue()); + requestHeaders.append(header.getKey(), header.getValue()); } - request.setRequestHeader("X-CC-Redirect", followRedirects ? "true" : "false"); - request.send(postBuffer == null ? null : Int8Array.fromJavaBuffer(postBuffer)); - return new Fetching(request); + requestHeaders.append("X-CC-Redirect", Boolean.toString(followRedirects)); + + var address = uri.getUri().toASCIIString(); + Response.fetch(Main.CORS_PROXY.isEmpty() ? address : Main.CORS_PROXY.replace("{}", address), request) + .then(this::handleResponse) + .catchError(e -> { + if (e instanceof JSError err) Console.error(err); + LOG.error(Logging.HTTP_ERROR, "Error in HTTP request", e); + close(Fetching.class, () -> queueFailure("Could not connect")); + return null; + }); + + return new Fetching(abort); } catch (Exception e) { LOG.error(Logging.HTTP_ERROR, "Error in HTTP request", e); queueFailure("Could not connect"); @@ -107,39 +126,39 @@ final class HttpRequest extends Resource { } } - private void onResponseStateChange(XMLHttpRequest request) { - if (request.getReadyState() != XMLHttpRequest.DONE) return; - if (request.getStatus() == 0) { + private @Nullable JSPromise handleResponse(Response response) { + if (response.getStatus() == 0) { close(Fetching.class, () -> queueFailure("Could not connect")); - return; + return null; } - var buffer = (ArrayBuffer) request.getResponse(); - SeekableByteChannel contents = new ArrayByteChannel(new Int8Array(buffer).copyToJavaArray()); - var reader = new ReadHandle(contents, binary); - Map responseHeaders = new HashMap<>(); - for (var header : Splitter.on("\r\n").split(request.getAllResponseHeaders())) { - var index = header.indexOf(':'); - if (index < 0) continue; - + response.getHeaders().forEach((v, k, h) -> { // Normalise the header (so "content-type" becomes "Content-Type") var upcase = true; - var headerBuilder = new StringBuilder(index); - for (var i = 0; i < index; i++) { - var c = header.charAt(i); + var headerBuilder = new StringBuilder(k.length()); + for (var i = 0; i < k.length(); i++) { + var c = k.charAt(i); headerBuilder.append(upcase ? Character.toUpperCase(c) : c); upcase = c == '-'; } - responseHeaders.put(headerBuilder.toString(), header.substring(index + 1).trim()); - } - var stream = new HttpResponseHandle(reader, request.getStatus(), request.getStatusText(), responseHeaders); + responseHeaders.put(headerBuilder.toString(), v); + }); - if (request.getStatus() >= 200 && request.getStatus() < 400) { - close(Fetching.class, () -> environment.queueEvent(SUCCESS_EVENT, address, stream)); - } else { - close(Fetching.class, () -> environment.queueEvent(FAILURE_EVENT, address, request.getStatusText(), stream)); - } + return response.bytes().then(body -> { + SeekableByteChannel contents = new ArrayByteChannel(new Int8Array(body).copyToJavaArray()); + var reader = new ReadHandle(contents, binary); + + var stream = new HttpResponseHandle(reader, response.getStatus(), response.getStatusText(), responseHeaders); + + if (response.getStatus() >= 200 && response.getStatus() < 400) { + close(Fetching.class, () -> environment.queueEvent(SUCCESS_EVENT, address, stream)); + } else { + close(Fetching.class, () -> environment.queueEvent(FAILURE_EVENT, address, response.getStatusText(), stream)); + } + + return null; + }); } private void queueFailure(String message) { diff --git a/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortController.java b/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortController.java new file mode 100644 index 000000000..911f69b87 --- /dev/null +++ b/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortController.java @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package cc.tweaked.web.js.abort; + +import org.teavm.jso.JSClass; +import org.teavm.jso.JSObject; +import org.teavm.jso.JSProperty; + +/** + * The {@link AbortController} interface represents a controller object that allows you to abort one or more Web + * requests as and when desired. + * + * @see MDN Reference + */ +@JSClass +public class AbortController implements JSObject { + public AbortController() { + } + + @JSProperty + public native AbortSignal getSignal(); + + public native void abort(); + + public native void abort(String reason); + + public native void abort(JSObject reason); +} diff --git a/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortSignal.java b/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortSignal.java new file mode 100644 index 000000000..9b292e9b9 --- /dev/null +++ b/projects/web/src/main/java/cc/tweaked/web/js/abort/AbortSignal.java @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package cc.tweaked.web.js.abort; + +import org.teavm.jso.JSObject; +import org.teavm.jso.JSProperty; +import org.teavm.jso.dom.events.EventTarget; + +/** + * The {@link AbortSignal} interface represents a signal object that allows you to communicate with an asynchronous + * operation (such as a fetch request) and abort it if required via an {@link AbortController} object. + * + * @see MDN Reference + */ +public interface AbortSignal extends EventTarget { + @JSProperty + boolean getAborted(); + + @JSProperty + JSObject getReason(); +} diff --git a/projects/web/src/main/java/cc/tweaked/web/js/fetch/Headers.java b/projects/web/src/main/java/cc/tweaked/web/js/fetch/Headers.java new file mode 100644 index 000000000..5efed587c --- /dev/null +++ b/projects/web/src/main/java/cc/tweaked/web/js/fetch/Headers.java @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package cc.tweaked.web.js.fetch; + +import org.teavm.jso.JSClass; +import org.teavm.jso.JSFunctor; +import org.teavm.jso.JSObject; + +/** + * The {@link Headers} interface of the Fetch API allows you to perform various actions on HTTP request and response + * headers. These actions include retrieving, setting, adding to, and removing headers from the list of the request's + * headers. + * + * @see MDN Reference + */ +@JSClass +public class Headers implements JSObject { + public native void append(String key, String value); + + public native void forEach(ForEachConsumer consumer); + + @JSFunctor + @FunctionalInterface + public interface ForEachConsumer extends JSObject { + void accept(String value, String key, Headers object); + } +} diff --git a/projects/web/src/main/java/cc/tweaked/web/js/fetch/RequestInit.java b/projects/web/src/main/java/cc/tweaked/web/js/fetch/RequestInit.java new file mode 100644 index 000000000..a017a96c2 --- /dev/null +++ b/projects/web/src/main/java/cc/tweaked/web/js/fetch/RequestInit.java @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package cc.tweaked.web.js.fetch; + +import cc.tweaked.web.js.abort.AbortSignal; +import org.teavm.jso.JSClass; +import org.teavm.jso.JSObject; +import org.teavm.jso.JSProperty; +import org.teavm.jso.core.JSMapLike; +import org.teavm.jso.core.JSObjects; +import org.teavm.jso.typedarrays.ArrayBuffer; +import org.teavm.jso.typedarrays.TypedArray; + +/** + * The properties that can be passed to {@link Response#fetch(String, RequestInit)}. + * + * @see MDN Reference + */ +@JSClass(transparent = true) +public abstract class RequestInit implements JSObject { + private RequestInit() { + } + + public static RequestInit create() { + return JSObjects.create(); + } + + @JSProperty + public native void setMethod(String method); + + @JSProperty + public native void setBody(String type); + + @JSProperty + public native void setBody(ArrayBuffer body); + + @JSProperty + public native void setBody(TypedArray body); + + @JSProperty + public native void setHeaders(Headers headers); + + @JSProperty + public native void setHeaders(JSMapLike headers); + + @JSProperty + public native void setSignal(AbortSignal signal); +} diff --git a/projects/web/src/main/java/cc/tweaked/web/js/fetch/Response.java b/projects/web/src/main/java/cc/tweaked/web/js/fetch/Response.java new file mode 100644 index 000000000..788bfd2be --- /dev/null +++ b/projects/web/src/main/java/cc/tweaked/web/js/fetch/Response.java @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package cc.tweaked.web.js.fetch; + +import org.teavm.jso.JSBody; +import org.teavm.jso.JSObject; +import org.teavm.jso.JSProperty; +import org.teavm.jso.core.JSPromise; +import org.teavm.jso.typedarrays.Uint8Array; + +/** + * The {@link Response} interface of the Fetch API represents the response to a request. + * + * @see MDN Reference + */ +public abstract class Response implements JSObject { + private Response() { + } + + @JSBody(params = "url", script = "return fetch(url)") + public static native JSPromise fetch(String url); + + @JSBody(params = { "url", "options" }, script = "return fetch(url, options)") + public static native JSPromise fetch(String url, RequestInit options); + + @JSProperty + public native int getStatus(); + + @JSProperty + public native String getStatusText(); + + @JSProperty + public native Headers getHeaders(); + + public native JSPromise bytes(); +} From d5da2f7d16a761dc1e914bdbb1c4a883a4d2c8c7 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Fri, 23 Oct 2026 00:16:49 +0100 Subject: [PATCH 3/4] Enable Nullaway in main common classes Bump to Nullaway 0.14.3, which fixes the issues we were seeing. This requires a couple of annotation changes in the code. Now that we've finally conquered null checking, it's clearly time to add another static analysis tool. Maybe I'll write my own, who knows. --- .../cc-tweaked.published-mod.gradle.kts | 7 -- gradle/libs.versions.toml | 2 +- projects/common/build.gradle.kts | 9 --- .../client/integration/ShaderMod.java | 5 +- .../computercraft/shared/ModRegistry.java | 2 +- .../command/arguments/ArgumentUtils.java | 11 +++- .../command/arguments/ComputerSelector.java | 2 +- .../command/arguments/RepeatArgumentType.java | 64 ++++++++----------- .../command/builder/CommandBuilder.java | 15 +---- .../shared/config/AddressRuleConfig.java | 5 +- .../diskdrive/DiskDriveBlockEntity.java | 2 +- .../peripheral/generic/SaturatedMethod.java | 3 +- .../modem/wired/WiredModemPeripheral.java | 8 +-- .../shared/turtle/core/TurtleBrain.java | 2 +- .../turtle/inventory/UpgradeContainer.java | 2 +- .../computercraft/shared/util/IDAssigner.java | 3 +- .../computercraft/shared/util/NBTUtil.java | 19 +++--- .../dan200/computercraft/core/apis/OSAPI.java | 3 +- .../core/apis/http/Resource.java | 1 - .../core/lua/VarargArguments.java | 10 ++- .../shared/platform/PlatformHelperImpl.java | 1 - 21 files changed, 72 insertions(+), 104 deletions(-) diff --git a/build-logic/base/src/main/kotlin/cc-tweaked.published-mod.gradle.kts b/build-logic/base/src/main/kotlin/cc-tweaked.published-mod.gradle.kts index 0faf2d9ee..a11c391e3 100644 --- a/build-logic/base/src/main/kotlin/cc-tweaked.published-mod.gradle.kts +++ b/build-logic/base/src/main/kotlin/cc-tweaked.published-mod.gradle.kts @@ -54,13 +54,6 @@ val verifyClassesMatch = tasks.register("verifyClassesMatch") check("MissingOverride", CheckSeverity.OFF) check("LoaderOverride", CheckSeverity.DEFAULT) check("MissingLoaderOverride", CheckSeverity.DEFAULT) - - // FIXME: We disable JSpecify in the common classes due to some NullAway bugs. As the two - // modes are not compatible, we have to disable NullAway entirely here. This can be removed - // when we bump to 0.14.3. - check("NullAway", CheckSeverity.WARN) - option("NullAway:JSpecifyMode", false) - option("NullAway:JSpecifyExperimental", false) } source = minecraftEmbeddedProjectResolved.map { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 415fb0d95..5a5b45d94 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,7 +70,7 @@ illuaminate = "0.1.0-83-g1131f68" lwjgl = "3.4.3" minotaur = "2.9.0" modDevGradle = "2.0.147" -nullAway = "0.14.2" +nullAway = "0.14.3" shadow = "9.6.1" spotless = "8.10.4" teavm = "0.16.0" diff --git a/projects/common/build.gradle.kts b/projects/common/build.gradle.kts index c0dc2b6fb..38f79b0be 100644 --- a/projects/common/build.gradle.kts +++ b/projects/common/build.gradle.kts @@ -8,7 +8,6 @@ import cc.tweaked.gradle.MergeTrees import cc.tweaked.gradle.annotationProcessorEverywhere import cc.tweaked.vanillaextract.configurations.Capabilities.clientClasses import cc.tweaked.vanillaextract.configurations.Capabilities.commonClasses -import net.ltgt.gradle.errorprone.errorprone plugins { `java-test-fixtures` @@ -71,14 +70,6 @@ dependencies { "cctJavadoc"(libs.cctJavadoc) } -// TODO: Enable JSpecify in the common classes. Unfortunately there's several bugs blocking us right now. -tasks.compileJava { - options.errorprone { - option("NullAway:JSpecifyMode", false) - option("NullAway:JSpecifyExperimental", false) - } -} - illuaminate { version = libs.versions.illuaminate } diff --git a/projects/common/src/client/java/dan200/computercraft/client/integration/ShaderMod.java b/projects/common/src/client/java/dan200/computercraft/client/integration/ShaderMod.java index 6a1d64847..9c6a236ad 100644 --- a/projects/common/src/client/java/dan200/computercraft/client/integration/ShaderMod.java +++ b/projects/common/src/client/java/dan200/computercraft/client/integration/ShaderMod.java @@ -7,7 +7,6 @@ package dan200.computercraft.client.integration; import dan200.computercraft.client.render.RenderTypes; import dan200.computercraft.client.render.text.DirectFixedWidthFontRenderer; import dan200.computercraft.client.render.vbo.DirectVertexBuffer; -import dan200.computercraft.core.util.Nullability; import java.nio.ByteBuffer; import java.util.Optional; @@ -49,10 +48,10 @@ public class ShaderMod { } private static final class Storage { - static final ShaderMod INSTANCE = Nullability.assertNonNull(ServiceLoader.load(Provider.class) + static final ShaderMod INSTANCE = ServiceLoader.load(Provider.class) .stream() .flatMap(x -> x.get().get().stream()) .findFirst() - .orElseGet(ShaderMod::new)); + .orElseGet(ShaderMod::new); } } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/ModRegistry.java b/projects/common/src/main/java/dan200/computercraft/shared/ModRegistry.java index d0e611e51..f9966612d 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/ModRegistry.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/ModRegistry.java @@ -364,7 +364,7 @@ public final class ModRegistry { static { register("tracking_field", TrackingFieldArgumentType.class, TrackingFieldArgumentType.metric()); register("computer", ComputerArgumentType.class, ComputerArgumentType.get()); - registerUnsafe("repeat", RepeatArgumentType.class, new RepeatArgumentType.Info()); + registerUnsafe("repeat", RepeatArgumentType.class, RepeatArgumentType.type()); } } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/ArgumentUtils.java b/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/ArgumentUtils.java index 2bd97ad28..8e342761f 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/ArgumentUtils.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/ArgumentUtils.java @@ -12,6 +12,7 @@ import dan200.computercraft.shared.platform.RegistryWrappers; import net.minecraft.commands.synchronization.ArgumentTypeInfo; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; import java.util.Objects; @@ -20,7 +21,7 @@ import java.util.Objects; * * @see net.minecraft.commands.synchronization.ArgumentUtils */ -public class ArgumentUtils { +class ArgumentUtils { public static > JsonObject serializeToJson(ArgumentTypeInfo.Template template) { var object = new JsonObject(); object.addProperty("type", "argument"); @@ -34,7 +35,9 @@ public class ArgumentUtils { } @SuppressWarnings("unchecked") - private static , T extends ArgumentTypeInfo.Template> void serializeToJson(JsonObject jsonObject, ArgumentTypeInfo argumentTypeInfo, ArgumentTypeInfo.Template template) { + private static , T extends ArgumentTypeInfo.@Nullable Template> void serializeToJson( + JsonObject jsonObject, ArgumentTypeInfo argumentTypeInfo, ArgumentTypeInfo.Template template + ) { argumentTypeInfo.serializeToJson((T) template, jsonObject); } @@ -43,7 +46,9 @@ public class ArgumentUtils { } @SuppressWarnings("unchecked") - private static , T extends ArgumentTypeInfo.Template> void serializeToNetwork(FriendlyByteBuf buffer, ArgumentTypeInfo type, ArgumentTypeInfo.Template template) { + private static , T extends ArgumentTypeInfo.@Nullable Template> void serializeToNetwork( + FriendlyByteBuf buffer, ArgumentTypeInfo type, ArgumentTypeInfo.Template template + ) { buffer.writeId(RegistryWrappers.COMMAND_ARGUMENT_TYPES, type); type.serializeToNetwork((T) template, buffer); } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/ComputerSelector.java b/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/ComputerSelector.java index cd934a137..7da0ae9a7 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/ComputerSelector.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/ComputerSelector.java @@ -374,7 +374,7 @@ public record ComputerSelector( CompletableFuture suggest(CommandContext source, SuggestionsBuilder builder); } - private static SuggestionProvider suggestComputers(Function renderer) { + private static SuggestionProvider suggestComputers(Function renderer) { return (anyContext, builder) -> suggestOnServer(anyContext, context -> { var remaining = builder.getRemaining(); for (var computer : ServerContext.get(context.getSource().getServer()).registry().getComputers()) { diff --git a/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/RepeatArgumentType.java b/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/RepeatArgumentType.java index 51ff6c5fc..1f0947eb1 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/RepeatArgumentType.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/command/arguments/RepeatArgumentType.java @@ -23,38 +23,26 @@ import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.function.BiConsumer; /** * Reads one argument multiple times. *

* Note that this must be the last element in an argument chain: in order to improve the quality of error messages, * we will always try to consume another argument while there is input remaining. - *

- * One problem with how parsers function, is that they must consume some input: and thus we * - * @param The type of each value returned - * @param The type of the inner parser. This will normally be a {@link List} or {@code T}. + * @param The type of each value returned. */ -public final class RepeatArgumentType implements ArgumentType> { - private final ArgumentType child; - private final BiConsumer, U> appender; - private final boolean flatten; +public final class RepeatArgumentType implements ArgumentType> { + private final ArgumentType child; private final SimpleCommandExceptionType some; - private RepeatArgumentType(ArgumentType child, BiConsumer, U> appender, boolean flatten, SimpleCommandExceptionType some) { + private RepeatArgumentType(ArgumentType child, SimpleCommandExceptionType some) { this.child = child; - this.appender = appender; - this.flatten = flatten; this.some = some; } - public static RepeatArgumentType some(ArgumentType appender, SimpleCommandExceptionType missing) { - return new RepeatArgumentType<>(appender, List::add, false, missing); - } - - public static RepeatArgumentType> someFlat(ArgumentType> appender, SimpleCommandExceptionType missing) { - return new RepeatArgumentType<>(appender, List::addAll, true, missing); + public static RepeatArgumentType some(ArgumentType appender, SimpleCommandExceptionType missing) { + return new RepeatArgumentType<>(appender, missing); } @Override @@ -66,7 +54,7 @@ public final class RepeatArgumentType implements ArgumentType> { if (!reader.canRead()) break; var startParse = reader.getCursor(); - appender.accept(out, child.parse(reader)); + out.add(child.parse(reader)); hadSome = true; if (reader.getCursor() == startParse) { @@ -109,47 +97,49 @@ public final class RepeatArgumentType implements ArgumentType> { return child.getExamples(); } - public static class Info implements ArgumentTypeInfo, Template> { + public static ArgumentTypeInfo, ? extends ArgumentTypeInfo.Template>> type() { + return new Info<>(); + } + + private static final class Info implements ArgumentTypeInfo, Template> { @Override - public void serializeToNetwork(RepeatArgumentType.Template arg, FriendlyByteBuf buf) { - buf.writeBoolean(arg.flatten); + public void serializeToNetwork(RepeatArgumentType.Template arg, FriendlyByteBuf buf) { ArgumentUtils.serializeToNetwork(buf, arg.child); buf.writeComponent(ArgumentUtils.getMessage(arg.some)); } @Override - public RepeatArgumentType.Template deserializeFromNetwork(FriendlyByteBuf buf) { - var isList = buf.readBoolean(); - var child = ArgumentUtils.deserialize(buf); + public RepeatArgumentType.Template deserializeFromNetwork(FriendlyByteBuf buf) { + @SuppressWarnings("unchecked") + var child = (ArgumentTypeInfo.Template>) ArgumentUtils.deserialize(buf); + var message = buf.readComponent(); - return new RepeatArgumentType.Template(this, child, isList, new SimpleCommandExceptionType(message)); + return new RepeatArgumentType.Template<>(this, child, new SimpleCommandExceptionType(message)); } @Override - public RepeatArgumentType.Template unpack(RepeatArgumentType argumentType) { - return new RepeatArgumentType.Template(this, ArgumentTypeInfos.unpack(argumentType.child), argumentType.flatten, argumentType.some); + public RepeatArgumentType.Template unpack(RepeatArgumentType argumentType) { + return new RepeatArgumentType.Template(this, ArgumentTypeInfos.unpack(argumentType.child), argumentType.some); } @Override - public void serializeToJson(RepeatArgumentType.Template arg, JsonObject json) { - json.addProperty("flatten", arg.flatten); + public void serializeToJson(RepeatArgumentType.Template arg, JsonObject json) { json.add("child", ArgumentUtils.serializeToJson(arg.child)); json.addProperty("error", Component.Serializer.toJson(ArgumentUtils.getMessage(arg.some))); } } - public record Template( - Info info, ArgumentTypeInfo.Template child, boolean flatten, SimpleCommandExceptionType some - ) implements ArgumentTypeInfo.Template> { + private record Template( + Info info, ArgumentTypeInfo.Template> child, SimpleCommandExceptionType some + ) implements ArgumentTypeInfo.Template> { @Override - @SuppressWarnings({ "unchecked", "rawtypes" }) - public RepeatArgumentType instantiate(CommandBuildContext commandBuildContext) { + public RepeatArgumentType instantiate(CommandBuildContext commandBuildContext) { var child = child().instantiate(commandBuildContext); - return flatten ? RepeatArgumentType.someFlat((ArgumentType) child, some()) : RepeatArgumentType.some(child, some()); + return RepeatArgumentType.some(child, some()); } @Override - public ArgumentTypeInfo, ?> type() { + public ArgumentTypeInfo, ?> type() { return info; } } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/command/builder/CommandBuilder.java b/projects/common/src/main/java/dan200/computercraft/shared/command/builder/CommandBuilder.java index 5f9372b71..99d761090 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/command/builder/CommandBuilder.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/command/builder/CommandBuilder.java @@ -17,7 +17,6 @@ import org.jspecify.annotations.Nullable; import java.util.ArrayList; import java.util.List; import java.util.function.Predicate; -import java.util.function.Supplier; import static dan200.computercraft.shared.command.Exceptions.ARGUMENT_EXPECTED; import static dan200.computercraft.shared.command.builder.HelpingArgumentBuilder.literal; @@ -58,27 +57,19 @@ public class CommandBuilder implements CommandNodeBuilder> { } public CommandNodeBuilder>> argManyValue(String name, ArgumentType type, List empty) { - return argMany(name, type, () -> empty); + return argMany(name, RepeatArgumentType.some(type, ARGUMENT_EXPECTED), empty); } public CommandNodeBuilder>> argManyValue(String name, ArgumentType type, T defaultValue) { return argManyValue(name, type, List.of(defaultValue)); } - public CommandNodeBuilder>> argMany(String name, ArgumentType type, Supplier> empty) { - return argMany(name, RepeatArgumentType.some(type, ARGUMENT_EXPECTED), empty); - } - - public CommandNodeBuilder>> argManyFlatten(String name, ArgumentType> type, Supplier> empty) { - return argMany(name, RepeatArgumentType.someFlat(type, ARGUMENT_EXPECTED), empty); - } - - private CommandNodeBuilder>> argMany(String name, RepeatArgumentType type, Supplier> empty) { + private CommandNodeBuilder>> argMany(String name, RepeatArgumentType type, List empty) { if (args.isEmpty()) throw new IllegalStateException("Cannot have empty arg chain builder"); return command -> { // The node for no arguments - var tail = setupTail(ctx -> command.run(ctx, empty.get())); + var tail = setupTail(ctx -> command.run(ctx, empty)); // The node for one or more arguments ArgumentBuilder moreArg = RequiredArgumentBuilder diff --git a/projects/common/src/main/java/dan200/computercraft/shared/config/AddressRuleConfig.java b/projects/common/src/main/java/dan200/computercraft/shared/config/AddressRuleConfig.java index 5ee6ce442..7fc658449 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/config/AddressRuleConfig.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/config/AddressRuleConfig.java @@ -141,13 +141,14 @@ class AddressRuleConfig { } private static > T parseEnum(String field, Class klass, String x) { - for (var value : klass.getEnumConstants()) { + var values = Objects.requireNonNull(klass.getEnumConstants()); + for (var value : values) { if (value.name().equalsIgnoreCase(x)) return value; } throw new InvalidRuleException(String.format( "Field '%s' should be one of %s, but is '%s'.", - field, Arrays.stream(klass.getEnumConstants()).map(Enum::name).toList(), x + field, Arrays.stream(values).map(Enum::name).toList(), x )); } } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java index 720e92520..461a2d604 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java @@ -78,7 +78,7 @@ public final class DiskDriveBlockEntity extends AbstractContainerBlockEntity { private boolean recordPlaying = false; // In order to avoid main-thread calls in the peripheral, we set flags to mark which operation should be performed, // then read them when ticking. - private final AtomicReference recordQueued = new AtomicReference<>(null); + private final AtomicReference<@Nullable RecordCommand> recordQueued = new AtomicReference<>(null); private final AtomicBoolean ejectQueued = new AtomicBoolean(false); /** diff --git a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/generic/SaturatedMethod.java b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/generic/SaturatedMethod.java index ce978718c..0a69c750f 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/generic/SaturatedMethod.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/generic/SaturatedMethod.java @@ -10,6 +10,7 @@ import dan200.computercraft.api.lua.LuaException; import dan200.computercraft.api.lua.MethodResult; import dan200.computercraft.api.peripheral.IComputerAccess; import dan200.computercraft.core.methods.PeripheralMethod; +import org.jspecify.annotations.Nullable; /** * A {@link PeripheralMethod} along with the method's target. @@ -34,7 +35,7 @@ final class SaturatedMethod { } @Override - public boolean equals(Object obj) { + public boolean equals(@Nullable Object obj) { if (obj == this) return true; if (!(obj instanceof SaturatedMethod other)) return false; diff --git a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/modem/wired/WiredModemPeripheral.java b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/modem/wired/WiredModemPeripheral.java index 7370b76be..031a402eb 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/modem/wired/WiredModemPeripheral.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/modem/wired/WiredModemPeripheral.java @@ -389,7 +389,7 @@ public abstract class WiredModemPeripheral extends ModemPeripheral implements Wi public synchronized @Nullable String mount(String desiredLocation, Mount mount) { if (!attached) throw new NotAttachedException(); var mounted = computer.mount(desiredLocation, mount, name); - mounts.add(mounted); + if (mounted != null) mounts.add(mounted); return mounted; } @@ -397,7 +397,7 @@ public abstract class WiredModemPeripheral extends ModemPeripheral implements Wi public synchronized @Nullable String mount(String desiredLocation, Mount mount, String driveName) { if (!attached) throw new NotAttachedException(); var mounted = computer.mount(desiredLocation, mount, driveName); - mounts.add(mounted); + if (mounted != null) mounts.add(mounted); return mounted; } @@ -405,7 +405,7 @@ public abstract class WiredModemPeripheral extends ModemPeripheral implements Wi public synchronized @Nullable String mountWritable(String desiredLocation, WritableMount mount) { if (!attached) throw new NotAttachedException(); var mounted = computer.mountWritable(desiredLocation, mount, name); - mounts.add(mounted); + if (mounted != null) mounts.add(mounted); return mounted; } @@ -413,7 +413,7 @@ public abstract class WiredModemPeripheral extends ModemPeripheral implements Wi public synchronized @Nullable String mountWritable(String desiredLocation, WritableMount mount, String driveName) { if (!attached) throw new NotAttachedException(); var mounted = computer.mountWritable(desiredLocation, mount, driveName); - mounts.add(mounted); + if (mounted != null) mounts.add(mounted); return mounted; } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/turtle/core/TurtleBrain.java b/projects/common/src/main/java/dan200/computercraft/shared/turtle/core/TurtleBrain.java index b82ebfa37..11b481752 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/turtle/core/TurtleBrain.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/turtle/core/TurtleBrain.java @@ -739,7 +739,7 @@ public class TurtleBrain implements TurtleAccessInternal { } @Override - public MethodResult resume(Object[] response) { + public MethodResult resume(@Nullable Object[] response) { if (response.length < 3 || !(response[1] instanceof Number id) || !(response[2] instanceof Boolean)) { return pull; } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/turtle/inventory/UpgradeContainer.java b/projects/common/src/main/java/dan200/computercraft/shared/turtle/inventory/UpgradeContainer.java index 88172d182..8f9e07cc9 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/turtle/inventory/UpgradeContainer.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/turtle/inventory/UpgradeContainer.java @@ -29,7 +29,7 @@ class UpgradeContainer implements Container { private final ITurtleAccess turtle; - private final List> lastUpgrade = Arrays.asList(null, null); + private final List<@Nullable UpgradeData> lastUpgrade = Arrays.asList(null, null); private final NonNullList lastStack = NonNullList.withSize(2, ItemStack.EMPTY); UpgradeContainer(ITurtleAccess turtle) { diff --git a/projects/common/src/main/java/dan200/computercraft/shared/util/IDAssigner.java b/projects/common/src/main/java/dan200/computercraft/shared/util/IDAssigner.java index d2d3408eb..04f892537 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/util/IDAssigner.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/util/IDAssigner.java @@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.HashMap; import java.util.Map; +import java.util.Objects; public final class IDAssigner { private static final Logger LOG = LoggerFactory.getLogger(IDAssigner.class); @@ -83,7 +84,7 @@ public final class IDAssigner { } } else { try { - Files.createDirectories(idFile.getParent()); + Files.createDirectories(Objects.requireNonNull(idFile.getParent())); } catch (IOException e) { LOG.error("Cannot create owning directory, IDs will not be persisted", e); } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/util/NBTUtil.java b/projects/common/src/main/java/dan200/computercraft/shared/util/NBTUtil.java index 43dc6c3c3..96b218673 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/util/NBTUtil.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/util/NBTUtil.java @@ -69,15 +69,7 @@ public final class NBTUtil { case Tag.TAG_BYTE, Tag.TAG_SHORT, Tag.TAG_INT, Tag.TAG_LONG -> ((NumericTag) tag).getAsLong(); case Tag.TAG_FLOAT, Tag.TAG_DOUBLE -> ((NumericTag) tag).getAsDouble(); case Tag.TAG_STRING -> tag.getAsString(); - case Tag.TAG_COMPOUND -> { - var compound = (CompoundTag) tag; - Map map = new HashMap<>(compound.size()); - for (var key : compound.getAllKeys()) { - var value = toLua(compound.get(key)); - if (value != null) map.put(key, value); - } - yield map; - } + case Tag.TAG_COMPOUND -> toLua((CompoundTag) tag); case Tag.TAG_LIST -> ((ListTag) tag).stream().map(NBTUtil::toLua).toList(); case Tag.TAG_BYTE_ARRAY -> { var array = ((ByteArrayTag) tag).getAsByteArray(); @@ -91,6 +83,15 @@ public final class NBTUtil { }; } + public static Map toLua(CompoundTag tag) { + Map map = new HashMap<>(tag.size()); + for (var key : tag.getAllKeys()) { + var value = toLua(tag.get(key)); + if (value != null) map.put(key, value); + } + return map; + } + @Nullable public static String getNBTHash(@Nullable CompoundTag tag) { if (tag == null) return null; diff --git a/projects/core/src/main/java/dan200/computercraft/core/apis/OSAPI.java b/projects/core/src/main/java/dan200/computercraft/core/apis/OSAPI.java index b68718648..3726e66e4 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/apis/OSAPI.java +++ b/projects/core/src/main/java/dan200/computercraft/core/apis/OSAPI.java @@ -8,7 +8,6 @@ import dan200.computercraft.api.lua.IArguments; import dan200.computercraft.api.lua.ILuaAPI; import dan200.computercraft.api.lua.LuaException; import dan200.computercraft.api.lua.LuaFunction; -import dan200.computercraft.core.util.Nullability; import dan200.computercraft.core.util.StringUtil; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; @@ -430,7 +429,7 @@ public class OSAPI implements ILuaAPI { @LuaFunction public final Object date(Optional formatA, Optional timeA) throws LuaException { var format = formatA.orElse("%c"); - long time = Nullability.assertNonNull(timeA.orElseGet(() -> Instant.now().getEpochSecond())); + long time = timeA.orElseGet(() -> Instant.now().getEpochSecond()); var instant = Instant.ofEpochSecond(time); ZonedDateTime date; diff --git a/projects/core/src/main/java/dan200/computercraft/core/apis/http/Resource.java b/projects/core/src/main/java/dan200/computercraft/core/apis/http/Resource.java index 6173dc8f2..791b0445f 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/apis/http/Resource.java +++ b/projects/core/src/main/java/dan200/computercraft/core/apis/http/Resource.java @@ -86,7 +86,6 @@ public abstract class Resource, S extends Resource.Reso return state.lock(); } - @SuppressWarnings("NullAway") // FIXME: NullAway doesn't support generics. @CheckReturnValue private boolean setState(LockedReference.ReadWriteHandle<@Nullable S> stateHandle, @Nullable S newState) { var oldState = stateHandle.get(); diff --git a/projects/core/src/main/java/dan200/computercraft/core/lua/VarargArguments.java b/projects/core/src/main/java/dan200/computercraft/core/lua/VarargArguments.java index 25a5cf1a0..396fb80c9 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/lua/VarargArguments.java +++ b/projects/core/src/main/java/dan200/computercraft/core/lua/VarargArguments.java @@ -27,7 +27,7 @@ final class VarargArguments implements IArguments { private volatile boolean closed; private final VarargArguments root; - private @Nullable ArraySlice cache; + private @Nullable ArraySlice<@Nullable Object> cache; private @Nullable ArraySlice typeNames; private boolean escapes; @@ -231,14 +231,12 @@ final class VarargArguments implements IArguments { } @SuppressWarnings("ArrayRecordComponent") - private record ArraySlice(@Nullable T[] array, int offset) { - // FIXME: We should be able to remove the @Nullables if we update NullAway. - - @Nullable T get(int index) { + private record ArraySlice(T[] array, int offset) { + T get(int index) { return array[offset + index]; } - void set(int index, @Nullable T value) { + void set(int index, T value) { array[offset + index] = value; } diff --git a/projects/fabric/src/main/java/dan200/computercraft/shared/platform/PlatformHelperImpl.java b/projects/fabric/src/main/java/dan200/computercraft/shared/platform/PlatformHelperImpl.java index 8cf95cbd9..0969c6b3d 100644 --- a/projects/fabric/src/main/java/dan200/computercraft/shared/platform/PlatformHelperImpl.java +++ b/projects/fabric/src/main/java/dan200/computercraft/shared/platform/PlatformHelperImpl.java @@ -335,7 +335,6 @@ public class PlatformHelperImpl implements PlatformHelper { } @Override - @SuppressWarnings("NullAway") // NullAway doesn't like the null here. public @Nullable IMedia getMedia(ItemStack stack) { return MediaLookup.get().find(stack, null); } From c7482783313c0fa31c0ad39bb44d8e9902435190 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Sun, 25 Oct 2026 12:11:44 +0000 Subject: [PATCH 4/4] Export tests reports via OTR - Configure JUnit tests to write OTR files. - Add a custom OTR reporter to the gametest framework. - Update parse-reports to ingest OTR files. - Gather all report files and publish them as an artifact. --- .github/workflows/main-ci.yml | 10 ++ .github/workflows/make-doc.yml | 1 + .../cc-tweaked.java-convention.gradle.kts | 8 ++ gradle/libs.versions.toml | 11 +- projects/common/build.gradle.kts | 1 + .../computercraft/gametest/core/TestHooks.kt | 4 +- .../gametest/core/TestReporters.kt | 111 ++++++++---------- .../computercraft/gametest/core/TestMod.java | 2 +- projects/forge/build.gradle.kts | 1 + .../computercraft/gametest/core/TestMod.java | 2 +- tools/parse-reports.py | 69 +++++++---- 11 files changed, 128 insertions(+), 92 deletions(-) diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index fab37f4a7..678db0fb6 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -59,6 +59,16 @@ jobs: run: ./tools/parse-reports.py if: ${{ failure() }} + - name: 📤 Upload Test Reports + uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: Tests + path: | + projects/*/build/test-results/test/open-test-report.xml + projects/*/build/test-results/runGametest.xml + retention-days: 1 + build-core: strategy: fail-fast: false diff --git a/.github/workflows/make-doc.yml b/.github/workflows/make-doc.yml index 29632d43f..4b66261ae 100644 --- a/.github/workflows/make-doc.yml +++ b/.github/workflows/make-doc.yml @@ -33,3 +33,4 @@ jobs: with: name: Documentation path: ./projects/web/build/site/ + retention-days: 1 diff --git a/build-logic/base/src/main/kotlin/cc-tweaked.java-convention.gradle.kts b/build-logic/base/src/main/kotlin/cc-tweaked.java-convention.gradle.kts index f61fd99a1..e7063b12c 100644 --- a/build-logic/base/src/main/kotlin/cc-tweaked.java-convention.gradle.kts +++ b/build-logic/base/src/main/kotlin/cc-tweaked.java-convention.gradle.kts @@ -172,6 +172,14 @@ tasks.test { testLogging { events("skipped", "failed") } + + // Enable open test reporting + jvmArgumentProviders.add { + listOf( + "-Djunit.platform.reporting.open.xml.enabled=true", + "-Djunit.platform.reporting.output.dir=${reports.junitXml.outputLocation.getAbsolutePath()}", + ) + } } tasks.withType(JacocoReport::class.java).configureEach { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a5b45d94..c162f0b45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,10 +52,11 @@ create-fabric = "6.0.7.0+mc1.20.1-build.1716" # Testing hamcrest = "3.0" +jmh = "1.37" jqwik = "1.10.1" junit = "6.1.3" junitPlatform = "6.1.3" -jmh = "1.37" +openTestReporting = "0.3.0" # Build tools cctJavadoc = "1.9.0" @@ -127,15 +128,17 @@ sodium = { module = "maven.modrinth:sodium", version.ref = "sodium" } # Testing hamcrest = { module = "org.hamcrest:hamcrest", version.ref = "hamcrest" } +jmh = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" } +jmh-processor = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" } jqwik-api = { module = "net.jqwik:jqwik-api", version.ref = "jqwik" } jqwik-engine = { module = "net.jqwik:jqwik-engine", version.ref = "jqwik" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" } +junit-platform-reporting = { module = "org.junit.platform:junit-platform-reporting", version.ref = "junitPlatform" } +openTestReporting = { module = "org.opentest4j.reporting:open-test-reporting-events", version.ref = "openTestReporting" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } -jmh = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" } -jmh-processor = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" } # LWJGL lwjgl-bom = { module = "org.lwjgl:lwjgl-bom", version.ref = "lwjgl" } @@ -190,7 +193,7 @@ externalMods-fabric-runtime = ["jei-fabric", "modmenu"] # Testing test = ["junit-jupiter-api", "junit-jupiter-params", "hamcrest", "jqwik-api"] -testRuntime = ["junit-jupiter-engine", "junit-platform-launcher", "jqwik-engine"] +testRuntime = ["junit-jupiter-engine", "junit-platform-launcher", "junit-platform-reporting", "jqwik-engine"] # Build tools teavm-api = ["teavm-jso", "teavm-jso-apis", "teavm-platform", "teavm-classlib", "teavm-extension-apis"] diff --git a/projects/common/build.gradle.kts b/projects/common/build.gradle.kts index 38f79b0be..0ee3524f6 100644 --- a/projects/common/build.gradle.kts +++ b/projects/common/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { testModCompileOnly(libs.mixin) testModImplementation(testFixtures(project(":core"))) testModImplementation(testFixtures(project(":common"))) + testModImplementation(libs.openTestReporting) testModImplementation(libs.bundles.kotlin) "cctJavadoc"(libs.cctJavadoc) diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt index b0cd27c73..5771ec01c 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt @@ -47,7 +47,7 @@ object TestHooks { var structureManager: StructureTemplateManager? = null @JvmStatic - fun init() { + fun init(loader: String) { ServerContext.luaMachine = ManagedComputers ComputerCraftAPI.registerAPIFactory(::TestAPI) StructureUtils.testStructuresDir = sourceDir.resolve("structures").toString() @@ -58,7 +58,7 @@ object TestHooks { try { GlobalTestReporter.replaceWith( MultiTestReporter( - JunitTestReporter(File(outputPath)), + OtrTestReporter("$loader gametests", File(outputPath)), LogTestReporter(), ), ) diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt index 94dab5d1d..8eeaf5200 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt @@ -4,22 +4,19 @@ package dan200.computercraft.gametest.core -import com.google.common.base.Stopwatch import net.minecraft.gametest.framework.GameTestInfo -import net.minecraft.gametest.framework.JUnitLikeTestReporter import net.minecraft.gametest.framework.TestReporter -import org.w3c.dom.Document -import org.w3c.dom.Element -import java.io.* -import java.nio.file.Files.createDirectories +import org.opentest4j.reporting.events.api.DocumentWriter +import org.opentest4j.reporting.events.api.NamespaceRegistry +import org.opentest4j.reporting.events.core.CoreFactory +import org.opentest4j.reporting.events.java.JavaFactory +import org.opentest4j.reporting.events.root.Events +import org.opentest4j.reporting.events.root.Finished +import org.opentest4j.reporting.events.root.RootFactory +import org.opentest4j.reporting.schema.Namespace +import java.io.File import java.time.Instant -import java.time.format.DateTimeFormatter -import java.util.concurrent.TimeUnit -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.transform.TransformerException -import javax.xml.transform.TransformerFactory -import javax.xml.transform.dom.DOMSource -import javax.xml.transform.stream.StreamResult +import org.opentest4j.reporting.events.core.Result as OtrResult /** * A test reporter which delegates to a list of other reporters. @@ -41,74 +38,62 @@ class MultiTestReporter(private val reporters: List) : TestReporte } /** - * Reports tests to a JUnit XML file. This is equivalent to [JUnitLikeTestReporter], except it ensures the destination - * directory exists and includes the stack trace in the error. + * A test reporter that writes [Open Test Reporting](https://github.com/ota4j-team/open-test-reporting/) test reports. + * + * @param name The name of this test suite. + * @param destination The destination file to write to. */ -open class JunitTestReporter(private val destination: File) : TestReporter { - private val document: Document - private val testSuite: Element - private val stopwatch: Stopwatch = Stopwatch.createStarted() +class OtrTestReporter(name: String, destination: File) : TestReporter { + private val writer: DocumentWriter + private var hasErrors = false init { - document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() + val namespaceRegistry = NamespaceRegistry.builder(Namespace.REPORTING_CORE) + .add("e", Namespace.REPORTING_EVENTS) + .add("java", Namespace.REPORTING_JAVA) + .build() - testSuite = document.createElement("testsuite") - testSuite.setAttribute("timestamp", DateTimeFormatter.ISO_INSTANT.format(Instant.now())) + writer = Events.createDocumentWriter(namespaceRegistry, destination.toPath()) - val container = document.createElement("testsuite") - container.appendChild(testSuite) - document.appendChild(container) + writer.append(RootFactory.started(SUITE_ID, Instant.now(), name)) } - private fun createTestCase(testInfo: GameTestInfo, name: String?): Element { - val testCase = document.createElement("testcase") - testCase.setAttribute("name", name) - testCase.setAttribute("classname", testInfo.structureName) - testCase.setAttribute("time", (testInfo.runTime.toDouble() / 1000.0).toString()) - testSuite.appendChild(testCase) - return testCase + private fun report(testInfo: GameTestInfo, configure: (Finished) -> Unit) { + val finished = Instant.now() + val started = finished.minusMillis(testInfo.runTime) + writer.append(RootFactory.started(testInfo.testName, started, testInfo.testName)) { + it.withParentId(SUITE_ID) + } + writer.append(RootFactory.finished(testInfo.testName, finished), configure) } override fun onTestFailed(testInfo: GameTestInfo) { - val error = testInfo.error!! - val result: Element - if (testInfo.isRequired) { - result = document.createElement("failure") - result.setAttribute("message", error.message) - result.setAttribute("type", error.javaClass.name) + report(testInfo) { report -> + if (testInfo.isRequired) hasErrors = true - val writer = StringWriter() - error.printStackTrace(PrintWriter(writer)) - result.textContent = writer.toString() - } else { - result = document.createElement("skipped") - result.setAttribute("message", error.message) + val status = if (testInfo.isRequired) OtrResult.Status.FAILED else OtrResult.Status.ABORTED + report.append(CoreFactory.result(status)) { result -> + val error = testInfo.error + if (error != null) { + result.append(CoreFactory.reason(error.message ?: "unknown")) + result.append(JavaFactory.throwable(error)) + } + } } - - createTestCase(testInfo, testInfo.testName).appendChild(result) } override fun onTestSuccess(testInfo: GameTestInfo) { - createTestCase(testInfo, testInfo.testName) + report(testInfo) { it.append(CoreFactory.result(OtrResult.Status.SUCCESSFUL)) } } override fun finish() { - stopwatch.stop() - testSuite.setAttribute( - "time", - (stopwatch.elapsed(TimeUnit.MILLISECONDS).toDouble() / 1000.0).toString(), - ) - - try { - try { - createDirectories(destination.toPath().parent) - } catch (e: IOException) { - throw UncheckedIOException("Failed to create parent directory", e) - } - - TransformerFactory.newInstance().newTransformer().transform(DOMSource(document), StreamResult(destination)) - } catch (transformerException: TransformerException) { - throw Error("Couldn't save test report", transformerException) + writer.append(RootFactory.finished(SUITE_ID, Instant.now())) { + it.append(CoreFactory.result(if (hasErrors) OtrResult.Status.FAILED else OtrResult.Status.SUCCESSFUL)) } + writer.close() + } + + companion object { + private const val SUITE_ID = "_gametests_" } } diff --git a/projects/fabric/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java b/projects/fabric/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java index 314d56003..4be7b4a91 100644 --- a/projects/fabric/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java +++ b/projects/fabric/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java @@ -21,7 +21,7 @@ import net.minecraft.resources.ResourceLocation; public class TestMod implements ModInitializer, ClientModInitializer { @Override public void onInitialize() { - TestHooks.init(); + TestHooks.init("Fabric"); var phase = new ResourceLocation(ComputerCraftAPI.MOD_ID, "test_mod"); ServerLifecycleEvents.SERVER_STARTED.addPhaseOrdering(Event.DEFAULT_PHASE, phase); diff --git a/projects/forge/build.gradle.kts b/projects/forge/build.gradle.kts index 0d63ccf61..5444fde53 100644 --- a/projects/forge/build.gradle.kts +++ b/projects/forge/build.gradle.kts @@ -204,6 +204,7 @@ dependencies { // Ensure our test fixture dependencies are on the classpath "testAdditionalRuntimeClasspath"(libs.bundles.kotlin) "testAdditionalRuntimeClasspath"(libs.bundles.test) + "testAdditionalRuntimeClasspath"(libs.openTestReporting) testImplementation(testFixtures(project(":common"))) testRuntimeOnly(libs.bundles.testRuntime) diff --git a/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java b/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java index 95bfab7aa..db5ad17fe 100644 --- a/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java +++ b/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java @@ -22,7 +22,7 @@ import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; @Mod("cctest") public class TestMod { public TestMod() { - TestHooks.init(); + TestHooks.init("Forge"); var bus = MinecraftForge.EVENT_BUS; bus.addListener(EventPriority.LOW, (ServerStartedEvent e) -> TestHooks.onServerStarted(e.getServer())); diff --git a/tools/parse-reports.py b/tools/parse-reports.py index 44907d7b7..970803ffe 100755 --- a/tools/parse-reports.py +++ b/tools/parse-reports.py @@ -11,11 +11,11 @@ See https://github.com/actions/toolkit/blob/master/docs/problem-matchers.md and https://github.com/actions/toolkit/blob/master/docs/commands.md. """ +import dataclasses import os.path import pathlib import re import xml.etree.ElementTree as ET -from typing import Optional, Tuple LUA_ERROR_LOCATION = re.compile(r"^\s+(/[\w./-]+):(\d+):", re.MULTILINE) JAVA_LUA_ERROR_LOCATION = re.compile(r"^java.lang.IllegalStateException: (/[\w./-]+):(\d+):") @@ -42,10 +42,8 @@ PROJECT_LOCATIONS = [ "projects/forge", ] -TEST_REPORTS = [] - -def find_file(path: str) -> Optional[str]: +def find_file(path: str) -> str | None: while len(path) > 0 and path[0] == "/": path = path[1:] @@ -58,7 +56,7 @@ def find_file(path: str) -> Optional[str]: return None -def find_location(message: str) -> Optional[Tuple[str, str]]: +def find_location(message: str) -> tuple[str, str] | None: location = LUA_ERROR_LOCATION.search(message) if location: file = find_file(location[1]) @@ -79,31 +77,60 @@ def find_location(message: str) -> Optional[Tuple[str, str]]: return None +_OTR_CORE = "https://schemas.opentest4j.org/reporting/core/0.2.0" +_OTR_EVENTS = "https://schemas.opentest4j.org/reporting/events/0.2.0" +_OTR_JAVA = "https://schemas.opentest4j.org/reporting/java/0.2.0" + + def _parse_junit_file(path: pathlib.Path): - for testcase in ET.parse(path).findall(".//testcase"): - for result in testcase: - if result.tag == "skipped": + tests: dict[str, str] = {} + tree = ET.parse(path).getroot() + for event in tree: + if event.tag == f"{{{_OTR_CORE}}}infrastructure": + continue + elif event.tag == f"{{{_OTR_EVENTS}}}started": + test_id: str = event.get("id") + test_name: str = event.get("name") + if (parent_id := event.get("parentId")) is not None: + test_name = tests[parent_id] + " ⊳ " + test_name + + tests[test_id] = test_name + elif event.tag == f"{{{_OTR_EVENTS}}}finished": + test_id: str = event.get("id") + test_name = tests[test_id] + + result = event.find(f"./{{{_OTR_CORE}}}result") + if result is None: continue - name = f'{testcase.attrib["classname"]}.{testcase.attrib["name"]}' - message = result.attrib.get("message") - full_message = result.text or message + if result.get("status") in ("SUCCESSFUL", "ABORTED"): + continue + + reason = result.find(f"./{{{_OTR_CORE}}}reason") + throwable = result.find(f"./{{{_OTR_JAVA}}}throwable") + + message = reason.text if reason is not None else "" + full_message = throwable.text if throwable is not None else "" location = find_location(full_message) - error = ERROR_MESSAGE.match(message) - if error: - error = error[1] + if (match := ERROR_MESSAGE.match(message)) is not None: + error = match.group(1) else: error = message + error = SPACES.sub(' ', error) if location: - print(f'## {location[0]}:{location[1]}: {name} failed: {SPACES.sub(" ", error)}') + print(f"## {location[0]}:{location[1]}: {test_name} failed: {error}") else: - print(f"::error::{name} failed") + print(f"::error::{test_name} failed: {error}") + + if len(full_message) > 0: + print("::group::Full error message") + print(full_message) + print("::endgroup::") + else: + print(f"Unknown tag: {event.tag}") - print("::group::Full error message") - print(full_message) - print("::endgroup::") def parse_junit() -> None: @@ -114,7 +141,7 @@ def parse_junit() -> None: print("::add-matcher::.github/matchers/junit.json") for project in PROJECT_LOCATIONS: - for path in pathlib.Path(os.path.join(project, "build/test-results/test")).glob("TEST-*.xml"): + for path in pathlib.Path(os.path.join(project, "build/test-results")).glob("*/open-test-report.xml"): _parse_junit_file(path) for path in pathlib.Path(os.path.join(project, "build/test-results")).glob("run*.xml"): @@ -130,7 +157,7 @@ def _parse_checkstyle(path: pathlib.Path): attrib = error.attrib print( - f'{attrib["severity"]} {filename}:{attrib["line"]}:{attrib.get("column", 1)}: {SPACES.sub(" ", attrib["message"])}' + f"{attrib['severity']} {filename}:{attrib['line']}:{attrib.get('column', 1)}: {SPACES.sub(' ', attrib['message'])}" )