diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 000000000..5269f1203
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index bccaeaaf0..0d43ab06a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -168,6 +168,9 @@ configurations.matching({ it =~ ~/test(Runtime|Compile|Implementation)Classpath/
sourceCompatibility = 1.11
targetCompatibility = 1.11
+compileJava.options.encoding = "UTF-8"
+compileTestJava.options.encoding = "UTF-8"
+
javafx {
version = "12.0.1"
modules = [
diff --git a/src/main/java/org/terasology/launcher/game/GameRunner.java b/src/main/java/org/terasology/launcher/game/GameRunner.java
deleted file mode 100644
index 1a16cc658..000000000
--- a/src/main/java/org/terasology/launcher/game/GameRunner.java
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright 2016 MovingBlocks
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.terasology.launcher.game;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.nio.charset.Charset;
-
-public class GameRunner implements Runnable {
-
- private static final Logger logger = LoggerFactory.getLogger(GameRunner.class);
-
- private final Process p;
-
- public GameRunner(final Process p) {
- this.p = p;
- }
-
- @Override
- public void run() {
- try {
- try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), Charset.defaultCharset()))) {
- String line;
- do {
- line = r.readLine();
- logger.trace("Game output: {}", line);
- } while (!isInterrupted() && line != null);
- }
- if (isInterrupted()) {
- logger.debug("Game thread interrupted.");
- return;
- }
- int exitValue = -1;
- try {
- exitValue = p.waitFor();
- } catch (InterruptedException e) {
- logger.error("The game thread was interrupted!", e);
- }
- logger.debug("Game closed with the exit value '{}'.", exitValue);
- } catch (IOException e) {
- logger.error("Could not read game output!", e);
- }
- }
-
- boolean isInterrupted() {
- return Thread.currentThread().isInterrupted();
- }
-}
diff --git a/src/main/java/org/terasology/launcher/game/GameService.java b/src/main/java/org/terasology/launcher/game/GameService.java
new file mode 100644
index 000000000..d779887c3
--- /dev/null
+++ b/src/main/java/org/terasology/launcher/game/GameService.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2020 MovingBlocks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.terasology.launcher.game;
+
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import javafx.concurrent.Service;
+import javafx.concurrent.Worker;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.terasology.launcher.settings.BaseLauncherSettings;
+
+import java.nio.file.Path;
+import java.util.concurrent.Executors;
+
+import static com.google.common.base.Verify.verifyNotNull;
+
+/**
+ * This service starts and monitors the game process.
+ *
+ * Its {@linkplain #GameService() constructor} requires no arguments. Use {@link #start(Path, BaseLauncherSettings)} to
+ * start the game process; the zero-argument form of {@code start()} will not have enough information.
+ *
+ * The Boolean value of this service is true when it believes the game process has started successfully.
+ * There will be some time between when this service is started and when that value is set. It can be observed on
+ * {@link #valueProperty()} or retrieved as {@link #getValue()}.
+ *
+ * The {@link Worker} interface of this Service provides a proxy to its current {@link RunGameTask}.
+ * Many of the methods the Worker interface defines are not used by this task type. In particular, the
+ * {@link Worker#progressProperty() progress} and {@link Worker#workDoneProperty() workDone} properties have no
+ * information for you that reflect the state of the game process.
+ *
+ * This service will be in {@link State#RUNNING RUNNING} state as long as the game process is live. It enters
+ * {@link State#SUCCEEDED SUCCEEDED} after the game process exits with no error code, or {@link State#FAILED FAILED} if
+ * the game failed to start or it terminates with an error. Whether it succeeded or failed, the service will then
+ * reset to {@link State#READY READY} to be started again.
+ *
{
+ private static final Logger logger = LoggerFactory.getLogger(GameService.class);
+
+ private Path gamePath;
+ private BaseLauncherSettings settings;
+
+ public GameService() {
+ setExecutor(Executors.newSingleThreadExecutor(
+ new ThreadFactoryBuilder()
+ .setNameFormat("GameService-%d")
+ .setDaemon(true)
+ .setUncaughtExceptionHandler(this::exceptionHandler)
+ .build()
+ ));
+ }
+
+ /**
+ * Start a new game process with these settings.
+ *
+ * @param gamePath the directory under which we will find libs/Teresology.jar, also used as the process's
+ * working directory
+ * @param settings supplies other settings relevant to configuring a process
+ */
+ @SuppressWarnings("checkstyle:HiddenField")
+ public void start(Path gamePath, BaseLauncherSettings settings) {
+ this.gamePath = gamePath;
+ this.settings = settings;
+
+ start();
+ }
+
+ /**
+ * Use {@link #start(Path, BaseLauncherSettings)} instead.
+ *
+ * It is an error to call this method before providing the configuration.
+ */
+ @Override
+ public void start() {
+ super.start();
+ }
+
+ /**
+ * Cancellation is unsupported. Do not attempt this method.
+ *
+ * Rationale: We do not terminate a running game process, and we don't want to lose our thread keeping track of
+ * the process while it's still live. If we did, our βis a game already running?β logic would fail.
+ *
+ * If you are using this with some kind of generic Service Manager that always invokes this method as part of an
+ * orderly shutdown, that could be a good reason to change this from throwing an exception to logging a warning.
+ * Until then, this fails loudly so you won't call this method thinking it does something and then become confused
+ * when nothing happens.
+ *
+ * @throws UnsupportedOperationException always
+ */
+ @Override
+ public boolean cancel() {
+ throw new UnsupportedOperationException("GameService does not cancel.");
+ }
+
+ /**
+ * Restarting is unsupported. Do not attempt this method.
+ *
+ * See {@link #cancel()} for rationale.
+ */
+ @Override
+ public void restart() {
+ super.restart();
+ }
+
+ /**
+ * Creates a new task to run the game with the current settings.
+ *
+ * This class's configuration fields must be set before this is called.
+ *
+ * @throws com.google.common.base.VerifyException when fields are unset
+ */
+ @Override
+ protected RunGameTask createTask() {
+ verifyNotNull(settings);
+ var starter = new GameStarter(verifyNotNull(gamePath), settings.getGameDataDirectory(),
+ settings.getMaxHeapSize(), settings.getInitialHeapSize(),
+ settings.getUserJavaParameterList(), settings.getUserGameParameterList(),
+ settings.getLogLevel());
+ return new RunGameTask(starter);
+ }
+
+ /** After a task completes, reset to ready for the next. */
+ @Override
+ protected void succeeded() {
+ reset(); // Ready to go again!
+ }
+
+ /** Checks to see if the failure left any exceptions behind, then resets to ready. */
+ @Override
+ protected void failed() {
+ // "Uncaught" exceptions from javafx's Task are actually caught and kept in a property,
+ // so if we want them logged we have to explicitly dig them out.
+ var error = getException();
+ if (error != null) {
+ exceptionHandler(Thread.currentThread(), error);
+ }
+ reset(); // Ready to try again!
+ }
+
+ private void exceptionHandler(Thread thread, Throwable thrown) {
+ logger.error("Unhandled exception", thrown);
+ }
+}
diff --git a/src/main/java/org/terasology/launcher/game/GameStarter.java b/src/main/java/org/terasology/launcher/game/GameStarter.java
index 20d751413..ee46252dc 100644
--- a/src/main/java/org/terasology/launcher/game/GameStarter.java
+++ b/src/main/java/org/terasology/launcher/game/GameStarter.java
@@ -1,11 +1,11 @@
/*
- * Copyright 2016 MovingBlocks
+ * Copyright 2020 MovingBlocks
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
- * http://www.apache.org/licenses/LICENSE-2.0
+ * https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
@@ -16,103 +16,73 @@
package org.terasology.launcher.game;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;
-import org.terasology.launcher.packages.Package;
import org.terasology.launcher.util.JavaHeapSize;
import java.io.IOException;
import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.Callable;
-public final class GameStarter {
-
- private static final Logger logger = LoggerFactory.getLogger(GameStarter.class);
-
- private static final int PROCESS_START_SLEEP_TIME = 5000;
-
- private Thread gameThread;
-
- public GameStarter() {
- }
-
- public boolean isRunning() {
- return gameThread != null && gameThread.isAlive();
- }
-
- public void dispose() {
- if (gameThread != null) {
- gameThread.interrupt();
- }
- gameThread = null;
- }
-
- public boolean startGame(Package gamePkg, Path gamePath, Path gameDataDirectory, JavaHeapSize maxHeapSize,
- JavaHeapSize initialHeapSize, List userJavaParameters, List userGameParameters, Level logLevel) {
- if (isRunning()) {
- logger.warn("The game can not be started because another game is already running! '{}'", gameThread);
- return false;
- }
-
- final List javaParameters = createJavaParameters(maxHeapSize, initialHeapSize, userJavaParameters, logLevel);
- final List processParameters = createProcessParameters(gamePath, gameDataDirectory, javaParameters, userGameParameters);
-
- return startProcess(gamePkg, gamePath, processParameters);
- }
+/**
+ * Takes the game and runtime options, provides something that will launch a process.
+ *
+ * @see java command manual
+ */
+class GameStarter implements Callable {
+ final ProcessBuilder processBuilder;
+
+ /**
+ * @param gamePath the directory under which we will find {@code libs/Terasology.jar}, also used as the process's
+ * working directory
+ * @param gameDataDirectory {@code -homedir}, the directory where Terasology's data files (saves & etc) are kept
+ * @param heapMin java's {@code -Xms}
+ * @param heapMax java's {@code -Xmx}
+ * @param javaParams additional arguments for the {@code java} command line
+ * @param gameParams additional arguments for the Terasology command line
+ * @param logLevel the minimum level of log events Terasology will include on its output stream to us
+ */
+ GameStarter(Path gamePath, Path gameDataDirectory, JavaHeapSize heapMin, JavaHeapSize heapMax, List javaParams, List gameParams,
+ Level logLevel) {
+ final List processParameters = new ArrayList<>();
+ processParameters.add(getRuntimePath().toString());
- private List createJavaParameters(JavaHeapSize maxHeapSize, JavaHeapSize initialHeapSize, List userJavaParameters, Level logLevel) {
- final List javaParameters = new ArrayList<>();
- if (initialHeapSize.isUsed()) {
- javaParameters.add("-Xms" + initialHeapSize.getSizeParameter());
+ if (heapMin.isUsed()) {
+ processParameters.add("-Xms" + heapMin.getSizeParameter());
}
- if (maxHeapSize.isUsed()) {
- javaParameters.add("-Xmx" + maxHeapSize.getSizeParameter());
+ if (heapMax.isUsed()) {
+ processParameters.add("-Xmx" + heapMax.getSizeParameter());
}
- javaParameters.add("-DlogOverrideLevel=" + logLevel.toString());
- javaParameters.addAll(userJavaParameters);
- return javaParameters;
- }
+ processParameters.add("-DlogOverrideLevel=" + logLevel.name());
+ processParameters.addAll(javaParams);
- private List createProcessParameters(Path gamePath, Path gameDataDirectory, List javaParameters,
- List gameParameters) {
- final List processParameters = new ArrayList<>();
- processParameters.add(System.getProperty("java.home") + "/bin/java"); // Use the current java
- processParameters.addAll(javaParameters);
processParameters.add("-jar");
- processParameters.add(gamePath.resolve("libs/Terasology.jar").toString());
+ processParameters.add(gamePath.resolve(Path.of("libs", "Terasology.jar")).toString());
processParameters.add("-homedir=" + gameDataDirectory.toAbsolutePath().toString());
- processParameters.addAll(gameParameters);
+ processParameters.addAll(gameParams);
- return processParameters;
+ processBuilder = new ProcessBuilder(processParameters)
+ .directory(gamePath.toFile())
+ .redirectErrorStream(true);
}
- private boolean startProcess(Package gamePkg, Path gamePath, List processParameters) {
- final ProcessBuilder pb = new ProcessBuilder(processParameters);
- pb.redirectErrorStream(true);
- pb.directory(gamePath.toFile());
- logger.debug("Starting game process with '{}' in '{}' for '{}-{}'", processParameters, gamePath.toFile(), gamePkg.getId(), gamePkg.getVersion());
- try {
- final Process p = pb.start();
-
- gameThread = new Thread(new GameRunner(p));
- gameThread.setName("game" + gamePkg.getId() + "-" + gamePkg.getVersion());
- gameThread.start();
-
- Thread.sleep(PROCESS_START_SLEEP_TIME);
+ /**
+ * Start the game in a new process.
+ *
+ * @return the newly started process
+ * @throws IOException from {@link ProcessBuilder#start()}
+ */
+ @Override
+ public Process call() throws IOException {
+ return processBuilder.start();
+ }
- if (!gameThread.isAlive()) {
- final int exitValue = p.waitFor();
- logger.warn("The game was stopped early. It returns with the exit value '{}'.", exitValue);
- return false;
- } else {
- logger.info("The game is successfully launched.");
- }
- } catch (InterruptedException | IOException | RuntimeException e) {
- logger.error("The game could not be started due to an error! Parameters '{}' for '{}-{}'!", processParameters, gamePkg.getId(), gamePkg.getVersion(), e);
- return false;
- }
- return true;
+ /**
+ * @return the executable {@code java} file to run the game with
+ */
+ Path getRuntimePath() {
+ return Paths.get(System.getProperty("java.home"), "bin", "java");
}
}
diff --git a/src/main/java/org/terasology/launcher/game/RunGameTask.java b/src/main/java/org/terasology/launcher/game/RunGameTask.java
new file mode 100644
index 000000000..613beddb3
--- /dev/null
+++ b/src/main/java/org/terasology/launcher/game/RunGameTask.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright 2020 MovingBlocks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.terasology.launcher.game;
+
+import com.google.common.base.MoreObjects;
+import javafx.concurrent.Task;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.event.Level;
+import org.terasology.launcher.gui.javafx.FxTimer;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.time.Duration;
+import java.util.concurrent.Callable;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Verify.verify;
+import static com.google.common.base.Verify.verifyNotNull;
+
+/**
+ * Starts and manages a game process.
+ *
+ * Many of the characteristics of this task are described in {@link GameService}, as it's the expected access point to
+ * this.
+ *
+ * (An individual {@link Task} lasts as long as the process does, the {@link javafx.concurrent.Service Service}
+ * provides a stable reference that doesn't have to be updated with each new task.)
+ *
+ * Beware javafx's treatment of exceptions that come up in {@link Task Tasks}. An uncaught exception will be stored
+ * in its {@link #exceptionProperty()} and never see its way to an executor's uncaught exception handler. It won't be
+ * until you call this object's {@link #get()} that it will be thrown again.
+ */
+class RunGameTask extends Task {
+ static final int EXIT_CODE_OK = 0;
+
+ /**
+ * The output of the process is tested against this for a sign that it launched successfully.
+ *
+ * (We don't yet have a formal protocol about this with Terasology, so it won't show up if its minimum log level is
+ * higher than {@link Level#INFO INFO}. But the default is to include {@link Level#INFO INFO}, so this should often
+ * work.)
+ */
+ static final Predicate START_MATCH = Pattern.compile("TerasologyEngine.+Initialization completed")
+ .asPredicate();
+
+ /**
+ * How long a process has to live in order for us to call it successful.
+ *
+ * If there's no output with {@link #START_MATCH} and it hasn't crashed after being up this long, it's probably
+ * okay.
+ */
+ static final Duration SURVIVAL_THRESHOLD = Duration.ofSeconds(10);
+
+ private static final Logger logger = LoggerFactory.getLogger(RunGameTask.class);
+
+ protected final Callable starter;
+
+ /**
+ * Indicates whether we have set the {@link Task#updateValue value} of this Task yet.
+ *
+ * The value is stored in a {@link javafx.beans.property.SimpleObjectProperty property} we can't directly
+ * access from this task's thread, so it remembers it here.
+ */
+ private boolean valueSet;
+
+ private FxTimer successTimer;
+
+ /**
+ * @param starter called as soon as the Task starts to start a new process
+ */
+ RunGameTask(Callable starter) {
+ this.starter = starter;
+ }
+
+ /**
+ * Starts the process, returns when it's done.
+ *
+ * @return true when the process exits with no error code
+ * @throws GameStartError if the process failed to start at all
+ * @throws GameExitError if the process terminates with an error code
+ * @throws GameExitError if the process quit before {@link #SURVIVAL_THRESHOLD}
+ * @throws InterruptedException if this thread was interrupted while waiting for something β
+ * doesn't come up as much as you might expect, because waiting on a {@code read} call
+ * of the process's output can not be interrupted (Java's rule, not ours)
+ */
+ @Override
+ protected Boolean call() throws GameStartError, GameExitError, InterruptedException, GameExitTooSoon {
+ verifyNotNull(this.starter);
+ verify(!this.isDone());
+ Process process;
+ try {
+ process = this.starter.call();
+ } catch (Exception e) {
+ throw new GameStartError(e);
+ }
+ monitorProcess(process);
+ return true;
+ }
+
+ /**
+ * Monitors the output and exit status of a process.
+ *
+ * @param process the running process
+ */
+ void monitorProcess(Process process) throws InterruptedException, GameExitError, GameExitTooSoon {
+ checkNotNull(process);
+ logger.debug("Game process is {}", process);
+ updateMessage("Game running as process " + process.pid());
+
+ startTimer();
+
+ // log each line of process output
+ var gameOutput = new BufferedReader(new InputStreamReader(process.getInputStream()));
+ gameOutput.lines().forEachOrdered(this::handleOutputLine);
+
+ try {
+ // The output has closed, so we _often_ have the exit value immediately, but apparently
+ // not always β the tests were flaky. To be safe, waitFor.
+ var exitValue = process.waitFor();
+ logger.debug("Game closed with the exit value '{}'.", exitValue);
+
+ if (exitValue == EXIT_CODE_OK) {
+ updateMessage("Process complete.");
+ } else {
+ updateMessage("Process exited with code " + exitValue);
+ throw new GameExitError(exitValue);
+ }
+ } catch (InterruptedException e) {
+ logger.warn("Interrupted while waiting for game process exit.", e);
+ throw e;
+ }
+
+ if (successTimer != null) {
+ // No error code, but the game quit before our timer went off? That doesn't
+ // seem right!
+ removeTimer();
+ throw new GameExitTooSoon();
+ }
+ }
+
+ /**
+ * Called with each line of the process's output.
+ *
+ * Expect the Process has {@linkplain ProcessBuilder#redirectErrorStream() merged output and error streams}.
+ *
+ * @param line a line of output, decoded to String, with trailing newline stripped
+ */
+ protected void handleOutputLine(String line) {
+ if ((!valueSet) && START_MATCH.test(line)) {
+ declareSurvival();
+ }
+ logger.info("Game output: {}", line);
+ }
+
+ private void declareSurvival() {
+ valueSet = true;
+ this.updateValue(true);
+ removeTimer();
+ }
+
+ private void timerComplete() {
+ logger.debug("Process has been alive at least {}, calling it good.", SURVIVAL_THRESHOLD);
+ declareSurvival();
+ }
+
+ protected void startTimer() {
+ successTimer = FxTimer.runLater(SURVIVAL_THRESHOLD, this::timerComplete);
+ }
+
+ protected void removeTimer() {
+ if (successTimer != null) {
+ successTimer.stop();
+ successTimer = null;
+ }
+ }
+
+ @Override
+ protected void failed() {
+ removeTimer();
+ }
+
+ public abstract static class RunGameError extends Exception {
+ }
+
+ /**
+ * The process failed to start.
+ *
+ * Check its {@link #getCause() cause}.
+ */
+ public static class GameStartError extends RunGameError {
+ GameStartError(final Exception e) {
+ super();
+ this.initCause(e);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this).addValue(this.getCause()).toString();
+ }
+ }
+
+ /**
+ * The process quit with an {@linkplain #exitValue error code}.
+ *
+ * These codes are platform-dependent. All know for sure is that it is not {@link #EXIT_CODE_OK}.
+ */
+ public static class GameExitError extends RunGameError {
+ public final int exitValue;
+
+ GameExitError(final int exitValue) {
+ this.exitValue = exitValue;
+ }
+
+ @Override
+ public String getMessage() {
+ return toString();
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this).add("exitValue", exitValue).toString();
+ }
+ }
+
+ /**
+ * The process only lasted a brief time.
+ */
+ public static class GameExitTooSoon extends RunGameError {
+ }
+}
diff --git a/src/main/java/org/terasology/launcher/gui/javafx/ApplicationController.java b/src/main/java/org/terasology/launcher/gui/javafx/ApplicationController.java
index c79e9ead4..70165fac0 100644
--- a/src/main/java/org/terasology/launcher/gui/javafx/ApplicationController.java
+++ b/src/main/java/org/terasology/launcher/gui/javafx/ApplicationController.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2019 MovingBlocks
+ * Copyright 2020 MovingBlocks
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,8 +19,10 @@
import javafx.animation.Transition;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
+import javafx.concurrent.WorkerStateEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.geometry.Insets;
@@ -34,6 +36,8 @@
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ProgressBar;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TabPane;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
@@ -46,7 +50,7 @@
import javafx.stage.StageStyle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.terasology.launcher.game.GameStarter;
+import org.terasology.launcher.game.GameService;
import org.terasology.launcher.packages.Package;
import org.terasology.launcher.packages.PackageManager;
import org.terasology.launcher.settings.BaseLauncherSettings;
@@ -75,7 +79,7 @@ public class ApplicationController {
private Path launcherDirectory;
private BaseLauncherSettings launcherSettings;
private PackageManager packageManager;
- private GameStarter gameStarter;
+ private final GameService gameService;
private Stage stage;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
@@ -120,6 +124,9 @@ public class ApplicationController {
public ApplicationController() {
warning = new SimpleObjectProperty<>(Optional.empty());
+ gameService = new GameService();
+ gameService.setOnFailed(this::handleRunFailed);
+ gameService.valueProperty().addListener(this::handleRunStarted);
}
@FXML
@@ -179,33 +186,49 @@ protected void openSettingsAction() {
}
private void startGameAction() {
- final Path gamePath = packageManager.resolveInstallDir(selectedPackage);
-
- if (gameStarter.isRunning()) {
+ if (gameService.isRunning()) {
logger.debug("The game can not be started because another game is already running.");
Dialogs.showInfo(stage, BundleUtils.getLabel("message_information_gameRunning"));
} else {
- final boolean gameStarted = gameStarter.startGame(selectedPackage, gamePath, launcherSettings.getGameDataDirectory(), launcherSettings.getMaxHeapSize(),
- launcherSettings.getInitialHeapSize(), launcherSettings.getUserJavaParameterList(),
- launcherSettings.getUserGameParameterList(), launcherSettings.getLogLevel());
- if (!gameStarted) {
- Dialogs.showError(stage, BundleUtils.getLabel("message_error_gameStart"));
+ final Path gamePath = packageManager.resolveInstallDir(selectedPackage);
+
+ gameService.start(gamePath, launcherSettings);
+ }
+ }
+
+ private void handleRunStarted(ObservableValue extends Boolean> o, Boolean oldValue, Boolean newValue) {
+ if (newValue == null || !newValue) {
+ return;
+ }
+
+ logger.debug("Game has started successfully.");
+
+ launcherSettings.setLastPlayedGameJob(selectedPackage.getId());
+ launcherSettings.setLastPlayedGameVersion(selectedPackage.getVersion());
+
+ if (launcherSettings.isCloseLauncherAfterGameStart()) {
+ if (downloadTask == null) {
+ logger.info("Close launcher after game start.");
+ close();
} else {
- launcherSettings.setLastPlayedGameJob(selectedPackage.getId());
- launcherSettings.setLastPlayedGameVersion(selectedPackage.getVersion());
-
- if (launcherSettings.isCloseLauncherAfterGameStart()) {
- if (downloadTask == null) {
- logger.info("Close launcher after game start.");
- close();
- } else {
- logger.info("The launcher can not be closed after game start, because a download is running.");
- }
- }
+ logger.info("The launcher can not be closed after game start, because a download is running.");
}
}
}
+ void handleRunFailed(WorkerStateEvent event) {
+ TabPane tabPane = (TabPane) stage.getScene().lookup("#contentTabPane");
+ if (tabPane != null) {
+ var tab = tabPane.lookup("#logTab");
+ tabPane.getSelectionModel().select((Tab) tab.getProperties().get(Tab.class));
+ } else {
+ // We're already in error-handling mode here, so avoid bailing with verifyNotNull
+ logger.warn("Failed to locate tab pane.");
+ }
+
+ Dialogs.showError(stage, BundleUtils.getLabel("message_error_gameStart"));
+ }
+
private void downloadAction() {
downloadTask = new DownloadTask(packageManager, selectedVersion);
@@ -297,8 +320,6 @@ public void update(final Path newLauncherDirectory, final Path newDownloadDirect
logbackLogger.addAppender(logViewController);
}
- gameStarter = new GameStarter();
-
packageItems = FXCollections.observableArrayList();
onSync();
@@ -316,7 +337,7 @@ public void update(final Path newLauncherDirectory, final Path newDownloadDirect
initializeComboBoxSelection();
}
- // To be called after database sync is done
+ /** To be called after database sync is done. */
private void onSync() {
packageItems.clear();
packageManager.getPackages()
@@ -457,7 +478,6 @@ private void close() {
} catch (IOException e) {
logger.warn("Could not store current launcher settings!");
}
- gameStarter.dispose();
// TODO: Improve close request handling
if (downloadTask != null) {
diff --git a/src/main/java/org/terasology/launcher/gui/javafx/FxTimer.java b/src/main/java/org/terasology/launcher/gui/javafx/FxTimer.java
new file mode 100644
index 000000000..fc5b29959
--- /dev/null
+++ b/src/main/java/org/terasology/launcher/gui/javafx/FxTimer.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2019 MovingBlocks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.terasology.launcher.gui.javafx;
+
+import javafx.animation.KeyFrame;
+import javafx.animation.Timeline;
+import javafx.util.Duration;
+
+/**
+ * Provides factory methods for timers that are manipulated from and execute their action on the JavaFX application
+ * thread.
+ *
+ * Derived from ReactFX.
+ *
+ * @see ReactFX
+ */
+public final class FxTimer {
+ private final Duration actionTime;
+ private final Timeline timeline;
+ private final Runnable action;
+
+ private long seq;
+
+
+ private FxTimer(java.time.Duration actionTime, java.time.Duration period, Runnable action, int cycles) {
+ this.actionTime = Duration.millis(actionTime.toMillis());
+ this.timeline = new Timeline();
+ this.action = action;
+
+ timeline.getKeyFrames().add(new KeyFrame(this.actionTime)); // used as placeholder
+ if (period != actionTime) {
+ timeline.getKeyFrames().add(new KeyFrame(Duration.millis(period.toMillis())));
+ }
+
+ timeline.setCycleCount(cycles);
+ }
+
+ /**
+ * Prepares a (stopped) timer that lasts for {@code delay} and whose action runs when timer ends.
+ */
+ public static FxTimer create(java.time.Duration delay, Runnable action) {
+ return new FxTimer(delay, delay, action, 1);
+ }
+
+ /**
+ * Equivalent to {@code create(delay, action).restart()}.
+ */
+ public static FxTimer runLater(java.time.Duration delay, Runnable action) {
+ FxTimer timer = create(delay, action);
+ timer.restart();
+ return timer;
+ }
+
+ public void restart() {
+ stop();
+ long expected = seq;
+ timeline.getKeyFrames().set(0, new KeyFrame(actionTime, ae -> {
+ if (seq == expected) {
+ action.run();
+ }
+ }));
+ timeline.play();
+ }
+
+ public void stop() {
+ timeline.stop();
+ ++seq;
+ }
+}
diff --git a/src/main/java/org/terasology/launcher/gui/javafx/LogViewController.java b/src/main/java/org/terasology/launcher/gui/javafx/LogViewController.java
index defe2ad3e..4aa6388b0 100644
--- a/src/main/java/org/terasology/launcher/gui/javafx/LogViewController.java
+++ b/src/main/java/org/terasology/launcher/gui/javafx/LogViewController.java
@@ -16,6 +16,8 @@
package org.terasology.launcher.gui.javafx;
+import ch.qos.logback.classic.pattern.RootCauseFirstThrowableProxyConverter;
+import ch.qos.logback.classic.pattern.ThrowableHandlingConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.AppenderBase;
import javafx.concurrent.ScheduledService;
@@ -35,12 +37,14 @@ public class LogViewController extends AppenderBase {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
private final StringBuffer buffer;
+ private final ThrowableHandlingConverter throwableConverter;
@FXML
private TextArea logArea;
public LogViewController() {
buffer = new StringBuffer();
+ throwableConverter = new RootCauseFirstThrowableProxyConverter();
ScheduledService schedule = new ScheduledService() {
@Override
@@ -81,5 +85,11 @@ protected void append(ILoggingEvent loggingEvent) {
buffer.append(String.format("%s | %-5s | ", DATE_FORMATTER.format(timestamp), loggingEvent.getLevel()));
buffer.append(message);
buffer.append("\n");
+
+ var error = loggingEvent.getThrowableProxy();
+ if (error != null) {
+ var s = throwableConverter.convert(loggingEvent);
+ buffer.append(s);
+ }
}
}
diff --git a/src/main/resources/org/terasology/launcher/views/application.fxml b/src/main/resources/org/terasology/launcher/views/application.fxml
index 4738adb42..30a0b0c35 100644
--- a/src/main/resources/org/terasology/launcher/views/application.fxml
+++ b/src/main/resources/org/terasology/launcher/views/application.fxml
@@ -75,7 +75,7 @@
-
+
diff --git a/src/test/java/org/terasology/launcher/Matchers.java b/src/test/java/org/terasology/launcher/Matchers.java
new file mode 100644
index 000000000..cedc0721b
--- /dev/null
+++ b/src/test/java/org/terasology/launcher/Matchers.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2020 MovingBlocks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.terasology.launcher;
+
+import org.hamcrest.Matcher;
+import org.hamcrest.core.AllOf;
+import org.hamcrest.core.IsIterableContaining;
+
+import java.util.Collection;
+import java.util.stream.Collectors;
+
+public final class Matchers {
+
+ private Matchers() { }
+
+ /**
+ * Creates a matcher for {@link Iterable}s that matches when consecutive passes over the
+ * examined {@link Iterable} yield at least one item that is equal to the corresponding
+ * item from the specified items
. Whilst matching, each traversal of the
+ * examined {@link Iterable} will stop as soon as a matching item is found.
+ *
+ * For example:
+ *
+ * {@code
+ * assertThat(Arrays.asList("foo", "bar", "baz"), hasItemsFrom(List.of("baz", "foo")))
+ * }
+ *
+ * @param items
+ * the items to compare against the items provided by the examined {@link Iterable}
+ */
+ public static Matcher> hasItemsFrom(Collection items) {
+ /* org.hamcrest.Matchers.hasItems(T...) takes variable arguments, so if
+ * we want to match against a list, we reimplement it.
+ */
+ return new AllOf<>(items.stream().map(
+ IsIterableContaining::hasItem
+ ).collect(Collectors.toUnmodifiableList()));
+ }
+}
diff --git a/src/test/java/org/terasology/launcher/SlowTest.java b/src/test/java/org/terasology/launcher/SlowTest.java
new file mode 100644
index 000000000..0ad816b03
--- /dev/null
+++ b/src/test/java/org/terasology/launcher/SlowTest.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020 MovingBlocks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.terasology.launcher;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+
+/**
+ * A test that is slower than normal.
+ *
+ * e.g. it uses external processes or other resources that take a little time to set up and
+ * tear down.
+ */
+@Target({ METHOD, ANNOTATION_TYPE })
+@Retention(RUNTIME)
+@Test
+@Tag("slow")
+public @interface SlowTest {
+}
diff --git a/src/test/java/org/terasology/launcher/StringIteratorInputStream.java b/src/test/java/org/terasology/launcher/StringIteratorInputStream.java
new file mode 100644
index 000000000..54c8c50a4
--- /dev/null
+++ b/src/test/java/org/terasology/launcher/StringIteratorInputStream.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2020 MovingBlocks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.terasology.launcher;
+
+import com.google.common.primitives.Ints;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+/**
+ * An {@code InputStream} fed by an {@code Iterator}.
+ *
+ * Testing something that consumes an {@link InputStream} {@linkplain BufferedReader#lines() line-by-line}? Your test
+ * fixture has a list of strings to use as inputs; how hard could it be to get an InputStream that will pass them one
+ * at a time?
+ *
+ * About this hard, it turns out.
+ *
+ * This was not written with high-volume or high-throughput use cases in mind. It will handle a few
+ * lines (or even a few kilobytes) in your test suite just fine. I do not recommend you run your server
+ * traffic through it.
+ */
+public class StringIteratorInputStream extends InputStream {
+ static final int COOLDOWN_LENGTH = 1;
+
+ int cooldown;
+ private final Iterator source;
+ private byte[] currentLine;
+ private int byteIndex;
+
+ public StringIteratorInputStream(Iterator source) {
+ this.source = source;
+ resetCooldown();
+ }
+
+ @Override
+ public int available() {
+ if (currentLine == null) {
+ // Playing hard-to-get with StreamDecoder.read. If it finishes a read
+ // and still has room in its buffer, it'll check if we're ready again
+ // right away. When we say we still aren't ready yet, it backs off.
+ if (cooldown > 0) {
+ cooldown -= 1;
+ return 0;
+ }
+ if (!loadNextLine()) {
+ // there's no more to be had. for real this time!
+ return 0;
+ }
+ }
+ return availableInCurrentLine();
+ }
+
+ @Override
+ public int read() {
+ if (currentLine == null) {
+ var gotNext = loadNextLine();
+ if (!gotNext) {
+ return -1;
+ }
+ }
+ var c = currentLine[byteIndex];
+ byteIndex++;
+ if (byteIndex >= currentLine.length) {
+ currentLine = null;
+ resetCooldown();
+ }
+ return c;
+ }
+
+ @Override
+ public int read(final byte[] b, final int off, final int len) throws IOException {
+ if (len == 0) {
+ return 0;
+ }
+ // Even if available() says we're empty, our superclass wants us to try
+ // to come up with at least one byte, blocking if necessary. Otherwise
+ // StreamDecoder.readBytes says "Underlying input stream returned zero bytes"
+ // and implodes.
+ @SuppressWarnings("UnstableApiUsage") var availableLength =
+ Ints.constrainToRange(availableInCurrentLine(), 1, len);
+ return super.read(b, off, availableLength);
+ }
+
+ protected int availableInCurrentLine() {
+ if (currentLine == null) {
+ return 0;
+ } else {
+ return currentLine.length - byteIndex;
+ }
+ }
+
+ private void resetCooldown() {
+ cooldown = COOLDOWN_LENGTH;
+ }
+
+ /**
+ * @return true when it succeeds in getting the next line, false when the input source has no more
+ */
+ private boolean loadNextLine() {
+ try {
+ final String nextString = source.next();
+ currentLine = nextString.getBytes();
+ } catch (NoSuchElementException e) {
+ return false;
+ } finally {
+ byteIndex = 0;
+ }
+ return true;
+ }
+}
diff --git a/src/test/java/org/terasology/launcher/game/MockProcesses.java b/src/test/java/org/terasology/launcher/game/MockProcesses.java
new file mode 100644
index 000000000..e5e84a235
--- /dev/null
+++ b/src/test/java/org/terasology/launcher/game/MockProcesses.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2020 MovingBlocks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.terasology.launcher.game;
+
+import org.apache.commons.io.input.NullInputStream;
+import org.terasology.launcher.StringIteratorInputStream;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Iterator;
+import java.util.Random;
+import java.util.concurrent.Callable;
+
+public final class MockProcesses {
+ static final Callable EXCEPTION_THROWING_START = () -> {
+ throw new OurIOException("GRUMPY \uD83D\uDC7F");
+ };
+
+ private MockProcesses() {
+ }
+
+ public static class HappyGameProcess extends Process {
+
+ private final InputStream inputStream;
+ private long pid;
+
+ HappyGameProcess() {
+ inputStream = new NullInputStream(0);
+ }
+
+ HappyGameProcess(String processOutput) {
+ inputStream = new ByteArrayInputStream(processOutput.getBytes());
+ }
+
+ @Override
+ public OutputStream getOutputStream() {
+ throw new UnsupportedOperationException("Stub.");
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return inputStream;
+ }
+
+ @Override
+ public InputStream getErrorStream() {
+ throw new UnsupportedOperationException("Stub; implement if we stop merging stdout and error streams.");
+ }
+
+ @Override
+ public int waitFor() {
+ return exitValue();
+ }
+
+ @Override
+ public long pid() {
+ if (this.pid == 0) {
+ this.pid = new Random().nextLong();
+ }
+ return this.pid;
+ }
+
+ @Override
+ public int exitValue() {
+ return 0;
+ }
+
+ @Override
+ public void destroy() {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ static class OneLineAtATimeProcess extends HappyGameProcess {
+ private final Iterator lines;
+
+ OneLineAtATimeProcess(Iterator lines) {
+ this.lines = lines;
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ // π€ If RunGameTask had a way to do
+ // Iterator lines = adapt(process)
+ // and we could provide a different adapter for our mock process than a system
+ // process, we could probably do without StringIteratorInputStream. ?
+ return new StringIteratorInputStream(lines);
+ }
+ }
+
+ static class OurIOException extends IOException {
+ OurIOException(final String grumpy) {
+ super(grumpy);
+ }
+ }
+}
diff --git a/src/test/java/org/terasology/launcher/game/TestGameRunner.java b/src/test/java/org/terasology/launcher/game/TestGameRunner.java
deleted file mode 100644
index 42c4a6730..000000000
--- a/src/test/java/org/terasology/launcher/game/TestGameRunner.java
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- * Copyright 2017 MovingBlocks
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.terasology.launcher.game;
-
-import org.hamcrest.Matchers;
-import org.junit.Before;
-import org.junit.Test; // junit because Spf4jTestLogRunner is not a Jupiter Extension yet
-import org.junit.runner.RunWith;
-import org.spf4j.log.Level;
-import org.spf4j.test.log.LogAssert;
-import org.spf4j.test.log.TestLoggers;
-import org.spf4j.test.log.annotations.CollectLogs;
-import org.spf4j.test.log.annotations.ExpectLog;
-import org.spf4j.test.log.annotations.PrintLogs;
-import org.spf4j.test.log.junit4.Spf4jTestLogJUnitRunner;
-import org.spf4j.test.matchers.LogMatchers;
-
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-
-import static org.hamcrest.Matchers.allOf;
-import static org.hamcrest.Matchers.containsString;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.anyInt;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.spy;
-import static org.mockito.Mockito.when;
-
-
-// /!\ WARNING: If you take off this Spf4jTestLogJUnitRunner
-// (e.g. to run as junit5 instead of vintage-junit4), it will
-// initially look like tests pass but the annotations won't work.
-// Make sure you can still get the assertions to *fail* when changed,
-// and that failures still show all the logs specified in CollectLogs
-// annotations.
-//
-// See: https://github.com/zolyfarkas/spf4j/issues/50
-@RunWith(Spf4jTestLogJUnitRunner.class)
-public class TestGameRunner {
-
- /**
- * Passed around to GameRunners to simulate a running game process.
- */
- private Process gameProcess;
- private TestLoggers testLog;
-
- @Before
- public void setup() throws Exception {
- testLog = TestLoggers.sys();
-
- // Create fake game process to give to GameRunner
- gameProcess = mock(Process.class);
- when(gameProcess.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[0]));
- }
-
- @Test
- // CollectLogs(TRACE) isn't necessary for this test's assertions.
- // Logs are shown on test failure, but the default doesn't include TRACE.
- // Because TRACE logs are relevant to this test we use this to include them in the output.
- @CollectLogs(minLevel = Level.TRACE)
- public void testGameOutput() throws Exception {
- // Simulate game process outputting TEST_STRING
- String testString = "LineOne\nLineTwo";
- when(gameProcess.getInputStream()).thenReturn(new ByteArrayInputStream(testString.getBytes()));
-
- // Can have checks be relatively loose:
- // With logs in any category, at TRACE or above,
- LogAssert broadExpectation = testLog.expect("", Level.TRACE,
- // expect a message that contains "LineOne"
- LogMatchers.hasMatchingMessage(containsString("LineOne")),
- // also a message that contains "LineTwo"
- LogMatchers.hasMatchingMessage(containsString("LineTwo"))
- // or to match the message exactly,
- // LogMatchers.hasMessage("LineTwo")
- );
-
- // You can also get very specific about how the log message is made.
- // We'll define a matcher to look specifically at records whose
- // format string begin with "Game output"
- var hasGameOutputFormat = LogMatchers.hasFormatWithPattern("^Game output.*");
-
- // These expectations are restricted specifically to logs sent to a logger named like this class.
- LogAssert detailedExpectation = testLog.expect(GameRunner.class.getName(), Level.TRACE,
- // Check its format against our matcher, and also that it has an argument that is exactly "LineOne"
- allOf(hasGameOutputFormat, LogMatchers.hasArguments("LineOne")),
- // ...and also for line two.
- allOf(hasGameOutputFormat, LogMatchers.hasArguments("LineTwo"))
- );
-
- // Log expectations must be set up *before* the logs are generated.
- // Now we can run the code under test:
- GameRunner gameRunner = new GameRunner(gameProcess);
- gameRunner.run();
-
- // And check to see if the assertions held.
- broadExpectation.assertObservation();
- detailedExpectation.assertObservation();
-
- // be sure to run those assertions! If you set expectations but never check them,
- // this test runner does *not* notice and warn you.
- }
-
- @Test
- // There's an annotation you can use for simple cases. It auto-asserts when the method finishes.
- @ExpectLog(level = Level.DEBUG, messageRegexp = "Game closed with the exit value '0'.")
- public void testGameExitSuccessful() {
- GameRunner gameRunner = new GameRunner(gameProcess);
- gameRunner.run();
- }
-
- @Test
- // The log-printer's default setting is to display all logged errors.
- // But we know the code under test will be logging an error and that
- // doesn't mean there's an error in the scenario. We can override the
- // settings for this logger to not print error logs from that class.
- //
- // The error logs will still be collected and displayed if the test fails.
- @PrintLogs(category = "org.terasology.launcher.game.GameRunner", minLevel = Level.OFF, ideMinLevel = Level.OFF, greedy = true)
- public void testGameEarlyInterrupt() throws Exception {
- GameRunner gameRunner = spy(new GameRunner(gameProcess));
- // Simulate early game thread interruption
- when(gameRunner.isInterrupted()).thenReturn(true);
-
- var interruptionLogged = testLog.expect("", Level.DEBUG, LogMatchers.hasMessage("Game thread interrupted."));
-
- // Run game process
- gameRunner.run();
-
- interruptionLogged.assertObservation();
- }
-
- @Test
- @PrintLogs(category = "org.terasology.launcher.game.GameRunner", minLevel = Level.OFF, ideMinLevel = Level.OFF, greedy = true)
- public void testGameLateInterrupt() throws Exception {
- // Simulate late game thread interruption (while game is running)
- when(gameProcess.waitFor()).thenThrow(new InterruptedException());
-
- var loggedException = testLog.expect("", Level.ERROR, Matchers.allOf(
- LogMatchers.hasMatchingExtraThrowable(Matchers.instanceOf(InterruptedException.class)),
- LogMatchers.hasMessage("The game thread was interrupted!")
- ));
-
- // Run game process
- GameRunner gameRunner = new GameRunner(gameProcess);
- gameRunner.run();
-
- // Make sure GameRunner logs an error with an InterruptedException
- loggedException.assertObservation();
- }
-
- @Test
- @PrintLogs(category = "org.terasology.launcher.game.GameRunner", minLevel = Level.OFF, ideMinLevel = Level.OFF, greedy = true)
- public void testGameOutputError() throws Exception {
- // Simulate an invalid output stream (that throws an IOException when read from because it's unhappy with its life)
- InputStream badStream = mock(InputStream.class);
- when(badStream.read(any(byte[].class), anyInt(), anyInt())).thenThrow(new IOException("Unhappy with life!"));
-
- when(gameProcess.getInputStream()).thenReturn(badStream);
-
- var loggedException = testLog.expect("", Level.ERROR, Matchers.allOf(
- LogMatchers.hasMatchingExtraThrowable(Matchers.instanceOf(IOException.class)),
- LogMatchers.hasMessage("Could not read game output!")
- ));
-
- // Run game process
- GameRunner gameRunner = new GameRunner(gameProcess);
- gameRunner.run();
-
- // Make sure GameRunner logs an error with an IOException
- loggedException.assertObservation();
- }
-}
diff --git a/src/test/java/org/terasology/launcher/game/TestGameStarter.java b/src/test/java/org/terasology/launcher/game/TestGameStarter.java
new file mode 100644
index 000000000..77fc138e4
--- /dev/null
+++ b/src/test/java/org/terasology/launcher/game/TestGameStarter.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2020 MovingBlocks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.terasology.launcher.game;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.event.Level;
+import org.terasology.launcher.util.JavaHeapSize;
+
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasItem;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.terasology.launcher.Matchers.hasItemsFrom;
+
+public class TestGameStarter {
+ static final String JAVA_ARG_1 = "-client";
+ static final String JAVA_ARG_2 = "--enable-preview";
+ static final String GAME_ARG_1 = "--no-splash";
+ static final String GAME_DIR = "game";
+ static final String GAME_DATA_DIR = "game_data";
+ static final JavaHeapSize HEAP_MIN = JavaHeapSize.NOT_USED;
+ static final JavaHeapSize HEAP_MAX = JavaHeapSize.GB_4;
+ static final Level LOG_LEVEL = Level.INFO;
+
+ private final FileSystem fs = FileSystems.getDefault();
+ private Path gamePath;
+ private List javaParams;
+ private List gameParams;
+ private Path gameDataPath;
+
+ @BeforeEach
+ public void setup() {
+ gamePath = fs.getPath(GAME_DIR);
+ gameDataPath = fs.getPath(GAME_DATA_DIR);
+ javaParams = List.of(JAVA_ARG_1, JAVA_ARG_2);
+ gameParams = List.of(GAME_ARG_1);
+ }
+
+ @Test
+ public void testConstruction() {
+ GameStarter starter = newStarter();
+ assertNotNull(starter);
+ }
+
+ private GameStarter newStarter() {
+ return new GameStarter(gamePath, gameDataPath, HEAP_MIN, HEAP_MAX, javaParams, gameParams, LOG_LEVEL);
+ }
+
+ @Test
+ public void testJre() {
+ GameStarter task = newStarter();
+ // This is the sort of test where the code under test and the expectation are just copies
+ // of the same source. But since there's a plan to separate the launcher runtime from the
+ // game runtime, the runtime location seemed like a good thing to specify in its own test.
+ assertTrue(task.getRuntimePath().startsWith(Path.of(System.getProperty("java.home"))));
+ }
+
+ @Test
+ public void testBuildProcess() {
+ GameStarter starter = newStarter();
+ ProcessBuilder processBuilder = starter.processBuilder;
+ final Path gameJar = gamePath.resolve(Path.of("libs", "Terasology.jar"));
+
+ assertNotNull(processBuilder.directory());
+ assertEquals(gamePath, processBuilder.directory().toPath());
+ assertThat(processBuilder.command(), hasItem(gameJar.toString()));
+ assertThat(processBuilder.command(), hasItemsFrom(gameParams));
+ assertThat(processBuilder.command(), hasItemsFrom(javaParams));
+ // TODO: heap min, heap max, log level
+ // could parameterize this test for the things that are optional?
+ // heap min, heap max, log level, gameParams and javaParams are all optional.
+ }
+}
diff --git a/src/test/java/org/terasology/launcher/game/TestRunGameTask.java b/src/test/java/org/terasology/launcher/game/TestRunGameTask.java
new file mode 100644
index 000000000..2143d4f4b
--- /dev/null
+++ b/src/test/java/org/terasology/launcher/game/TestRunGameTask.java
@@ -0,0 +1,356 @@
+// Copyright 2020 The Terasology Foundation
+// SPDX-License-Identifier: Apache-2.0
+
+package org.terasology.launcher.game;
+
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import javafx.concurrent.WorkerStateEvent;
+import javafx.util.Pair;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.spf4j.log.Level;
+import org.spf4j.test.log.LogAssert;
+import org.spf4j.test.log.TestLoggers;
+import org.spf4j.test.matchers.LogMatchers;
+import org.terasology.launcher.SlowTest;
+import org.testfx.framework.junit5.ApplicationExtension;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.anyOf;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasToString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.not;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+@Timeout(5)
+@ExtendWith(ApplicationExtension.class)
+public class TestRunGameTask {
+
+ static final int EXIT_CODE_OK = 0;
+ static final int EXIT_CODE_ERROR = 1;
+ static final int EXIT_CODE_SIGKILL = 0b10000000 + 9; // SIGKILL = 9
+ static final int EXIT_CODE_SIGTERM = 0b10000000 + 15; // SIGTERM = 15
+
+ private ExecutorService executor;
+
+ @SuppressWarnings("SameParameterValue")
+ static ExecutorService singleThreadExecutor(String nameFormat) {
+ var builder = new ThreadFactoryBuilder().setNameFormat(nameFormat);
+ return Executors.newSingleThreadExecutor(builder.build());
+ }
+
+ @BeforeEach
+ void setUp() {
+ // Would it be plausible to do a @BeforeAll thing that provides a thread pool we don't have
+ // to tear down for each test? What kind of assertions would we have to make between tests
+ // to ensure it's in a clean state?
+ executor = singleThreadExecutor("gameTask-%s");
+ }
+
+ @AfterEach
+ void tearDown() throws InterruptedException {
+ assertThat(executor.shutdownNow(), empty());
+ executor.awaitTermination(100, TimeUnit.MILLISECONDS);
+ assertTrue(executor.isTerminated());
+ }
+
+ @Test
+ public void testGameOutput() throws InterruptedException, RunGameTask.RunGameError {
+ String[] gameOutputLines = {"LineOne", "LineTwo"};
+ Process gameProcess = new MockProcesses.HappyGameProcess(String.join("\n", gameOutputLines));
+
+ var hasGameOutputFormat = LogMatchers.hasFormatWithPattern("^Game output.*");
+
+ LogAssert detailedExpectation = TestLoggers.sys().expect(
+ RunGameTask.class.getName(), Level.INFO,
+ allOf(hasGameOutputFormat, LogMatchers.hasArguments(gameOutputLines[0])),
+ allOf(hasGameOutputFormat, LogMatchers.hasArguments(gameOutputLines[1]))
+ );
+
+ new NonTimingGameTask(null).monitorProcess(gameProcess);
+
+ detailedExpectation.assertObservation();
+ }
+
+ @SlowTest
+ @DisabledOnOs(OS.WINDOWS)
+ public void testGameExitSuccessful() throws InterruptedException, ExecutionException {
+ var gameTask = new NonTimingGameTask(UnixProcesses.COMPLETES_SUCCESSFULLY);
+
+ // we can use TestLogger expectations without Slf4jTestRunner, we just can't
+ // depend on their annotations. I think.
+ var hasExitMessage = TestLoggers.sys().expect(
+ RunGameTask.class.getName(), Level.DEBUG,
+ allOf(
+ LogMatchers.hasFormatWithPattern("Game closed with the exit value.*"),
+ LogMatchers.hasArguments(EXIT_CODE_OK)
+ )
+ );
+
+ executor.submit(gameTask);
+ gameTask.get();
+
+ hasExitMessage.assertObservation(100, TimeUnit.MILLISECONDS);
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ public void testGameExitError() throws InterruptedException {
+ var gameTask = new RunGameTask(UnixProcesses.COMPLETES_WITH_ERROR);
+
+ var hasExitMessage = TestLoggers.sys().expect(
+ RunGameTask.class.getName(), Level.DEBUG,
+ allOf(
+ LogMatchers.hasFormatWithPattern("Game closed with the exit value.*"),
+ LogMatchers.hasArguments(EXIT_CODE_ERROR)
+ )
+ );
+
+ executor.submit(gameTask);
+ var thrown = assertThrows(ExecutionException.class, gameTask::get);
+ Throwable exc = thrown.getCause();
+ assertThat(exc, instanceOf(RunGameTask.GameExitError.class));
+ assertEquals(EXIT_CODE_ERROR, ((RunGameTask.GameExitError) exc).exitValue);
+
+ hasExitMessage.assertObservation(100, TimeUnit.MILLISECONDS);
+ }
+
+ @Test
+ public void testBadStarter() {
+ var gameTask = new RunGameTask(MockProcesses.EXCEPTION_THROWING_START);
+
+ executor.submit(gameTask);
+ var thrown = assertThrows(ExecutionException.class, gameTask::get);
+ Throwable exc = thrown.getCause();
+ assertThat(exc, instanceOf(RunGameTask.GameStartError.class));
+ assertThat(exc.getCause(), instanceOf(MockProcesses.OurIOException.class));
+ }
+
+ @SlowTest
+ public void testExeNotFound() {
+ // not disabled-on-Windows because all platforms should be capable of failing
+ var gameTask = new RunGameTask(UnixProcesses.NO_SUCH_COMMAND);
+
+ executor.submit(gameTask);
+ var thrown = assertThrows(ExecutionException.class, gameTask::get);
+ Throwable exc = thrown.getCause();
+ assertThat(exc, instanceOf(RunGameTask.GameStartError.class));
+ var cause = exc.getCause();
+ assertThat(cause, instanceOf(IOException.class));
+ assertThat(cause, anyOf(
+ hasToString(containsString("No such file")),
+ hasToString(containsString("The system cannot find the file specified"))
+ ));
+ }
+
+ @SlowTest
+ @DisabledOnOs(OS.WINDOWS)
+ public void testTerminatedProcess() {
+ var gameTask = new RunGameTask(new UnixProcesses.SelfDestructingProcess(5));
+
+ executor.submit(gameTask);
+ var thrown = assertThrows(ExecutionException.class, gameTask::get);
+ Throwable exc = thrown.getCause();
+ assertThat(exc, instanceOf(RunGameTask.GameExitError.class));
+ final int exitValue = ((RunGameTask.GameExitError) exc).exitValue;
+ // It is redundant to test both that a value is one thing and
+ // also is not a different thing, but it'd be informative test output if
+ // it fails with the other signal.
+ assertThat(exitValue, allOf(equalTo(EXIT_CODE_SIGTERM), not(EXIT_CODE_SIGKILL)));
+ }
+
+
+ @Test
+ public void testSuccessEvent() throws Exception {
+ // Matches {@link RunGameTask.START_MATCH}
+ final String confirmedStart = "terasology.engine.TerasologyEngine - Initialization completed";
+
+ final List mockOutputLines = List.of(
+ "some babble\n",
+ confirmedStart + "\n",
+ "more babble\n",
+ "have a nice day etc\n"
+ );
+
+ // A record of observed events (thread-safe).
+ final Queue> actualHistory = new ConcurrentLinkedQueue<>();
+
+ final List> expectedHistory = List.of(
+ Happenings.PROCESS_OUTPUT_LINE.val(),
+ Happenings.PROCESS_OUTPUT_LINE.val(),
+ Happenings.TASK_VALUE_SET.val(true), // that line was the confirmedStart event!
+ Happenings.PROCESS_OUTPUT_LINE.val(),
+ Happenings.PROCESS_OUTPUT_LINE.val(),
+ Happenings.TASK_COMPLETED.val()
+ );
+
+ final Runnable handleLineSent = () -> actualHistory.add(Happenings.PROCESS_OUTPUT_LINE.val());
+
+ // This makes our "process," which streams out its lines and runs the callback after each.
+ final Process lineAtATimeProcess = new MockProcesses.OneLineAtATimeProcess(
+ spyingIterator(mockOutputLines, handleLineSent));
+
+ // RunGameTask, the code under test, finally appears.
+ final var gameTask = new RunGameTask(() -> lineAtATimeProcess);
+
+ // Arrange to record when things happen.
+ gameTask.valueProperty().addListener(
+ (x, y, newValue) -> actualHistory.add(Happenings.TASK_VALUE_SET.val(newValue))
+ );
+
+ gameTask.addEventHandler(
+ WorkerStateEvent.WORKER_STATE_SUCCEEDED,
+ (event) -> actualHistory.add(Happenings.TASK_COMPLETED.val())
+ );
+
+ // Act!
+ executor.submit(gameTask);
+ var actualReturnValue = gameTask.get(); // task.get blocks until it has run to completion
+
+ // Assert!
+ assertTrue(actualReturnValue);
+
+ assertIterableEquals(expectedHistory, actualHistory);
+ }
+
+ @Test
+ public void testFastExitDoesNotResultInSuccess() throws ExecutionException, InterruptedException {
+ final List mockOutputLines = List.of(
+ "this is a line from some process\n",
+ "oh, everything is over already, goodbye"
+ );
+
+ // A record of observed events (thread-safe).
+ final Queue> actualHistory = new ConcurrentLinkedQueue<>();
+
+ final List> expectedHistory = List.of(
+ Happenings.PROCESS_OUTPUT_LINE.val(),
+ Happenings.PROCESS_OUTPUT_LINE.val(),
+ Happenings.TASK_FAILED.val()
+ );
+
+ final Runnable handleLineSent = () -> actualHistory.add(Happenings.PROCESS_OUTPUT_LINE.val());
+
+ // This makes our "process," which streams out its lines and runs the callback after each.
+ final Process lineAtATimeProcess = new MockProcesses.OneLineAtATimeProcess(
+ spyingIterator(mockOutputLines, handleLineSent));
+
+ // RunGameTask, the code under test, finally appears.
+ final var gameTask = new RunGameTask(() -> lineAtATimeProcess);
+
+ // Arrange to record when things happen.
+ gameTask.valueProperty().addListener(
+ (x, y, newValue) -> actualHistory.add(Happenings.TASK_VALUE_SET.val(newValue))
+ );
+
+ gameTask.addEventHandler(
+ WorkerStateEvent.WORKER_STATE_SUCCEEDED,
+ (event) -> actualHistory.add(Happenings.TASK_COMPLETED.val())
+ );
+ gameTask.addEventHandler(
+ WorkerStateEvent.WORKER_STATE_FAILED,
+ (event) -> actualHistory.add(Happenings.TASK_FAILED.val())
+ );
+
+ // Act!
+ executor.submit(gameTask);
+ var thrown = assertThrows(ExecutionException.class, gameTask::get);
+ assertThat(thrown.getCause(), instanceOf(RunGameTask.GameExitTooSoon.class));
+
+ assertIterableEquals(expectedHistory, actualHistory, renderColumns(actualHistory, expectedHistory));
+ }
+
+ public static Supplier renderColumns(Iterable actualIterable, Iterable expectedIterable) {
+ return () -> {
+ var outputs = new StringBuilder(256);
+
+ var expectedIter = expectedIterable.iterator();
+ var actualIter = actualIterable.iterator();
+ var ended = "β".repeat(20);
+
+ outputs.append(String.format("%27s\t%s\n", "Expected", "Actual"));
+ do {
+ var expected = expectedIter.hasNext() ? expectedIter.next() : ended;
+ var actual = actualIter.hasNext() ? actualIter.next() : ended;
+ var compared = expected.equals(actual) ? " " : "β";
+
+ outputs.append(String.format("%27s %s %s\n", expected, compared, actual));
+ } while (expectedIter.hasNext() || actualIter.hasNext());
+
+ return outputs.toString();
+ };
+ }
+
+
+ /**
+ * An Iterator that runs the given callback every iteration.
+ *
+ * @param list to be iterated over
+ * @param onNext to be called each iteration
+ */
+ public static Iterator spyingIterator(List list, Runnable onNext) {
+ return list.stream().takeWhile(string -> {
+ onNext.run();
+ return true;
+ }).iterator();
+ }
+
+ /** Things that happen in RunGameTask that we want to make assertions about. */
+ enum Happenings {
+ PROCESS_OUTPUT_LINE,
+ TASK_VALUE_SET,
+ TASK_COMPLETED,
+ TASK_FAILED;
+
+ ValuedHappening val(T value) {
+ return new ValuedHappening<>(this, value);
+ }
+
+ ValuedHappening val() {
+ return new ValuedHappening<>(this, null);
+ }
+
+ static final class ValuedHappening extends Pair {
+ private ValuedHappening(final Happenings key, final T value) {
+ super(key, value);
+ }
+ }
+ }
+
+
+ static class NonTimingGameTask extends RunGameTask {
+ NonTimingGameTask(final Callable starter) {
+ super(starter);
+ }
+
+ @Override
+ protected void startTimer() {
+ // no timers here.
+ }
+ }
+}
diff --git a/src/test/java/org/terasology/launcher/game/UnixProcesses.java b/src/test/java/org/terasology/launcher/game/UnixProcesses.java
new file mode 100644
index 000000000..7322ed6a9
--- /dev/null
+++ b/src/test/java/org/terasology/launcher/game/UnixProcesses.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2020 MovingBlocks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.terasology.launcher.game;
+
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.UUID;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+public final class UnixProcesses {
+ static final Callable COMPLETES_SUCCESSFULLY = runProcess("true");
+ static final Callable COMPLETES_WITH_ERROR = runProcess("false");
+ static final Callable NO_SUCH_COMMAND = runProcess(() -> {
+ // If you have a program with this name on your path while running these tests,
+ // you have incredible luck.
+ return "nope" + UUID.randomUUID();
+ });
+
+ private UnixProcesses() {
+ }
+
+ private static Callable runProcess(String... command) {
+ final ProcessBuilder processBuilder = new ProcessBuilder(command);
+ processBuilder.redirectErrorStream(true);
+ return processBuilder::start;
+ }
+
+ private static Callable runProcess(Supplier command) {
+ return runProcess(command.get());
+ }
+
+ private static class SlowTicker implements Callable {
+ private final int seconds;
+
+ SlowTicker(int seconds) {
+ this.seconds = seconds;
+ }
+
+ @Override
+ public Process call() throws IOException {
+ var pb = new ProcessBuilder(
+ "/bin/bash", "-c",
+ String.format("for i in $( seq %s ) ; do echo $i ; sleep 1 ; done", seconds)
+ );
+ var proc = pb.start();
+ LoggerFactory.getLogger(SlowTicker.class).debug(" β² Ticker PID {}", proc.pid());
+ return proc;
+ }
+ }
+
+ static class SelfDestructingProcess extends SlowTicker {
+ SelfDestructingProcess(final int seconds) {
+ super(seconds);
+ }
+
+ @Override
+ public Process call() throws IOException {
+ var proc = super.call();
+ new ScheduledThreadPoolExecutor(1).schedule(
+ // looks like destroy = SIGTERM,
+ // destroyForcibly = SIGKILL
+ proc::destroy, 100, TimeUnit.MILLISECONDS);
+ return proc;
+ }
+ }
+}
diff --git a/src/test/java/org/terasology/launcher/util/TestFileUtils.java b/src/test/java/org/terasology/launcher/util/TestFileUtils.java
index eed59a5cb..b243c2fd3 100644
--- a/src/test/java/org/terasology/launcher/util/TestFileUtils.java
+++ b/src/test/java/org/terasology/launcher/util/TestFileUtils.java
@@ -1,23 +1,12 @@
-/*
- * Copyright 2017 MovingBlocks
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
+// Copyright 2020 The Terasology Foundation
+// SPDX-License-Identifier: Apache-2.0
package org.terasology.launcher.util;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.io.TempDir;
import org.spf4j.log.Level;
import org.spf4j.test.log.TestLoggers;
@@ -52,9 +41,11 @@ public class TestFileUtils {
public Path tempFolder;
@Test
+ @DisabledOnOs(OS.WINDOWS)
public void testCannotCreateDirectory() {
final Path directory = tempFolder.resolve(DIRECTORY_NAME);
var tempFolderFile = tempFolder.toFile();
+ // for some reason this fails on Windows...
assert tempFolderFile.setWritable(false);
try {
@@ -78,6 +69,7 @@ public void testNotDirectory() throws IOException {
}
@Test
+ @DisabledOnOs(OS.WINDOWS)
public void testNoPerms() throws IOException {
var directory = tempFolder.resolve(DIRECTORY_NAME);
Files.createDirectory(directory, PosixFilePermissions.asFileAttribute(Collections.emptySet()));