ConsoleWatcher.java

package net.morimekta.testing.junit4;

import net.morimekta.io.tty.TTY;
import net.morimekta.io.tty.TTYSize;
import net.morimekta.testing.console.Console;
import net.morimekta.testing.console.ConsoleManager;
import org.junit.rules.TestWatcher;
import org.junit.runner.Description;


/**
 * A JUnit 4 {@link TestWatcher} {@code @Rule} that sets up a fake console before each
 * test and restores the original system streams afterward. Provides fluent configuration
 * for terminal size, interactivity, and dumping output on test failure.
 *
 * <pre>{@code
 * public class Test {
 *     {@literal@}Rule
 *     public ConsoleWatcher console = new ConsoleWatcher()
 *         .withTerminalSize(20, 80)
 *         .interactive()
 *         .dumpOnFailure();
 *
 *     {@literal@}Test
 *     public void testOutput() {
 *         System.err.println("woot!");
 *
 *         assertThat(console.console().error(), is("woot!\n"));
 *     }
 * }
 * }</pre>
 *
 * <p>Note that this watcher hijacks {@link System#in}, {@link System#out} and
 * {@link System#err}. This may cause problems when other systems print to those
 * streams and expect the output to be present for others.
 */
public class ConsoleWatcher
        extends TestWatcher {
    private final ConsoleManager manager = new ConsoleManager();

    /**
     * Create a console watcher with default settings (non-interactive, default terminal size).
     */
    public ConsoleWatcher() {}

    private boolean started = false;

    private TTYSize defaultTerminalSize        = Console.DEFAULT_TERMINAL_SIZE;
    private boolean defaultInteractive         = false;
    private boolean defaultDumpOutputOnFailure = false;
    private boolean defaultDumpErrorOnFailure  = false;

    /**
     * Get the fake TTY for inspecting terminal properties (size, interactivity, mode).
     *
     * @return The testing TTY.
     */
    public TTY tty() {
        return manager.getTTY();
    }

    /**
     * Get the {@link Console} interface for inspecting captured I/O and setting input.
     *
     * @return The testing console.
     */
    public Console console() {
        return manager.getConsole();
    }

    /**
     * Set the current terminal size.
     *
     * @param rows Row count.
     * @param cols Column count.
     * @return The console watcher.
     */
    public ConsoleWatcher withTerminalSize(int rows, int cols) {
        var terminalSize = new TTYSize(rows, cols);
        if (started) {
            manager.setTerminalSize(terminalSize);
        } else {
            defaultTerminalSize = terminalSize;
        }
        return this;
    }

    /**
     * Set input mode to non-interactive. This makes the terminal no longer
     * behave like an interactive terminal (the default for ConsoleWatcher),
     * but as a wrapped shell script.
     *
     * @return The console watcher.
     */
    public ConsoleWatcher nonInteractive() {
        if (started) {
            manager.setInteractive(false);
        } else {
            defaultInteractive = false;
        }
        return this;
    }

    /**
     * Set input mode to interactive. This makes the terminal behave like an
     * interactive terminal (the default for ConsoleWatcher).
     *
     * @return The console watcher.
     */
    public ConsoleWatcher interactive() {
        if (started) {
            manager.setInteractive(true);
        } else {
            defaultInteractive = true;
        }
        return this;
    }

    /**
     * Dump stdout to error output on failure.
     *
     * @return The console watcher.
     */
    public ConsoleWatcher dumpOutputOnFailure() {
        if (started) {
            manager.setDumpOutputOnFailure(true);
            manager.setForkOutput(false);
        } else {
            defaultDumpOutputOnFailure = true;
        }
        return this;
    }

    /**
     * Dump stderr to error output on failure.
     *
     * @return The console watcher.
     */
    public ConsoleWatcher dumpErrorOnFailure() {
        if (started) {
            manager.setDumpErrorOnFailure(true);
            manager.setForkError(false);
        } else {
            defaultDumpErrorOnFailure = true;
        }
        return this;
    }

    /**
     * Dump both stdout and stderr to error output on failure.
     *
     * @return The console watcher.
     */
    public ConsoleWatcher dumpOnFailure() {
        dumpOutputOnFailure();
        dumpErrorOnFailure();
        return this;
    }

    @Override
    protected void starting(Description description) {
        started = true;
        manager.setTerminalSize(defaultTerminalSize);
        manager.setInteractive(defaultInteractive);
        manager.setDumpErrorOnFailure(defaultDumpErrorOnFailure);
        manager.setForkError(!defaultDumpErrorOnFailure);
        manager.setDumpOutputOnFailure(defaultDumpOutputOnFailure);
        manager.setForkOutput(!defaultDumpOutputOnFailure);
        manager.doBeforeEach();
    }

    @Override
    protected void failed(Throwable e, Description description) {
        manager.onTestFailed(description.getMethodName());
    }

    @Override
    protected void finished(Description description) {
        manager.doAfterEach();
    }
}