diff --git a/cr-core/build.gradle.kts b/cr-core/build.gradle.kts index 99f734b..07f937a 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") @@ -200,6 +214,9 @@ tasks.javadoc { tasks.test { useJUnitPlatform() + // Same environment as CI, on every machine: no test may pop a real dialog on a developer's + // screen, and headless is where a stray JOptionPane fails loudly instead of blocking. + systemProperty("java.awt.headless", "true") // InteractiveTestCases is a manual runner (see runInteractiveTest), not an automated test - // there are no @Test methods in this project. failOnNoDiscoveredTests.set(false) diff --git a/cr-core/src/main/java/org/terasology/crashreporter/pages/ErrorMessagePanel.java b/cr-core/src/main/java/org/terasology/crashreporter/pages/ErrorMessagePanel.java index 952db4a..3f35b88 100644 --- a/cr-core/src/main/java/org/terasology/crashreporter/pages/ErrorMessagePanel.java +++ b/cr-core/src/main/java/org/terasology/crashreporter/pages/ErrorMessagePanel.java @@ -23,6 +23,7 @@ import java.awt.Font; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; +import java.io.Closeable; import java.io.File; import java.io.IOException; import java.io.PrintWriter; @@ -41,8 +42,13 @@ /** * Shows the error message plus stack trace. + * + *

