ConsoleExtension.java

package net.morimekta.testing.junit5;

import net.morimekta.io.tty.TTY;
import net.morimekta.io.tty.TTYMode;
import net.morimekta.io.tty.TTYSize;
import net.morimekta.testing.console.Console;
import net.morimekta.testing.console.ConsoleManager;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.junit.jupiter.api.extension.TestWatcher;

import static net.morimekta.testing.console.Console.DEFAULT_TERMINAL_SIZE;
import static net.morimekta.testing.junit5.AnnotationUtil.getTopAnnotation;
import static net.morimekta.testing.junit5.AnnotationUtil.isAnnotationPresent;

/**
 * A JUnit 5 extension that provides fake console I/O with parameter injection support.
 * Hijacks {@link System#in}, {@link System#out}, and {@link System#err} for the duration
 * of each test, and restores the original streams afterward.
 *
 * <p>Supports injecting {@link Console} or {@link net.morimekta.io.tty.TTY} directly into
 * test method parameters, and can be configured with the following annotations:
 * <ul>
 *   <li>{@link ConsoleSize} - set terminal rows and columns</li>
 *   <li>{@link ConsoleInteractive} - set whether the TTY is interactive</li>
 *   <li>{@link ConsoleMode} - set the TTY mode (COOKED or RAW)</li>
 *   <li>{@link ConsoleDumpOutputOnFailure} - dump stdout on test failure</li>
 *   <li>{@link ConsoleDumpErrorOnFailure} - dump stderr on test failure</li>
 * </ul>
 *
 * <pre>{@code
 * {@literal@}ExtendWith(ConsoleExtension.class)
 * {@literal@}ConsoleSize(rows = 20, cols = 80)
 * public class MyTest {
 *     {@literal@}Test
 *     public void testMyThing(Console console) {
 *         System.out.println("Hello");
 *         assertThat(console.output(), containsString("Hello"));
 *     }
 * }
 * }</pre>
 */
public class ConsoleExtension extends ConsoleManager
        implements BeforeEachCallback, AfterEachCallback, TestWatcher, ParameterResolver {
    /**
     * Instantiate the extension.
     */
    public ConsoleExtension() {}

    @Override
    public void beforeEach(ExtensionContext context) {
        setTerminalSize(getTopAnnotation(context, ConsoleSize.class)
                                .map(a -> new TTYSize(a.rows(), a.cols()))
                                .orElse(DEFAULT_TERMINAL_SIZE));
        setInteractive(getTopAnnotation(context, ConsoleInteractive.class)
                               .map(ConsoleInteractive::value)
                               .orElse(true));
        setTTYMode(getTopAnnotation(context, ConsoleMode.class)
                           .map(ConsoleMode::value)
                           .orElse(TTYMode.COOKED));
        setDumpOutputOnFailure(isAnnotationPresent(context, ConsoleDumpOutputOnFailure.class));
        setForkOutput(!isAnnotationPresent(context, ConsoleDumpOutputOnFailure.class));
        setDumpErrorOnFailure(isAnnotationPresent(context, ConsoleDumpErrorOnFailure.class));
        setForkError(!isAnnotationPresent(context, ConsoleDumpErrorOnFailure.class));
        doBeforeEach();
    }

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        super.onTestFailed(context.getDisplayName());
    }

    @Override
    public void afterEach(ExtensionContext context) {
        super.doAfterEach();
    }

    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
            throws ParameterResolutionException {
        Class<?> type = parameterContext.getParameter().getType();
        return type.equals(Console.class) || type.equals(TTY.class);
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
            throws ParameterResolutionException {
        Class<?> type = parameterContext.getParameter().getType();
        if (type.equals(Console.class)) {
            return getConsole();
        }
        if (type.equals(TTY.class)) {
            return getTTY();
        }
        throw new ParameterResolutionException("No Console param for type " + type.getName());
    }
}