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/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 5b2a996fc..8c6170684 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,10 +53,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" @@ -71,7 +72,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" @@ -129,15 +130,17 @@ sodium-forge = { module = "maven.modrinth:sodium", version.ref = "sodium.forge" # 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" } @@ -192,7 +195,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-api/src/main/java/dan200/computercraft/impl/package-info.java b/projects/common-api/src/main/java/dan200/computercraft/impl/package-info.java index 0dfb78140..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 @@ -2,8 +2,8 @@ // // SPDX-License-Identifier: MPL-2.0 -@ApiStatus.Internal @NullMarked +@ApiStatus.Internal package dan200.computercraft.impl; import org.jetbrains.annotations.ApiStatus; diff --git a/projects/common/build.gradle.kts b/projects/common/build.gradle.kts index fcad56b21..e05086744 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` @@ -69,19 +68,12 @@ dependencies { testModCompileOnly(libs.mixin) testModImplementation(testFixtures(project(":core"))) testModImplementation(testFixtures(project(":common"))) + testModImplementation(libs.openTestReporting) testModImplementation(libs.bundles.kotlin) "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/impl/AbstractUpgradeHolder.java b/projects/common/src/main/java/dan200/computercraft/impl/AbstractUpgradeHolder.java index cb757e7b5..36eb2c9f2 100644 --- a/projects/common/src/main/java/dan200/computercraft/impl/AbstractUpgradeHolder.java +++ b/projects/common/src/main/java/dan200/computercraft/impl/AbstractUpgradeHolder.java @@ -79,7 +79,7 @@ public abstract class AbstractUpgradeHolder { this.data = 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 ec53af0af..b528444f9 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.isSameItemSameComponents(holder.getItem(), newItem)) return; + if (ItemStack.matches(holder.getItem(), newItem)) return; if (!upgrade.value().getCraftingIngredient().test(newItem) || !upgrade.value().isItemSuitable(newItem)) { throw new IllegalArgumentException( 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 0dd521f5f..7551488cd 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/ModRegistry.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/ModRegistry.java @@ -483,7 +483,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.TypeInfo()); + 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 e834f4890..84dfee607 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 @@ -13,13 +13,14 @@ import net.minecraft.commands.synchronization.ArgumentTypeInfo; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; /** * Utilities for working with arguments. * * @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"); @@ -33,7 +34,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); } @@ -42,7 +45,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.writeVarInt(BuiltInRegistries.COMMAND_ARGUMENT_TYPE.getIdOrThrow(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 de74a6bba..7abfeb8eb 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 @@ -353,7 +353,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 f1cb8e620..cfe02caea 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 @@ -25,38 +25,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 @@ -68,7 +56,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) { @@ -111,47 +99,49 @@ public final class RepeatArgumentType implements ArgumentType> { return child.getExamples(); } - public static class TypeInfo implements ArgumentTypeInfo, Template> { + public static ArgumentTypeInfo, ? extends ArgumentTypeInfo.Template>> type() { + return new TypeInfo<>(); + } + + private static final class TypeInfo 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); ComponentSerialization.TRUSTED_CONTEXT_FREE_STREAM_CODEC.encode(buf, 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 = ComponentSerialization.TRUSTED_CONTEXT_FREE_STREAM_CODEC.decode(buf); - 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), RegistryAccess.EMPTY)); } } - public record Template( - TypeInfo info, ArgumentTypeInfo.Template child, boolean flatten, SimpleCommandExceptionType some - ) implements ArgumentTypeInfo.Template> { + private record Template( + TypeInfo 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 3cc27039a..9ac8347c4 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 @@ -148,13 +148,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 ba6d987b7..f8ae87455 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 @@ -81,7 +81,7 @@ public final class DiskDriveBlockEntity extends AbstractContainerBlockEntity imp 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); /** @@ -172,7 +172,7 @@ public final class DiskDriveBlockEntity extends AbstractContainerBlockEntity imp */ private synchronized void updateMedia() { var newStack = getDiskStack(); - if (ItemStack.isSameItemSameComponents(newStack, media.stack())) return; + if (ItemStack.matches(newStack, media.stack())) return; var newMedia = MediaStack.of(newStack); @@ -243,7 +243,7 @@ public final class DiskDriveBlockEntity extends AbstractContainerBlockEntity imp */ @GuardedBy("this") private void updateMediaStack(ItemStack stack, boolean immediate) { - if (ItemStack.isSameItemSameComponents(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/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/recipe/function/CopyComponents.java b/projects/common/src/main/java/dan200/computercraft/shared/recipe/function/CopyComponents.java index 9eaad2b63..6834acef5 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/recipe/function/CopyComponents.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/recipe/function/CopyComponents.java @@ -78,7 +78,7 @@ public final class CopyComponents implements RecipeFunction { } @Override - public Type getType() { + public Type getType() { return ModRegistry.RecipeFunctions.COPY_COMPONENTS.get(); } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/recipe/function/RecipeFunction.java b/projects/common/src/main/java/dan200/computercraft/shared/recipe/function/RecipeFunction.java index ae4ebe767..4a4aaa21d 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/recipe/function/RecipeFunction.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/recipe/function/RecipeFunction.java @@ -39,7 +39,7 @@ public interface RecipeFunction { /** * The registry where {@link RecipeFunction}s are registered. */ - ResourceKey>> REGISTRY = ResourceKey.createRegistryKey(ResourceLocation.fromNamespaceAndPath(ComputerCraftAPI.MOD_ID, "recipe_function")); + ResourceKey>> REGISTRY = ResourceKey.createRegistryKey(ResourceLocation.fromNamespaceAndPath(ComputerCraftAPI.MOD_ID, "recipe_function")); /** * The codec to read and write {@link RecipeFunction}s with. @@ -66,7 +66,7 @@ public interface RecipeFunction { * * @return The type of this recipe function. */ - Type getType(); + Type getType(); /** * Apply this recipe function, modifying the result item. 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 b0ed63617..f1d3e415d 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 @@ -740,7 +740,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 7d63770d0..a39783ca7 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/turtle/upgrades/TurtleTool.java b/projects/common/src/main/java/dan200/computercraft/shared/turtle/upgrades/TurtleTool.java index 4cf392439..802d35b46 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 @@ -108,7 +108,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.isSameItemSameComponents(stack, oldStack)) return; + if (ItemStack.matches(stack, oldStack)) return; upgrade.setItem(stack); } 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 b4f65ceef..1d472de8e 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 @@ -51,15 +51,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(); @@ -73,6 +65,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 Tag tag) { if (tag == null) return null; 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 9cf1df753..84b21c0c1 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.isSameItemSameComponents(item, stack) && item.getCount() == stack.getCount(); + return ItemStack.matches(item, stack); } @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 327164c0a..a1372dc40 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 @@ -118,7 +118,7 @@ class Recipe_Test { for (recipe in recipes) { // Ensure the result matches the input. val result = recipe.getResultItem(context.level.registryAccess()) - if (!ItemStack.isSameItemSameComponents(result, item) || result.count != item.count) { + if (!ItemStack.matches(result, item)) { context.fail("Expected to craft ${item.toStringFull()} but crafted ${result.toStringFull()}") } 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 54481f368..ccf32d893 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 @@ -377,7 +377,7 @@ fun GameTestHelper.assertCraftable(items: List, result: ItemStack, wi val actualResult = recipe.get().value.assemble(input, level.registryAccess()) - if (!ItemStack.isSameItemSameComponents(actualResult, result) || actualResult.count != result.count) { + if (!ItemStack.matches(actualResult, result)) { fail("Expected $items to craft ${result.toStringFull()}, got ${actualResult.toStringFull()}") } } 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 9980c65fb..aaec3e026 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 @@ -48,7 +48,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() @@ -59,7 +59,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/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 095b51bda..c0e398bc5 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; @@ -230,14 +230,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/FabricConfigFile.java b/projects/fabric/src/main/java/dan200/computercraft/shared/platform/FabricConfigFile.java index a297fb251..4ef73f4ca 100644 --- a/projects/fabric/src/main/java/dan200/computercraft/shared/platform/FabricConfigFile.java +++ b/projects/fabric/src/main/java/dan200/computercraft/shared/platform/FabricConfigFile.java @@ -10,7 +10,6 @@ import com.electronwill.nightconfig.core.file.FileNotFoundAction; import com.electronwill.nightconfig.core.file.FileWatcher; import com.electronwill.nightconfig.core.io.WritingMode; import com.google.errorprone.annotations.concurrent.GuardedBy; -import dan200.computercraft.core.util.Nullability; import dan200.computercraft.shared.config.ConfigFile; import org.apache.commons.lang3.function.TriFunction; import org.jspecify.annotations.Nullable; @@ -59,7 +58,7 @@ public final class FabricConfigFile extends ConfigFile { closeConfig(); - var path = Nullability.assertNonNull(Arrays.stream(paths).filter(Files::exists).findFirst().orElseGet(() -> paths[paths.length - 1])); + var path = Arrays.stream(paths).filter(Files::exists).findFirst().orElseGet(() -> paths[paths.length - 1]); var config = this.config = CommentedFileConfig.builder(path).sync() .onFileNotFound(FileNotFoundAction.READ_NOTHING) .writingMode(WritingMode.REPLACE) 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 4bee39bf5..673649a8a 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 @@ -246,7 +246,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); } 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 bca5f21f6..8f157e32a 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 = ResourceLocation.fromNamespaceAndPath(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 02b09919b..4353871ca 100644 --- a/projects/forge/build.gradle.kts +++ b/projects/forge/build.gradle.kts @@ -214,6 +214,7 @@ dependencies { // Ensure our test fixture dependencies are on the classpath "testAdditionalRuntimeClasspath"(libs.bundles.kotlin) "testAdditionalRuntimeClasspath"(libs.bundles.test) + "testAdditionalRuntimeClasspath"(libs.openTestReporting) "testWithIris"(libs.iris.forge) "testWithIris"(libs.sodium.forge) 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 bb541a94e..539020c2f 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.neoforged.neoforge.event.tick.ServerTickEvent; @Mod("cctest") public class TestMod { public TestMod(IEventBus modBus) { - TestHooks.init(); + TestHooks.init("Forge"); var bus = NeoForge.EVENT_BUS; bus.addListener(EventPriority.LOW, (ServerStartedEvent e) -> TestHooks.onServerStarted(e.getServer())); 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(); +} 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'])}" )