Holds a {@link RandomAccessFile} per log tab (to tail appended lines) and a directory watch, + * for as long as it lives. {@link #close()} releases them; the dialog triggers it through + * {@link #removeNotify()} on dispose, and tests must call it before their temp folder is cleaned up - + * Windows refuses to delete a directory while either handle is open. */ -public class ErrorMessagePanel extends JPanel { +public class ErrorMessagePanel extends JPanel implements Closeable { private static final long serialVersionUID = 8449689452512733452L; @@ -55,6 +61,11 @@ public class ErrorMessagePanel extends JPanel { // logReaders is the list of each log file's reader private final List logReaders = Lists.newArrayList(); + // SwingWorker may still deliver already-published events to the EDT after cancel(); this + // stops those late callbacks from reopening a reader or indexing into the cleared list. + // Guarded by this panel's monitor, see close(). + private boolean closed; + /** * @param exception the exception to display * @param logFileFolder the folder that contains the relevant log files @@ -182,6 +193,47 @@ public int compare(Path p0, Path p1) { }); } + // synchronized with addNewTab/updateLog: the dialog closes on the EDT, where callbacks already + // run, but a test closes from its own thread while a queued callback may be mid-flight. + @Override + public synchronized void close() throws IOException { + closed = true; + IOException first = null; + try { + logUpdateWorker.close(); + } catch (IOException e) { + first = e; + } + for (RandomAccessFile logReader : logReaders) { + try { + logReader.close(); + } catch (IOException e) { + if (first == null) { + first = e; + } + } + } + logReaders.clear(); + if (first != null) { + throw first; + } + } + + /** + * Swing calls this when the panel leaves a displayable hierarchy - for this dialog, that is + * dispose. Card-layout page switches do not remove the panel, so tailing keeps working while + * the user moves between pages. + */ + @Override + public void removeNotify() { + super.removeNotify(); + try { + close(); + } catch (IOException e) { + e.printStackTrace(System.err); + } + } + @Override public void setVisible(boolean aFlag) { super.setVisible(aFlag); @@ -296,7 +348,10 @@ private static String readLogFileContent(Path logFile) { * @param logFileFolder log folder * @param newLogPath path of the new log file */ - private void addNewTab(Path logFileFolder, Path newLogPath) { + private synchronized void addNewTab(Path logFileFolder, Path newLogPath) { + if (closed) { + return; + } logFiles.add(newLogPath); sortLogFiles(logFiles); int index = logFiles.indexOf(newLogPath); @@ -323,7 +378,10 @@ private void addNewTab(Path logFileFolder, Path newLogPath) { * Update log information * @param changedLogPath path of the changed log file */ - private void updateLog(Path changedLogPath) { + private synchronized void updateLog(Path changedLogPath) { + if (closed) { + return; + } int index = logFiles.indexOf(changedLogPath); if (index != -1) { RandomAccessFile logReader = logReaders.get(index); diff --git a/cr-core/src/main/java/org/terasology/crashreporter/pages/LogUpdateWorker.java b/cr-core/src/main/java/org/terasology/crashreporter/pages/LogUpdateWorker.java index fb319c4..bc290e2 100644 --- a/cr-core/src/main/java/org/terasology/crashreporter/pages/LogUpdateWorker.java +++ b/cr-core/src/main/java/org/terasology/crashreporter/pages/LogUpdateWorker.java @@ -5,7 +5,9 @@ import com.sun.nio.file.SensitivityWatchEventModifier; import javax.swing.SwingWorker; +import java.io.Closeable; import java.io.IOException; +import java.nio.file.ClosedWatchServiceException; import java.nio.file.FileSystems; import java.nio.file.Path; import java.nio.file.WatchEvent; @@ -20,7 +22,7 @@ * It watches change in the background thread, see {@code doInBackground} method. * It processes event in EDT thread, see {@code process} method. */ -public class LogUpdateWorker extends SwingWorker> { +public class LogUpdateWorker extends SwingWorker> implements Closeable { public static final String CREATED = "CREATE_LOG"; public static final String MODIFIED = "MODIFIED_LOG"; @@ -45,8 +47,8 @@ protected Void doInBackground() throws Exception { WatchKey key; try { key = watchService.take(); - } catch (InterruptedException e) { - e.printStackTrace(); + } catch (InterruptedException | ClosedWatchServiceException e) { + // Either way someone asked us to stop - see close(). return null; } @@ -67,8 +69,25 @@ protected Void doInBackground() throws Exception { return null; } + /** + * Stops watching and releases the {@link WatchService}. On Windows an open watch on a + * directory keeps that directory from being deleted, so anything that owns a panel with a + * worker (the dialog, a test's temp folder) needs this to run before the folder goes away. + */ + @Override + public void close() throws IOException { + cancel(true); + if (watchService != null) { + watchService.close(); + } + } + @Override protected void process(List> chunks) { + if (isCancelled()) { + // publish() may have queued this before close() ran; the panel is gone. + return; + } super.process(chunks); for (WatchEvent event : chunks) { WatchEvent.Kind kind = event.kind(); 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 5f85028..65fa271 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 @@ -18,6 +18,7 @@ import java.awt.BorderLayout; import java.awt.Cursor; import java.awt.Font; +import java.awt.GraphicsEnvironment; import java.awt.GridLayout; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; @@ -25,6 +26,14 @@ import java.io.IOException; import java.net.URL; import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; import java.util.function.Supplier; /** @@ -34,6 +43,8 @@ public class UploadPanel extends JPanel { private static final long serialVersionUID = -8247883237201535146L; + private static final long DEFAULT_UPLOAD_TIMEOUT_SECONDS = 30; + private JButton uploadPasteBinButton; private boolean isComplete; private URL uploadURL; @@ -44,14 +55,27 @@ public class UploadPanel extends JPanel { private final Supplier logFileNameSupplier; + private final long uploadTimeoutSeconds; + private JButton uploadSkipButton; private JLabel titleLabel; public UploadPanel(GlobalProperties properties, Supplier logTextSupp, Supplier logFileNameSupp) { + this(properties, logTextSupp, logFileNameSupp, DEFAULT_UPLOAD_TIMEOUT_SECONDS); + } + + /** + * @param uploadTimeoutSeconds how long {@link #upload} waits for the upload {@link Callable} before treating it + * as failed - package-private constructor so tests can use a short timeout instead of + * {@link #DEFAULT_UPLOAD_TIMEOUT_SECONDS}. + */ + UploadPanel(GlobalProperties properties, Supplier logTextSupp, Supplier logFileNameSupp, + long uploadTimeoutSeconds) { this.textSupplier = logTextSupp; this.logFileNameSupplier = logFileNameSupp; + this.uploadTimeoutSeconds = uploadTimeoutSeconds; setLayout(new BorderLayout(50, 20)); statusLabel = new JLabel(I18N.getMessage("noUpload"), SwingConstants.RIGHT); statusLabel.setFont(statusLabel.getFont().deriveFont(Font.BOLD)); @@ -116,22 +140,69 @@ public URL getUploadedFileURL() { return uploadURL; } + /** + * Runs {@code callable} on its own thread and waits up to {@link #uploadTimeoutSeconds} for it + * to finish - {@code PastebinUploadRunnable} makes a real HTTP call with no timeout of its own, + * so without one here a slow or unreachable server leaves the button disabled and the status + * label reading "please wait" forever, with no way for the user to tell the difference between + * "still working" and "will never finish". + */ private void upload(final Callable callable) { - Runnable runnable = new Runnable() { + final ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "Upload"); + thread.setDaemon(true); + return thread; + } + }); + final Future future = executor.submit(callable); + Thread watcher = new Thread(new Runnable() { @Override public void run() { try { - URL link = callable.call(); - uploadSuccess(link); - } catch (Exception e) { - uploadFailed(e); + awaitUpload(future, uploadTimeoutSeconds, new Consumer() { + @Override + public void accept(URL link) { + uploadSuccess(link); + } + }, new Consumer() { + @Override + public void accept(Exception e) { + uploadFailed(e); + } + }); + } finally { + executor.shutdownNow(); } } - }; + }, "Upload-Watcher"); + watcher.setDaemon(true); + watcher.start(); + } - Thread thread = new Thread(runnable, "Upload"); - thread.start(); + /** + * Waits up to {@code timeoutSeconds} for {@code future}, then dispatches to exactly one of the + * two callbacks - split out from {@link #upload} as a plain, Swing-free method so the timeout + * and exception-unwrapping logic can be tested directly against a real {@link Future} without + * needing a full {@code UploadPanel}/button-click harness. + */ + static void awaitUpload(Future future, long timeoutSeconds, Consumer onSuccess, Consumer onFailure) { + try { + URL link = future.get(timeoutSeconds, TimeUnit.SECONDS); + onSuccess.accept(link); + } catch (TimeoutException e) { + future.cancel(true); + onFailure.accept(new IOException( + "Upload timed out after " + timeoutSeconds + "s - the server may be unreachable", e)); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + onFailure.accept(cause instanceof Exception ? (Exception) cause : e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + onFailure.accept(e); + } } private void updateStatus() { @@ -165,13 +236,26 @@ public void run() { }); } - private void uploadFailed(final Exception e) { + // Package-private so tests can call it directly, no test-only seam needed. + 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 public void run() { - String uploadFailed = I18N.getMessage("uploadFailed"); - JOptionPane.showMessageDialog(null, e.getLocalizedMessage(), uploadFailed, JOptionPane.ERROR_MESSAGE); + // Headless (CI, unit tests): stderr above is the whole report; a modal here would + // throw HeadlessException on the EDT or, with a display, block the test JVM. + if (!GraphicsEnvironment.isHeadless()) { + String uploadFailed = I18N.getMessage("uploadFailed"); + JOptionPane.showMessageDialog(null, e.getLocalizedMessage(), uploadFailed, JOptionPane.ERROR_MESSAGE); + } uploadPasteBinButton.setEnabled(true); updateStatus(); } diff --git a/cr-core/src/test/java/org/terasology/crashreporter/pages/ErrorMessagePanelTabOrderTest.java b/cr-core/src/test/java/org/terasology/crashreporter/pages/ErrorMessagePanelTabOrderTest.java index f418822..9b0e5cb 100644 --- a/cr-core/src/test/java/org/terasology/crashreporter/pages/ErrorMessagePanelTabOrderTest.java +++ b/cr-core/src/test/java/org/terasology/crashreporter/pages/ErrorMessagePanelTabOrderTest.java @@ -31,12 +31,14 @@ void tabsAreOrderedAlphabeticallyRegardlessOfCreationOrder(@TempDir Path logFold Thread.sleep(10); writeLog(logFolder, "Terasology-init.log", "INIT"); - ErrorMessagePanel panel = new ErrorMessagePanel(new GlobalProperties(), new RuntimeException("boom"), - logFolder, CrashReporter.MODE.CRASH_REPORTER); - - List titles = panel.getTabTitles(); - assertEquals(Arrays.asList("Terasology-init.log", "Terasology-menu.log"), titles, - "Expected tabs in alphabetical order regardless of which file was created first, got: " + titles); + // Closed before returning so the panel's open readers and folder watch don't stop JUnit + // deleting the temp folder on Windows. + try (ErrorMessagePanel panel = new ErrorMessagePanel(new GlobalProperties(), new RuntimeException("boom"), + logFolder, CrashReporter.MODE.CRASH_REPORTER)) { + List titles = panel.getTabTitles(); + assertEquals(Arrays.asList("Terasology-init.log", "Terasology-menu.log"), titles, + "Expected tabs in alphabetical order regardless of which file was created first, got: " + titles); + } } private static void writeLog(Path folder, String name, String content) throws IOException { diff --git a/cr-core/src/test/java/org/terasology/crashreporter/pages/ErrorMessagePanelTest.java b/cr-core/src/test/java/org/terasology/crashreporter/pages/ErrorMessagePanelTest.java index 34e0e4f..8032996 100644 --- a/cr-core/src/test/java/org/terasology/crashreporter/pages/ErrorMessagePanelTest.java +++ b/cr-core/src/test/java/org/terasology/crashreporter/pages/ErrorMessagePanelTest.java @@ -18,6 +18,9 @@ /** * Regression tests for #53: PasteBin upload only included whichever log tab happened to be * selected, silently dropping every other log file present. + * + *

Every panel is closed before the test returns: it keeps a reader open per log file plus a + * watch on the folder, and on Windows that stops JUnit from deleting the {@code @TempDir}. */ class ErrorMessagePanelTest { @@ -26,27 +29,41 @@ void getLogIncludesEveryLogFileNotJustTheSelectedTab(@TempDir Path logFolder) th writeLog(logFolder, "Terasology-init.log", "INIT LOG CONTENT"); writeLog(logFolder, "Terasology-menu.log", "MENU LOG CONTENT"); - ErrorMessagePanel panel = new ErrorMessagePanel(new GlobalProperties(), new RuntimeException("boom"), - logFolder, CrashReporter.MODE.CRASH_REPORTER); - - String log = panel.getLog(); - assertTrue(log.contains("INIT LOG CONTENT"), "Expected the init log's content in the combined log, got: " + log); - assertTrue(log.contains("MENU LOG CONTENT"), "Expected the menu log's content in the combined log, got: " + log); - assertTrue(log.contains("Terasology-init.log"), "Expected a header naming the init log tab, got: " + log); - assertTrue(log.contains("Terasology-menu.log"), "Expected a header naming the menu log tab, got: " + log); + try (ErrorMessagePanel panel = new ErrorMessagePanel(new GlobalProperties(), new RuntimeException("boom"), + logFolder, CrashReporter.MODE.CRASH_REPORTER)) { + String log = panel.getLog(); + assertTrue(log.contains("INIT LOG CONTENT"), "Expected the init log's content in the combined log, got: " + log); + assertTrue(log.contains("MENU LOG CONTENT"), "Expected the menu log's content in the combined log, got: " + log); + assertTrue(log.contains("Terasology-init.log"), "Expected a header naming the init log tab, got: " + log); + assertTrue(log.contains("Terasology-menu.log"), "Expected a header naming the menu log tab, got: " + log); + } } @Test void getLogStillWorksWithASingleLogFile(@TempDir Path logFolder) throws IOException { writeLog(logFolder, "Terasology.log", "SOLO LOG CONTENT"); + try (ErrorMessagePanel panel = new ErrorMessagePanel(new GlobalProperties(), new RuntimeException("boom"), + logFolder, CrashReporter.MODE.CRASH_REPORTER)) { + assertTrue(panel.getLog().contains("SOLO LOG CONTENT")); + } + } + + @Test + void closeReleasesTheLogFilesSoTheFolderCanBeDeleted(@TempDir Path logFolder) throws IOException { + Path log = writeLog(logFolder, "Terasology.log", "LOG CONTENT"); + ErrorMessagePanel panel = new ErrorMessagePanel(new GlobalProperties(), new RuntimeException("boom"), logFolder, CrashReporter.MODE.CRASH_REPORTER); + panel.close(); - assertTrue(panel.getLog().contains("SOLO LOG CONTENT")); + // Only Windows actually refuses this while a reader is open, so on other platforms this + // documents the contract rather than proving it. + Files.delete(log); + assertTrue(Files.notExists(log), "Expected the log file to be deletable once the panel is closed"); } - private static void writeLog(Path folder, String name, String content) throws IOException { - Files.write(folder.resolve(name), content.getBytes(StandardCharsets.UTF_8)); + private static Path writeLog(Path folder, String name, String content) throws IOException { + return Files.write(folder.resolve(name), content.getBytes(StandardCharsets.UTF_8)); } } 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..1a8e476 --- /dev/null +++ b/cr-core/src/test/java/org/terasology/crashreporter/pages/UploadPanelFailureLoggingTest.java @@ -0,0 +1,55 @@ +// 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.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; + +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() throws UnsupportedEncodingException { + originalErr = System.err; + capturedErr = new ByteArrayOutputStream(); + // String charset overloads: the Charset ones are Java 10+, and cr-core targets Java 8. + System.setErr(new PrintStream(capturedErr, true, StandardCharsets.UTF_8.name())); + } + + @AfterEach + void restoreStderr() { + System.setErr(originalErr); + } + + @Test + void aFailedUploadIsPrintedToStderrNotJustShownInAPopup() throws UnsupportedEncodingException { + UploadPanel panel = new UploadPanel(new GlobalProperties(), () -> "log text", () -> "log.txt"); + + // Synchronous stderr print, no thread/timing dance needed. + panel.uploadFailed(new IllegalStateException("upload failed: missing Jackson class")); + + String stderr = capturedErr.toString(StandardCharsets.UTF_8.name()); + 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); + } +} diff --git a/cr-core/src/test/java/org/terasology/crashreporter/pages/UploadPanelTest.java b/cr-core/src/test/java/org/terasology/crashreporter/pages/UploadPanelTest.java new file mode 100644 index 0000000..b22031c --- /dev/null +++ b/cr-core/src/test/java/org/terasology/crashreporter/pages/UploadPanelTest.java @@ -0,0 +1,113 @@ +// 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.Test; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Regression tests for the PasteBin upload hang found while manually testing the reporter dialog: + * {@code PastebinUploadRunnable} makes a real HTTP call with no timeout of its own, so a slow or + * unreachable server left the upload button disabled and the status label reading "please wait" + * forever, with no way to tell "still working" from "never finishing". + * + *

Fully offline: {@code PastebinUploadRunnable} is never instantiated here, so no test makes a + * real HTTP call. "Slow"/"failing" uploads are hand-written {@link Callable}s (sleep-then-return, + * throw); the {@code pastebin.com} URLs below are only ever passed to {@link URL#URL(String)}, + * which parses a string and never opens a connection. + */ +class UploadPanelTest { + + private ExecutorService executor; + + @AfterEach + void shutdown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + @Test + void aSlowUploadFailsWithATimeoutInsteadOfHangingForever() throws Exception { + executor = Executors.newSingleThreadExecutor(); + Future future = executor.submit(new Callable() { + @Override + public URL call() throws InterruptedException, MalformedURLException { + // Longer than the 1-second timeout below - simulates the observed hang. + Thread.sleep(5_000); + return new URL("https://pastebin.com/never-reached"); + } + }); + + AtomicReference successResult = new AtomicReference<>(); + AtomicReference failureResult = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + + UploadPanel.awaitUpload(future, 1, link -> { + successResult.set(link); + done.countDown(); + }, e -> { + failureResult.set(e); + done.countDown(); + }); + + assertTrue(done.await(1, TimeUnit.SECONDS), "awaitUpload must return once its own timeout elapses"); + assertNull(successResult.get(), "a timed-out upload must not report success"); + assertTrue(failureResult.get() instanceof IOException, "expected a timeout to surface as an IOException, got: " + failureResult.get()); + assertTrue(failureResult.get().getMessage().contains("timed out"), + "expected a message naming the timeout, got: " + failureResult.get().getMessage()); + assertTrue(future.isCancelled(), "the underlying upload task should be cancelled once it's timed out"); + } + + @Test + void aFastUploadReportsSuccess() throws Exception { + executor = Executors.newSingleThreadExecutor(); + final URL expected = new URL("https://pastebin.com/abc123"); + Future future = executor.submit(new Callable() { + @Override + public URL call() { + return expected; + } + }); + + AtomicReference successResult = new AtomicReference<>(); + UploadPanel.awaitUpload(future, 5, successResult::set, e -> fail("expected success, got: " + e)); + + assertEquals(expected, successResult.get()); + } + + @Test + void anUnderlyingFailureIsUnwrappedFromExecutionException() throws Exception { + executor = Executors.newSingleThreadExecutor(); + final IOException realCause = new IOException("invalid API key"); + Future future = executor.submit(new Callable() { + @Override + public URL call() throws IOException { + throw realCause; + } + }); + + AtomicReference failureResult = new AtomicReference<>(); + UploadPanel.awaitUpload(future, 5, link -> fail("expected failure"), failureResult::set); + + assertEquals(realCause, failureResult.get(), + "expected the real cause unwrapped from ExecutionException, not the wrapper itself"); + } +}