diff --git a/cr-core/build.gradle.kts b/cr-core/build.gradle.kts index 7fcf709..a7dad6a 100644 --- a/cr-core/build.gradle.kts +++ b/cr-core/build.gradle.kts @@ -62,6 +62,20 @@ dependencies { pmd("net.sourceforge.pmd:pmd-java:7.0.0-rc4") implementation("org:jpastebin:1.0.1") + // jpastebin needs these at runtime (see its own embedded META-INF/maven/org/jpastebin/pom.xml, + // which pins Jackson 2.9.7) but the POM Gradle actually resolves from the JBoss repo is an + // empty Nexus-generated stub with no at all - so without declaring these + // ourselves, PastebinUploadRunnable.call() throws NoClassDefFoundError the first time it + // touches a Jackson class, only when someone actually clicks "Upload". 2.9.7 is a 2018 release + // with known CVEs; Jackson's 2.x line keeps this level of API (ObjectMapper, TypeReference, + // annotations) stable, so a current release is a safe drop-in rather than matching the old pin. + // The BOM (not three separately-pinned versions) because jackson-annotations renumbered its own + // versioning away from core/databind's x.y.z scheme starting at 2.20 - the BOM is what keeps the + // three resolvable together regardless of a given module's own version string. + implementation(platform("com.fasterxml.jackson:jackson-bom:2.22.2")) + implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("com.fasterxml.jackson.core:jackson-core") + implementation("com.fasterxml.jackson.core:jackson-annotations") implementation("org.apache.httpcomponents:httpclient:4.5.13") implementation("org.apache.httpcomponents:httpmime:4.5.13") diff --git a/cr-core/src/main/java/org/terasology/crashreporter/GlobalProperties.java b/cr-core/src/main/java/org/terasology/crashreporter/GlobalProperties.java index 7c7cade..94a062b 100644 --- a/cr-core/src/main/java/org/terasology/crashreporter/GlobalProperties.java +++ b/cr-core/src/main/java/org/terasology/crashreporter/GlobalProperties.java @@ -39,16 +39,20 @@ public enum KEY { public GlobalProperties() { String propsUrl = "/crashreporter.properties"; String defaultPropsUrl = "/crashreporter_defaults.properties"; - try (InputStream stream = CrashReporter.class.getResourceAsStream(defaultPropsUrl)) { - properties.load(stream); - } catch (IOException e) { - // this should never go wrong - System.err.println("Unable to load default properties"); - } - try (InputStream stream = CrashReporter.class.getResourceAsStream(propsUrl)) { - properties.load(stream); + loadIfPresent(defaultPropsUrl); + // Only cr-core's downstream consumers (cr-terasology, cr-destsol, ...) ship this file - + // it's absent when cr-core is used standalone, which getResourceAsStream signals with + // null rather than an IOException, so that has to be checked explicitly. + loadIfPresent(propsUrl); + } + + private void loadIfPresent(String resourceUrl) { + try (InputStream stream = CrashReporter.class.getResourceAsStream(resourceUrl)) { + if (stream != null) { + properties.load(stream); + } } catch (IOException e) { - System.err.println("Unable to load " + propsUrl); + System.err.println("Unable to load " + resourceUrl); } } diff --git a/cr-core/src/main/java/org/terasology/crashreporter/pages/UploadPanel.java b/cr-core/src/main/java/org/terasology/crashreporter/pages/UploadPanel.java index cc30939..7103d5a 100644 --- a/cr-core/src/main/java/org/terasology/crashreporter/pages/UploadPanel.java +++ b/cr-core/src/main/java/org/terasology/crashreporter/pages/UploadPanel.java @@ -119,6 +119,11 @@ public URL getUploadedFileURL() { return uploadURL; } + /** Package-private test hook - equivalent to a real "PasteBin" click, but with a caller-supplied Callable. */ + void uploadForTesting(Callable callable) { + upload(callable); + } + private void upload(final Callable callable) { Runnable runnable = new Runnable() { @@ -169,6 +174,14 @@ public void run() { } private void uploadFailed(final Exception e) { + // Printed unconditionally, not just shown in the dialog below: a JOptionPane only reaches + // whoever is watching the screen at that exact moment, and leaves no trace at all once + // it's dismissed - nothing else in this codebase logs upload failures anywhere. Whoever + // launched this process (a script, a supervisor, a developer tailing output) needs to be + // able to find out what happened after the fact, not just the person who happened to be + // looking right then. + e.printStackTrace(System.err); + SwingUtilities.invokeLater(new Runnable() { @Override diff --git a/cr-core/src/test/java/org/terasology/crashreporter/pages/UploadPanelFailureLoggingTest.java b/cr-core/src/test/java/org/terasology/crashreporter/pages/UploadPanelFailureLoggingTest.java new file mode 100644 index 0000000..f20c503 --- /dev/null +++ b/cr-core/src/test/java/org/terasology/crashreporter/pages/UploadPanelFailureLoggingTest.java @@ -0,0 +1,65 @@ +// Copyright 2026 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.crashreporter.pages; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.terasology.crashreporter.GlobalProperties; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Callable; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression test for an upload failure that previously left no trace anywhere once its + * {@code JOptionPane} was dismissed - found while manually testing the reporter dialog: a real + * exception (a missing-Jackson {@code NoClassDefFoundError} - see the jackson-bom fix elsewhere in + * this commit) only ever showed up in a popup, with nothing printed to stderr for whoever launched + * the process to find afterward. + */ +class UploadPanelFailureLoggingTest { + + private PrintStream originalErr; + private ByteArrayOutputStream capturedErr; + + @BeforeEach + void redirectStderr() { + originalErr = System.err; + capturedErr = new ByteArrayOutputStream(); + System.setErr(new PrintStream(capturedErr, true, StandardCharsets.UTF_8)); + } + + @AfterEach + void restoreStderr() { + System.setErr(originalErr); + } + + @Test + void aFailedUploadIsPrintedToStderrNotJustShownInAPopup() throws InterruptedException { + UploadPanel panel = new UploadPanel(new GlobalProperties(), () -> "log text", () -> "log.txt"); + + final Exception cause = new IllegalStateException("upload failed: missing Jackson class"); + panel.uploadForTesting(new Callable() { + @Override + public URL call() throws Exception { + throw cause; + } + }); + + long deadline = System.currentTimeMillis() + 2000; + while (capturedErr.size() == 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + } + + String stderr = capturedErr.toString(StandardCharsets.UTF_8); + assertTrue(stderr.contains("IllegalStateException"), "Expected the exception type on stderr, got: " + stderr); + assertTrue(stderr.contains("upload failed: missing Jackson class"), + "Expected the exception message on stderr, got: " + stderr); + } +}