diff --git a/README.md b/README.md index 2ba69568..394fe535 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## webrepl -A quick and dirty REPL for JPv3's IIIF Cookbook recipes. +A quick and dirty, single-threaded REPL for JPv3's IIIF Cookbook recipes. ### How to Build diff --git a/pom.xml b/pom.xml index 6e25138d..a18d47ec 100644 --- a/pom.xml +++ b/pom.xml @@ -46,7 +46,7 @@ 0.11 0.0.0-SNAPSHOT - 5.0.7 + 5.0.10 webrepl @@ -60,6 +60,8 @@ 5.11.0 5.14.2 + 4.2.2 + 1.2.1 true @@ -116,6 +118,18 @@ ${mockito.version} test + + com.github.stefanbirkner + system-lambda + ${system.lambda.version} + test + + + org.awaitility + awaitility + ${awaitility.version} + test + @@ -169,7 +183,7 @@ - info.freelibrary.iiif.webrepl.Server + info.freelibrary.iiif.webrepl.WebRepl @@ -241,7 +255,8 @@ maven-surefire-plugin - ${jacoco.agent.arg} -javaagent:${org.mockito:mockito-core:jar} + ${jacoco.agent.arg} -javaagent:${org.mockito:mockito-core:jar} + --add-opens java.base/java.util=ALL-UNNAMED -Xshare:off ${test.http.port} @@ -255,7 +270,8 @@ ${test.http.port} - --add-opens java.base/sun.nio.ch=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED + -Xshare:off @@ -396,7 +412,7 @@ freelib-parent info.freelibrary - 12.0.0 + 12.0.3 diff --git a/src/main/java/info/freelibrary/iiif/webrepl/DiagConsumer.java b/src/main/java/info/freelibrary/iiif/webrepl/DiagnosticConsumer.java similarity index 92% rename from src/main/java/info/freelibrary/iiif/webrepl/DiagConsumer.java rename to src/main/java/info/freelibrary/iiif/webrepl/DiagnosticConsumer.java index 24022ac5..bcb79259 100644 --- a/src/main/java/info/freelibrary/iiif/webrepl/DiagConsumer.java +++ b/src/main/java/info/freelibrary/iiif/webrepl/DiagnosticConsumer.java @@ -13,7 +13,7 @@ /** * A diagnostic consumer for handling rejected code snippets. */ -public class DiagConsumer implements Consumer { +public class DiagnosticConsumer implements Consumer { /** A delimiter for the end of problematic code. */ private static final String END = "]]"; @@ -42,7 +42,7 @@ public class DiagConsumer implements Consumer { * * @param aBuffer An output buffer */ - public DiagConsumer(final StringBuilder aBuffer) { + public DiagnosticConsumer(final StringBuilder aBuffer) { myBuffer = aBuffer; } @@ -71,7 +71,7 @@ public void accept(final Diag aDiagnostic) { } // Add line numbers to what's returned - code = StringUtils.addLineNumbers(myBuffer.toString()); + code = StringUtils.addLineNumbers(myBuffer.toString().trim()); // Zero out the output buffer myBuffer.setLength(0); diff --git a/src/main/java/info/freelibrary/iiif/webrepl/EnvOptions.java b/src/main/java/info/freelibrary/iiif/webrepl/EnvOptions.java new file mode 100644 index 00000000..caf2bc7d --- /dev/null +++ b/src/main/java/info/freelibrary/iiif/webrepl/EnvOptions.java @@ -0,0 +1,42 @@ + +package info.freelibrary.iiif.webrepl; + +import java.time.Duration; + +import org.microhttp.Options; + +import info.freelibrary.util.Constants; +import info.freelibrary.util.Env; + +/** + * The server's environmental options. + */ +public class EnvOptions { + + /** The maximum request size. */ + private static final int DEFAULT_MAX_REQ_SIZE = 1_024 * 1_024; + + /** The default port at which the server listens. */ + private static final int DEFAULT_PORT = 8888; + + /** The read buffer size. */ + private static final int DEFAULT_READ_BUF_SIZE = 1_024 * 64; + + /** The request timeout. */ + private static final long DEFAULT_REQ_TIMEOUT = 60L; + + /** + * Gets the environmental options. + * + * @return The configuration options + */ + public Options getOpts() { + final int port = Env.get(Config.HTTP_PORT, DEFAULT_PORT); + final int reqSize = Env.get(Config.MAX_REQUEST_SIZE, DEFAULT_MAX_REQ_SIZE); + final int bufSize = Env.get(Config.READ_BUFFER_SIZE, DEFAULT_READ_BUF_SIZE); + final long timeout = (long) Env.get(Config.REQUEST_TIMEOUT, DEFAULT_REQ_TIMEOUT); + + return Options.builder().withHost(Constants.INADDR_ANY).withPort(port).withMaxRequestSize(reqSize) + .withRequestTimeout(Duration.ofSeconds(timeout)).withReadBufferSize(bufSize).build(); + } +} diff --git a/src/main/java/info/freelibrary/iiif/webrepl/Imports.java b/src/main/java/info/freelibrary/iiif/webrepl/Imports.java new file mode 100644 index 00000000..9f5baf0c --- /dev/null +++ b/src/main/java/info/freelibrary/iiif/webrepl/Imports.java @@ -0,0 +1,93 @@ + +package info.freelibrary.iiif.webrepl; + +import static info.freelibrary.util.Constants.EOL; +import static info.freelibrary.util.StringUtils.addLineNumbers; +import static info.freelibrary.util.StringUtils.indent; +import static java.util.stream.Collectors.joining; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; + +import info.freelibrary.util.warnings.PMD; +import info.freelibrary.util.warnings.Sonar; + +/** + * A string of imports. + */ +public class Imports { + + /** A label for the display of imports in the logs. */ + private static final String LABEL = "Imports:"; + + /** A string of imports. */ + private final String myImports; + + /** + * Creates a new imports string. + * + * @throws IOException If there is trouble reading from the imports file + */ + public Imports() throws IOException { + File importsFile = Path.of("/etc/jshell/imports.jsh").toFile(); + + // Check to see if we're running from the Maven build + if (!importsFile.exists()) { + importsFile = Path.of("src/main/docker/imports.jsh").toFile(); + } + + try (BufferedReader reader = Files.newBufferedReader(importsFile.toPath())) { + myImports = reader.lines().filter(line -> !line.isBlank()).collect(joining(EOL)); + } + } + + /** + * Gets all the imports. + * + * @return An imports string + */ + @SuppressWarnings({ Sonar.SYSTEM_OUT_ERR, PMD.SYSTEM_PRINTLN }) + public String getAll() { + if (myImports.isEmpty()) { + System.err.println(); + } else { + System.err.println(EOL + LABEL); + System.err.println(indent(addLineNumbers(myImports), 2)); + } + + return myImports; + } + + /** + * Gets all the imports that are referenced in the supplied code snippet. + * + * @param aSnippet A code snippet to check for imports + * @return An imports string + * @throws IOException if there is trouble parsing the imports + */ + @SuppressWarnings({ Sonar.SYSTEM_OUT_ERR, PMD.SYSTEM_PRINTLN }) + public String getReferenced(final String aSnippet) throws IOException { + final String imports; + + try (BufferedReader reader = new BufferedReader(new StringReader(myImports))) { + imports = reader.lines().filter(line -> { + final String className = line.substring(line.lastIndexOf('.') + 1, line.length() - 1); + return aSnippet == null || aSnippet.contains(className); + }).collect(joining(EOL)).trim(); + } + + // The above gets imports for the user, the below shows the imported imports in the logs + if (imports.isEmpty()) { + System.err.println(); + } else { + System.err.println(EOL + LABEL); + System.err.println(indent(addLineNumbers(imports), 2)); + } + + return imports; + } +} diff --git a/src/main/java/info/freelibrary/iiif/webrepl/ParsingError.java b/src/main/java/info/freelibrary/iiif/webrepl/ParsingError.java new file mode 100644 index 00000000..0b20aedf --- /dev/null +++ b/src/main/java/info/freelibrary/iiif/webrepl/ParsingError.java @@ -0,0 +1,34 @@ + +package info.freelibrary.iiif.webrepl; + +import info.freelibrary.util.Constants; +import info.freelibrary.util.StringUtils; + +/** + * A {@code WebRepl} parsing error. + */ +public class ParsingError extends Exception { + + /** The message template used in constructing the exception message. */ + static final String MESSAGE_TEMPLATE = + "Parsing error, but there wasn't a useful diagnostic message. Submitted source code:" + Constants.EOL + + Constants.EOL + "{}"; + + /** The {@code serialVersionUID} for the ParsingError class. */ + private static final long serialVersionUID = 8614571124201029070L; + + /** + * Creates a parsing error for the supplied source. This type of parsing error lacks any diagnostic information from + * JShell. + * + * @param aSource A source code snippet + */ + public ParsingError(final String aSource) { + super(StringUtils.format(MESSAGE_TEMPLATE, aSource)); + } + + @Override + public String toString() { + return getMessage(); + } +} diff --git a/src/main/java/info/freelibrary/iiif/webrepl/Server.java b/src/main/java/info/freelibrary/iiif/webrepl/Server.java index 2355f716..c355cfdf 100644 --- a/src/main/java/info/freelibrary/iiif/webrepl/Server.java +++ b/src/main/java/info/freelibrary/iiif/webrepl/Server.java @@ -4,44 +4,32 @@ import static info.freelibrary.iiif.webrepl.Status.METHOD_NOT_ALLOWED; import static info.freelibrary.iiif.webrepl.Status.NOT_FOUND; import static info.freelibrary.iiif.webrepl.Status.OK; -import static info.freelibrary.util.Constants.EMPTY; -import static info.freelibrary.util.Constants.EOL; -import static info.freelibrary.util.Constants.INADDR_ANY; -import static info.freelibrary.util.Constants.SPACE; -import static java.nio.charset.StandardCharsets.UTF_8; +import static info.freelibrary.util.StringUtils.indent; -import java.io.BufferedReader; import java.io.ByteArrayOutputStream; -import java.io.File; import java.io.IOException; import java.io.PrintStream; import java.net.URISyntaxException; import java.net.URLDecoder; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.CodeSource; -import java.time.Duration; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; -import java.util.stream.Collectors; +import java.util.regex.Pattern; import org.microhttp.EventLoop; import org.microhttp.Handler; import org.microhttp.Header; -import org.microhttp.Options; import org.microhttp.Request; import org.microhttp.Response; -import info.freelibrary.util.Env; +import info.freelibrary.util.Constants; import info.freelibrary.util.StringUtils; import info.freelibrary.util.warnings.PMD; import info.freelibrary.util.warnings.Sonar; -import info.freelibrary.iiif.presentation.v3.Manifest; - import jdk.jshell.JShell; -import jdk.jshell.JShellException; import jdk.jshell.Snippet; import jdk.jshell.Snippet.Status; import jdk.jshell.SnippetEvent; @@ -52,18 +40,6 @@ @SuppressWarnings({ PMD.TOO_MANY_STATIC_IMPORTS, PMD.EXCESSIVE_IMPORTS }) public final class Server { - /** The maximum request size. */ - private static final int DEFAULT_MAX_REQ_SIZE = 1_024 * 1_024; - - /** The default port at which the server listens. */ - private static final int DEFAULT_PORT = 8888; - - /** The read buffer size. */ - private static final int DEFAULT_READ_BUF_SIZE = 1_024 * 64; - - /** The request timeout. */ - private static final long DEFAULT_REQ_TIMEOUT = 60L; - /** The server's event loop. **/ private final EventLoop myEventLoop; @@ -75,7 +51,7 @@ public final class Server { * @throws URISyntaxException If an invalid URI is passed to the server configuration */ public Server() throws IOException, URISyntaxException, ClassNotFoundException { - myEventLoop = new EventLoop(getOptions(), new Server.JPv3Handler()); + myEventLoop = new EventLoop(new EnvOptions().getOpts(), new Server.JPv3Handler()); myEventLoop.start(); } @@ -88,7 +64,7 @@ public Server() throws IOException, URISyntaxException, ClassNotFoundException { * @throws URISyntaxException If an invalid URI is passed to the server configuration */ public Server(final Handler aHandler) throws IOException { - myEventLoop = new EventLoop(getOptions(), aHandler); + myEventLoop = new EventLoop(new EnvOptions().getOpts(), aHandler); myEventLoop.start(); } @@ -97,7 +73,7 @@ public Server(final Handler aHandler) throws IOException { * * @throws InterruptedException If the application's process is interrupted */ - public void run() throws InterruptedException { + public void start() throws InterruptedException { myEventLoop.join(); } @@ -108,36 +84,6 @@ public void stop() { myEventLoop.stop(); } - /** - * Gets the configuration of the event loop. - * - * @return An event loop configuration - */ - Options getOptions() { - final int port = Env.get(Config.HTTP_PORT, DEFAULT_PORT); - final int reqSize = Env.get(Config.MAX_REQUEST_SIZE, DEFAULT_MAX_REQ_SIZE); - final int bufSize = Env.get(Config.READ_BUFFER_SIZE, DEFAULT_READ_BUF_SIZE); - final long timeout = (long) Env.get(Config.REQUEST_TIMEOUT, DEFAULT_REQ_TIMEOUT); - - return Options.builder().withHost(INADDR_ANY).withPort(port).withMaxRequestSize(reqSize) - .withRequestTimeout(Duration.ofSeconds(timeout)).withReadBufferSize(bufSize).build(); - } - - /** - * Runs the server. - * - * @param anArgsArray An array of arguments - * @throws IOException If there is trouble starting the server - * @throws InterruptedException If the process is interrupted before it's completed - * @throws URISyntaxException If the Jar path cannot be converted into a URI - * @throws ClassNotFoundException If the Jar file with the JPv3 classes cannot be found - */ - @SuppressWarnings({ "checkstyle:UncommentedMain" }) - public static void main(final String[] anArgsArray) - throws IOException, InterruptedException, URISyntaxException, ClassNotFoundException { - new Server().run(); - } - /** * An event handler for code evaluation requests. */ @@ -149,6 +95,9 @@ static class JPv3Handler implements Handler { /** A constant for the content type header. */ private static final String CONTENT_TYPE = "Context-Type"; + /** A regex pattern to match submission requests. */ + private static final Pattern EDITOR_PATTERN = Pattern.compile("editor(/)?$"); + /** An empty response body. */ private static final byte[] EMPTY_BODY = {}; @@ -158,12 +107,21 @@ static class JPv3Handler implements Handler { /** A hard-coded snippet that will return the result of the supplied code snippet. */ private static final String MAIN_METHOD = "Jpv3Snippet.main(new String[]{});"; + /** A constant for the status response. */ + private static final String STATUS_LABEL = "Status: "; + + /** A regex pattern to match submission requests. */ + private static final Pattern SUBMIT_PATTERN = Pattern.compile("submit(/)?$"); + /** The response headers that are returned for plain text responses. */ private static final List
TEXT_CONTENT_TYPE = getHeaders(new Header(CONTENT_TYPE, "text/plain")); /** A cached {@code WebResource}. */ private final byte[] myHTML; + /** The imports used by the Java shell environment. */ + private final Imports myImports; + /** The Java shell's output stream. */ private final ByteArrayOutputStream myOutputStream; @@ -177,100 +135,46 @@ static class JPv3Handler implements Handler { * @throws ClassNotFoundException If the JPv3 classes cannot be found * @throws URISyntaxException If the Jar file's path couldn't be converted into a URI */ - @SuppressWarnings({ Sonar.SYSTEM_OUT_ERR }) + @SuppressWarnings({ Sonar.SYSTEM_OUT_ERR, PMD.SYSTEM_PRINTLN }) JPv3Handler() throws IOException, ClassNotFoundException, URISyntaxException { + this(new Imports()); + } + + /** + * Creates a new {@code JPv3Handler} with the supplied imports list. + * + * @param aImportsList A list of imports in string form + * @throws IOException If there is trouble reading the {@code WebResource} + * @throws ClassNotFoundException If the JPv3 classes cannot be found + * @throws URISyntaxException If the Jar file's path couldn't be converted into a URI + */ + @SuppressWarnings({ Sonar.SYSTEM_OUT_ERR, PMD.SYSTEM_PRINTLN }) + JPv3Handler(final Imports aImportsList) throws IOException, ClassNotFoundException, URISyntaxException { myOutputStream = new ByteArrayOutputStream(); + myImports = aImportsList; + + // Create the shell, specifying preview features and a version that we depend on myShell = JShell.builder().compilerOptions("--enable-preview", "--source", "21") .out(new PrintStream(myOutputStream)).build(); // Pre-load and cache the code editor's HTML page myHTML = new WebResource("index.html").getBytes(); - // Check that all the imports can be loaded successfully - myShell.eval(getImports()).stream().filter(event -> !Snippet.Status.VALID.equals(event.status())) + // Check that all the imports can be loaded successfully (i.e., that our classpath is current) + myShell.eval(myImports.getAll()).stream().filter(event -> !Status.VALID.equals(event.status())) .map((Function) SnippetEvent::status).forEach(System.err::println); - // Load the JPv3 classes so the imports have something to load - myShell.addToClasspath(getJarClasspath()); + // Just add a new line to distinguish between the startup import load and what follows + System.err.println(); } @Override - @SuppressWarnings({ PMD.COGNITIVE_COMPLEXITY, PMD.SYSTEM_PRINTLN, Sonar.COGNITIVE_COMPLEXITY, - Sonar.SYSTEM_OUT_ERR }) public void handle(final Request aRequest, final Consumer aCallback) { - final String uri = aRequest.uri(); - final Response response; - - switch (aRequest.method()) { - case "POST" -> { - System.err.println(aRequest.method() + SPACE + uri); - - if (uri.endsWith("submit") || uri.endsWith("submit/")) { - final StringBuilder submission = new StringBuilder(); - - submission.append(decodeSubmission(aRequest.body())); - System.err.println("body: " + submission.toString()); - - try { - myShell.eval(getCode(submission.toString().trim())).forEach(event -> { - switch (event.status()) { - case VALID -> { - // The submitted code is valid, so get its result to write out - final List results = myShell.eval(MAIN_METHOD); - - results.forEach(output -> { - if (Snippet.Status.VALID.equals(output.status())) { - System.out.println(output.value()); - } - }); - } - case REJECTED -> { - final Snippet snippet = event.snippet(); - final StringBuilder buffer = new StringBuilder(snippet.source()); - - // Just check one at a time, and let the editor iterate - myShell.diagnostics(snippet).findFirst().ifPresentOrElse( - new DiagConsumer(buffer), () -> buffer.delete(0, buffer.length()) - .append("Parsing error, but diagnostics were not found")); - - try { - myOutputStream.write(buffer.toString().getBytes(UTF_8)); - } catch (final IOException details) { - System.err.println(details); - } - } - default -> { - final JShellException exception = event.exception(); - - if (exception != null) { - System.err.println(exception); - System.out.println(exception); - } - } - } - }); - } catch (final IOException details) { - System.err.println(details); - System.out.println(details); - } - - response = getResponse(OK, TEXT_CONTENT_TYPE, myOutputStream.toString().getBytes(UTF_8)); - myOutputStream.reset(); // After writing it to the browser, zero out its contents - } else { - response = getResponse(NOT_FOUND, TEXT_CONTENT_TYPE, EMPTY_BODY); - } - } - case "GET" -> { - System.err.println(aRequest.method() + SPACE + uri); - - if (uri.endsWith("editor") || uri.endsWith("editor/")) { - response = getResponse(OK, HTML_CONTENT_TYPE, myHTML); - } else { - response = getResponse(NOT_FOUND, TEXT_CONTENT_TYPE, EMPTY_BODY); - } - } - default -> response = getResponse(METHOD_NOT_ALLOWED, TEXT_CONTENT_TYPE, EMPTY_BODY); - } + final Response response = switch (aRequest.method()) { + case "POST" -> handlePost(aRequest); + case "GET" -> handleGet(aRequest); + default -> getResponse(METHOD_NOT_ALLOWED, TEXT_CONTENT_TYPE, EMPTY_BODY); + }; aCallback.accept(response); } @@ -282,14 +186,14 @@ public void handle(final Request aRequest, final Consumer aCallback) { * @return A decoded code submission */ private String decodeSubmission(final byte[] aSubmission) { - final String data = new String(aSubmission, UTF_8); + final String data = new String(aSubmission, StandardCharsets.UTF_8); if (data.startsWith(CODE_DELIM)) { - return URLDecoder.decode(data.substring(CODE_DELIM.length()), UTF_8); + return URLDecoder.decode(data.substring(CODE_DELIM.length()), StandardCharsets.UTF_8); } // If submission wasn't valid, just return an empty string which will evaluate to nothing - return EMPTY; + return Constants.EMPTY; } /** @@ -299,86 +203,159 @@ private String decodeSubmission(final byte[] aSubmission) { * @return The formatted code * @throws IOException If there is trouble reading the imports for the code block */ + @SuppressWarnings({ Sonar.SYSTEM_OUT_ERR, PMD.SYSTEM_PRINTLN }) private String getCode(final String aCodeBlock) throws IOException { final String code = """ {} - class Jpv3Snippet { - public static void main(String[] args) { + class Jpv3Snippet { + public static void main(String[] args) { {} - - } + } } """; - return StringUtils.format(code, getImports(aCodeBlock), aCodeBlock); + return StringUtils.format(code, myImports.getReferenced(aCodeBlock), indent(aCodeBlock, 4)); } /** - * Gets a list of Java imports. + * Creates a server response from the supplied parameters. * - * @return A list of Java imports - * @throws IOException If there is trouble reading the imports file + * @param aEnum An HTTP response enum + * @param aHeaderList A list of response headers + * @param aBody A response body + * @return The newly constructed response */ - private String getImports() throws IOException { - return getImports(null); + private Response getResponse(final info.freelibrary.iiif.webrepl.Status aEnum, final List
aHeaderList, + final byte[] aBody) { + return new Response(aEnum.getCode(), aEnum.getMessage(), aHeaderList, aBody); } /** - * Gets a list of Java imports. + * Handle a GET request. * - * @param aSnippet A snippet to evaluate - * @return A list of Java imports - * @throws IOException If there is trouble reading the imports file + * @param aRequest A GET request to handle + * @return A response to the GET request */ @SuppressWarnings({ PMD.SYSTEM_PRINTLN, Sonar.SYSTEM_OUT_ERR }) - private String getImports(final String aSnippet) throws IOException { - File importsFile = Path.of("/etc/jshell/imports.jsh").toFile(); - - // Check to see if we're running from the Maven build - if (!importsFile.exists()) { - importsFile = Path.of("src/main/docker/imports.jsh").toFile(); - } + private Response handleGet(final Request aRequest) { + final String uri = aRequest.uri(); - try (BufferedReader reader = Files.newBufferedReader(importsFile.toPath())) { - final String imports = reader.lines().filter(line -> !line.isBlank()).filter(line -> { - final String className = line.substring(line.lastIndexOf('.') + 1, line.length() - 1); - return aSnippet == null || aSnippet.contains(className); - }).collect(Collectors.joining(EOL)) + EOL; + System.err.println(aRequest.method() + Constants.SPACE + uri + Constants.EOL); - System.err.println(imports); - return imports; + if (!EDITOR_PATTERN.matcher(uri).find()) { + return getResponse(NOT_FOUND, TEXT_CONTENT_TYPE, EMPTY_BODY); } + + return getResponse(OK, HTML_CONTENT_TYPE, myHTML); } /** - * Gets the classpath of the Jar file that contains the JPv3 classes. + * Handle a POST request. * - * @return The path to the Jar file in string form - * @throws ClassNotFoundException If the JPv3 classes cannot be found - * @throws URISyntaxException If there is trouble converting the Jar path into a URI + * @param aRequest A POST request to handle + * @return A response to the POST request */ - private String getJarClasspath() throws ClassNotFoundException, URISyntaxException { - final CodeSource codeSource = Manifest.class.getProtectionDomain().getCodeSource(); + @SuppressWarnings({ PMD.COGNITIVE_COMPLEXITY, Sonar.COGNITIVE_COMPLEXITY, PMD.SYSTEM_PRINTLN, + Sonar.SYSTEM_OUT_ERR }) + private Response handlePost(final Request aRequest) { + final String uri = aRequest.uri(); + final byte[] bytes; + final String body; - if (codeSource != null) { - return new File(codeSource.getLocation().toURI()).getAbsolutePath(); + System.err.println(aRequest.method() + Constants.SPACE + uri); + + // If we're not processing a code submission, we don't care + if (!SUBMIT_PATTERN.matcher(uri).find()) { + return getResponse(NOT_FOUND, TEXT_CONTENT_TYPE, EMPTY_BODY); } - throw new ClassNotFoundException(); + body = decodeSubmission(aRequest.body()).trim(); + + System.err.println("Body: " + Constants.EOL + indent(StringUtils.addLineNumbers(body), 2)); + + try { + myShell.eval(getCode(body)).forEach(event -> { + final Status status = event.status(); + + switch (status) { + case VALID -> { + // The submitted code is valid, so get its result to write out + final SnippetEvent output = myShell.eval(MAIN_METHOD).get(0); + + // Before returning the output though, log the VALID status + System.err.println(Constants.EOL + STATUS_LABEL + status); + + if (Status.VALID.equals(output.status())) { + System.out.println(output.value()); + } + } + case REJECTED, RECOVERABLE_DEFINED -> { + final Snippet snippet = event.snippet(); + final StringBuilder buffer = new StringBuilder(snippet.source()); + + // Just check one at a time, and let the editor iterate + myShell.diagnostics(snippet).findFirst().ifPresentOrElse(new DiagnosticConsumer(buffer), + () -> buffer.delete(0, buffer.length()) + .append(new ParsingError(StringUtils.addLineNumbers(body)))); + try { + final String output = buffer.toString().trim(); + + // Write correctly formatted code to the user + myOutputStream.write(output.getBytes(StandardCharsets.UTF_8)); + + // Wrap the user's response in another format + System.err.println("Error: "); + System.err.println(indent(reformat(output), 2)); + System.err.println(Constants.EOL + STATUS_LABEL + status + Constants.EOL); + } catch (final IOException details) { + System.err.println(details); + } finally { + try { + myOutputStream.flush(); + } catch (final IOException details) { + System.err.println(details); + } + } + } + default -> { + System.err.println("Unhandled status: " + status); + + Optional.ofNullable(event.exception()).ifPresentOrElse(exception -> { + System.err.println(exception); + System.out.println(exception); + }, () -> { + final ParsingError error = new ParsingError(body); + + System.err.println(error); + System.out.println(error); + }); + } + } + }); + + // Clear out our snippets for a fresh start with the next request + myShell.snippets().forEach(myShell::drop); + } catch (final IOException details) { + System.err.println(details); + System.out.println(details); + } + + bytes = myOutputStream.toString().trim().getBytes(StandardCharsets.UTF_8); + myOutputStream.reset(); + + return getResponse(OK, TEXT_CONTENT_TYPE, bytes); } /** - * Creates a server response from the supplied parameters. + * Strips line numbers from a user error message's code block so that they can be re-added across the whole + * message. * - * @param aEnum An HTTP response enum - * @param aHeaderList A list of response headers - * @param aBody A response body - * @return The newly constructed response + * @param aMessage An error message that's being returned to the user + * @return An error message that has reformatted what was returned to the user */ - private Response getResponse(final info.freelibrary.iiif.webrepl.Status aEnum, final List
aHeaderList, - final byte[] aBody) { - return new Response(aEnum.getCode(), aEnum.getMessage(), aHeaderList, aBody); + private String reformat(final String aMessage) { + return StringUtils.addLineNumbers(aMessage.replaceAll("(?m)^\\d+\\s+(?=[A-Za-z])", Constants.EMPTY)); } /** diff --git a/src/main/java/info/freelibrary/iiif/webrepl/WebRepl.java b/src/main/java/info/freelibrary/iiif/webrepl/WebRepl.java new file mode 100644 index 00000000..73e61659 --- /dev/null +++ b/src/main/java/info/freelibrary/iiif/webrepl/WebRepl.java @@ -0,0 +1,46 @@ + +package info.freelibrary.iiif.webrepl; + +import java.io.IOException; +import java.net.URISyntaxException; + +import info.freelibrary.util.warnings.Checkstyle; + +/** + * An application that serves an interpreter for testing JPV3 code. + */ +public final class WebRepl { + + /** The WebRepl's server. */ + private static Server myServer; + + /** + * A private constructor for the WebRepl application. + */ + private WebRepl() { + // This is intentionally left empty. + } + + /** + * Runs the WebRepl application. + * + * @param anArgsArray An array of initial arguments + * @throws IOException If the server has trouble starting + * @throws InterruptedException If the server is interrupted + * @throws URISyntaxException If the server is using an invalid URI + * @throws ClassNotFoundException If a class used by the server cannot be found + */ + @SuppressWarnings({ Checkstyle.UNCOMMENTED_MAIN, "checkstyle:UncommentedMain" }) + public static void main(final String[] anArgsArray) + throws IOException, InterruptedException, URISyntaxException, ClassNotFoundException { + myServer = new Server(); + myServer.start(); + } + + /** + * Stops the WebRepl server. + */ + static void stop() { + myServer.stop(); + } +} diff --git a/src/test/java/info/freelibrary/iiif/webrepl/DiagConsumerTest.java b/src/test/java/info/freelibrary/iiif/webrepl/DiagConsumerTest.java index 9704861c..581599f7 100644 --- a/src/test/java/info/freelibrary/iiif/webrepl/DiagConsumerTest.java +++ b/src/test/java/info/freelibrary/iiif/webrepl/DiagConsumerTest.java @@ -13,7 +13,7 @@ import jdk.jshell.Diag; /** - * Tests of {@link DiagConsumer}. + * Tests of {@link DiagnosticConsumer}. */ class DiagConsumerTest { @@ -35,8 +35,8 @@ class DiagConsumerTest { /** The test buffer. */ private StringBuilder myBuffer; - /** The {@link DiagConsumer} being tested. */ - private DiagConsumer myDiagConsumer; + /** The {@link DiagnosticConsumer} being tested. */ + private DiagnosticConsumer myDiagConsumer; /** * Sets up the testing environment. @@ -44,7 +44,7 @@ class DiagConsumerTest { @BeforeEach void setUp() { myBuffer = new StringBuilder(); - myDiagConsumer = new DiagConsumer(myBuffer); + myDiagConsumer = new DiagnosticConsumer(myBuffer); } /** diff --git a/src/test/java/info/freelibrary/iiif/webrepl/EnvOptionsTest.java b/src/test/java/info/freelibrary/iiif/webrepl/EnvOptionsTest.java new file mode 100644 index 00000000..3b8bee28 --- /dev/null +++ b/src/test/java/info/freelibrary/iiif/webrepl/EnvOptionsTest.java @@ -0,0 +1,50 @@ + +package info.freelibrary.iiif.webrepl; + +import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.net.ServerSocket; + +import org.junit.jupiter.api.Test; +import org.microhttp.Options; + +/** + * Tests of the {@code EnvOptions} class. + */ +class EnvOptionsTest { + + /** + * Tests getting the environmental options. + */ + @Test + final void testGetOpts() throws Exception { + final int port = getPort(); + + withEnvironmentVariable(Config.HTTP_PORT, Integer.toString(port))// + .and(Config.MAX_REQUEST_SIZE, Integer.toString(1_024))// + .and(Config.READ_BUFFER_SIZE, Integer.toString(1_024))// + .and(Config.REQUEST_TIMEOUT, Long.toString(30L))// + .execute(() -> { + final Options opts = new EnvOptions().getOpts(); + + assertEquals(port, opts.port()); + assertEquals(1_024, opts.maxRequestSize()); + assertEquals(1_024, opts.readBufferSize()); + assertEquals(30L, opts.requestTimeout().getSeconds()); + }); + } + + /** + * Gets an open port. + * + * @return An open port + * @throws IOException If there is trouble finding an open port + */ + private static int getPort() throws IOException { + try (ServerSocket serverSocket = new ServerSocket(0)) { + return serverSocket.getLocalPort(); + } + } +} diff --git a/src/test/java/info/freelibrary/iiif/webrepl/ImportsTest.java b/src/test/java/info/freelibrary/iiif/webrepl/ImportsTest.java new file mode 100644 index 00000000..890e0b98 --- /dev/null +++ b/src/test/java/info/freelibrary/iiif/webrepl/ImportsTest.java @@ -0,0 +1,79 @@ + +package info.freelibrary.iiif.webrepl; + +import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemErr; +import static info.freelibrary.util.Constants.EOL; +import static info.freelibrary.util.StringUtils.addLineNumbers; +import static info.freelibrary.util.StringUtils.indent; +import static java.util.stream.Collectors.joining; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.StringReader; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import info.freelibrary.util.StringUtils; + +/** + * Tests the {@code Imports} class. + */ +class ImportsTest { + + /** The imports being tested. */ + private static String myImports; + + /** + * Tests getting all the imports. + */ + @Test + final void testGetAll() throws Exception { + final String expected = "Imports:" + EOL + indent(addLineNumbers(myImports), 2); + final String found = tapSystemErr(() -> { + assertEquals(myImports, new Imports().getAll()); + }); + + assertEquals(expected.trim(), found.trim()); + } + + /** + * Tests getting only the imports that are needed by the supplied code snippet. + * + * @throws Exception If there is trouble reading the imports + */ + @Test + final void testGetReferenced() throws Exception { + final String imports = "import info.freelibrary.iiif.presentation.v3.Canvas;"; + final String snippet = "Canvas"; + + final String expected = """ + Imports: + 1 import info.freelibrary.iiif.presentation.v3.Canvas; + """.trim(); + + final String found = tapSystemErr(() -> { + assertEquals(imports, new Imports().getReferenced(snippet)); + }).trim(); + + assertEquals(expected, found); + } + + /** + * Sets up the testing environment. + * + * @throws IOException If there is trouble reading the imports from file + */ + @BeforeAll + static final void setUp() throws IOException { + final String imports = StringUtils.read(new File("src/main/docker/imports.jsh")); + + // Skip blank lines so we can make the imports.jsh file more human readable + try (BufferedReader reader = new BufferedReader(new StringReader(imports))) { + myImports = reader.lines().filter(line -> !line.isBlank()).collect(joining(EOL)); + } + } + +} diff --git a/src/test/java/info/freelibrary/iiif/webrepl/ParsingErrorTest.java b/src/test/java/info/freelibrary/iiif/webrepl/ParsingErrorTest.java new file mode 100644 index 00000000..3becbc7b --- /dev/null +++ b/src/test/java/info/freelibrary/iiif/webrepl/ParsingErrorTest.java @@ -0,0 +1,26 @@ + +package info.freelibrary.iiif.webrepl; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import info.freelibrary.util.StringUtils; + +/** + * Tests of the {@code ParsingError} class. + */ +class ParsingErrorTest { + + /** The code snippet used in the test. */ + private static final String TEST_CODE = "Hello!"; + + /** + * Tests the {@code ParsingError} constructor. + */ + @Test + final void testParsingErrorConstruction() { + assertEquals(StringUtils.format(ParsingError.MESSAGE_TEMPLATE, TEST_CODE), + new ParsingError(TEST_CODE).toString()); + } +} diff --git a/src/test/java/info/freelibrary/iiif/webrepl/ServerTest.java b/src/test/java/info/freelibrary/iiif/webrepl/ServerTest.java index c9a8f808..2c77dc01 100644 --- a/src/test/java/info/freelibrary/iiif/webrepl/ServerTest.java +++ b/src/test/java/info/freelibrary/iiif/webrepl/ServerTest.java @@ -1,88 +1,158 @@ package info.freelibrary.iiif.webrepl; -import static info.freelibrary.util.Constants.INADDR_ANY; +import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemErr; +import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemErrAndOut; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.io.IOException; -import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import org.junit.jupiter.api.Test; -import org.microhttp.Options; import org.microhttp.Request; import org.microhttp.Response; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +import info.freelibrary.util.Constants; +import info.freelibrary.util.HTTP; import info.freelibrary.util.warnings.JDK; -import info.freelibrary.iiif.webrepl.Server.JPv3Handler; /** * Tests of the {@link Server} class. */ class ServerTest { - /** The GET method constant. */ - private static final String GET = "GET"; + /** The HTTP version constant. */ + private static final String HTTP_VERSION = "HTTP/1.1"; - /** The POST method constant. */ - private static final String POST = "POST"; + /** The source code submission endpoint. */ + private static final String SUBMIT = "/submit"; /** The test code passed to the consumer. */ - private static final String TEST_CODE = "System.out.println(\"Hello, World!\");"; + private static final byte[] TEST_CODE = + "code=System.out.println(\"Hello, World!\");".getBytes(StandardCharsets.UTF_8); /** - * Tests the server's POST response handler. + * Tests that {@code System.err} reports when a bad import is loaded in server initialization. + * + * @throws Exception If there is trouble reading the imports + */ + @Test + void testBadImportForServer() throws Exception { + final Imports mockImports = Mockito.mock(Imports.class); + final String output; + + when(mockImports.getAll()).thenReturn("import something.that.does.not.Exist;"); + + output = tapSystemErr(() -> { + new Server.JPv3Handler(mockImports); + }); + + assertEquals("REJECTED", output.trim()); + } + + /** + * Tests submitting code to an invalid endpoint. + * + * @throws Exception If an exception occurs + */ + @Test + @SuppressWarnings(JDK.UNCHECKED) + void testHandlePostSubmitInvalidEndpoint() throws Exception { + final Consumer mockConsumer = Mockito.mock(Consumer.class); + final Request mockRequest = new Request(HTTP.Method.POST, "/nothing", HTTP_VERSION, List.of(), TEST_CODE); + final ArgumentCaptor responseCaptor; + final Response response; + + tapSystemErr(() -> { + new Server.JPv3Handler().handle(mockRequest, mockConsumer); + }); + + responseCaptor = ArgumentCaptor.forClass(Response.class); + verify(mockConsumer).accept(responseCaptor.capture()); + response = responseCaptor.getValue(); + + assertEquals(404, response.status()); + } + + /** + * Tests submitting code to an invalid endpoint. + * + * @throws Exception If an exception occurs */ @Test @SuppressWarnings(JDK.UNCHECKED) - void testHandlePostSubmitValidCode() throws URISyntaxException, ClassNotFoundException, IOException { - final JPv3Handler handler = new Server.JPv3Handler(); + void testHandlePostSubmitInvalidParam() throws Exception { + final byte[] testCode = "bad=System.out.println(\"Hello, World!\");".getBytes(StandardCharsets.UTF_8); final Consumer mockConsumer = Mockito.mock(Consumer.class); - final byte[] code = ("code=" + TEST_CODE).getBytes(); - final Request mockRequest = new Request(POST, "/submit", "HTTP/1.1", List.of(), code); + final Request mockRequest = new Request(HTTP.Method.POST, SUBMIT, HTTP_VERSION, List.of(), testCode); final ArgumentCaptor responseCaptor; final Response response; - handler.handle(mockRequest, mockConsumer); + tapSystemErrAndOut(() -> { + new Server.JPv3Handler().handle(mockRequest, mockConsumer); + }); responseCaptor = ArgumentCaptor.forClass(Response.class); verify(mockConsumer).accept(responseCaptor.capture()); response = responseCaptor.getValue(); assertEquals(201, response.status()); + assertEquals(Constants.EMPTY, new String(response.body(), StandardCharsets.UTF_8)); + } + + /** + * Tests the server's POST response handler. + * + * @throws Exception If an exception occurs + */ + @Test + @SuppressWarnings(JDK.UNCHECKED) + void testHandlePostSubmitValidCode() throws Exception { + final Consumer mockConsumer = Mockito.mock(Consumer.class); + final Request mockRequest = new Request(HTTP.Method.POST, SUBMIT, HTTP_VERSION, List.of(), TEST_CODE); + final ArgumentCaptor responseCaptor; + final Response response; + + tapSystemErrAndOut(() -> { + new Server.JPv3Handler().handle(mockRequest, mockConsumer); + }); + + responseCaptor = ArgumentCaptor.forClass(Response.class); + verify(mockConsumer).accept(responseCaptor.capture()); + response = responseCaptor.getValue(); + + assertEquals(HTTP.CREATED, response.status()); assertEquals("OK", response.reason()); assertEquals("text/plain", response.headers().get(0).value()); - assertEquals("Hello, World!\n", new String(response.body())); + assertEquals("Hello, World!", new String(response.body())); } /** * Tests the server's GET response handler. + * + * @throws Exception If an exception occurs */ @Test @SuppressWarnings(JDK.UNCHECKED) - final void testHandlerBadMethod() throws URISyntaxException, ClassNotFoundException, IOException { + final void testHandlerBadMethod() throws Exception { final Request mockRequest = Mockito.mock(Request.class); final Consumer mockConsumer = Mockito.mock(Consumer.class); final ArgumentCaptor responseCaptor; final Response response; when(mockRequest.uri()).thenReturn("http://0.0.0.0/yada"); - when(mockRequest.method()).thenReturn("DELETE"); + when(mockRequest.method()).thenReturn(HTTP.Method.DELETE); when(mockRequest.body()).thenReturn(new byte[] {}); - new Server.JPv3Handler().handle(mockRequest, mockConsumer); + tapSystemErr(() -> { + new Server.JPv3Handler().handle(mockRequest, mockConsumer); + }); // Capture the Response passed to the Consumer responseCaptor = ArgumentCaptor.forClass(Response.class); @@ -94,20 +164,24 @@ final void testHandlerBadMethod() throws URISyntaxException, ClassNotFoundExcept /** * Tests the server's response handler. + * + * @throws Exception If an exception occurs */ @Test @SuppressWarnings(JDK.UNCHECKED) - final void testHandlerGet() throws URISyntaxException, ClassNotFoundException, IOException { + final void testHandlerGet() throws Exception { final Request mockRequest = Mockito.mock(Request.class); final Consumer mockConsumer = Mockito.mock(Consumer.class); final ArgumentCaptor responseCaptor; final Response response; when(mockRequest.uri()).thenReturn("http://0.0.0.0/"); - when(mockRequest.method()).thenReturn(GET); + when(mockRequest.method()).thenReturn(HTTP.Method.GET); when(mockRequest.body()).thenReturn(new byte[] {}); - new Server.JPv3Handler().handle(mockRequest, mockConsumer); + tapSystemErr(() -> { + new Server.JPv3Handler().handle(mockRequest, mockConsumer); + }); // Capture the Response passed to the Consumer responseCaptor = ArgumentCaptor.forClass(Response.class); @@ -119,20 +193,24 @@ final void testHandlerGet() throws URISyntaxException, ClassNotFoundException, I /** * Tests the server's GET response handler. + * + * @throws Exception If an exception occurs */ @Test @SuppressWarnings(JDK.UNCHECKED) - final void testHandlerGetEditor() throws URISyntaxException, ClassNotFoundException, IOException { + final void testHandlerGetEditor() throws Exception { final Request mockRequest = Mockito.mock(Request.class); final Consumer mockConsumer = Mockito.mock(Consumer.class); final ArgumentCaptor responseCaptor; final Response response; when(mockRequest.uri()).thenReturn("http://0.0.0.0/editor"); - when(mockRequest.method()).thenReturn(GET); + when(mockRequest.method()).thenReturn(HTTP.Method.GET); when(mockRequest.body()).thenReturn(new byte[] {}); - new Server.JPv3Handler().handle(mockRequest, mockConsumer); + tapSystemErr(() -> { + new Server.JPv3Handler().handle(mockRequest, mockConsumer); + }); // Capture the Response passed to the Consumer responseCaptor = ArgumentCaptor.forClass(Response.class); @@ -143,63 +221,51 @@ final void testHandlerGetEditor() throws URISyntaxException, ClassNotFoundExcept } /** - * Tests the startup of the server. + * Tests passing a handler to the Server's constructor. * - * @throws InterruptedException If the server cannot be started + * @throws Exception If there is trouble capturing {@code System.err}. */ @Test - final void testServer() throws InterruptedException, ClassNotFoundException, URISyntaxException, IOException { - final ExecutorService executor = Executors.newSingleThreadExecutor(); - final Server server = new Server(); - - final Runnable task = () -> { - try { - server.run(); - } catch (final InterruptedException details) { - Thread.currentThread().interrupt(); - fail(details.getMessage(), details); - } - }; - - try { - final Future future = executor.submit(task); - - future.get(2, TimeUnit.SECONDS); - } catch (TimeoutException | ExecutionException | InterruptedException details) { - server.stop(); - - if (!(details instanceof TimeoutException)) { - fail(details.getMessage()); - } - - } finally { - executor.shutdown(); - - try { - if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { - server.stop(); - } - } catch (final InterruptedException details) { - Thread.currentThread().interrupt(); - } - } + final void testHandlerPassedToServer() throws Exception { + tapSystemErr(() -> { + assertDoesNotThrow(() -> { + new Server(new Server.JPv3Handler()).stop(); + }); + }); } /** - * Tests the server constructor that takes a event loop handler. + * Tests that {@code System.err} reports when a bad import is loaded in handler. * - * @throws ClassNotFoundException If the handler cannot be found - * @throws URISyntaxException If the handler uses an invalid URI - * @throws IOException If there is trouble reading or writing from the server + * @throws Exception If there is trouble reading the imports */ @Test - final void testServerWithHandler() throws ClassNotFoundException, URISyntaxException, IOException { - final Server server = new Server(new Server.JPv3Handler()); - final Options opts = server.getOptions(); + @SuppressWarnings(JDK.UNCHECKED) + void testRejectedCode() throws Exception { + final byte[] testCode = "code=System.out.printn(\"Hello, World!\");".getBytes(StandardCharsets.UTF_8); + final Consumer mockConsumer = Mockito.mock(Consumer.class); + final Request mockRequest = new Request(HTTP.Method.POST, SUBMIT, HTTP_VERSION, List.of(), testCode); + final ArgumentCaptor responseCaptor; + final Response response; + final String expected = """ + Could not parse: - assertEquals(INADDR_ANY, opts.host()); - assertEquals(Integer.parseInt(System.getenv(Config.HTTP_PORT)), opts.port()); + 1 [[System.out.printn]]("Hello, World!"); - server.stop(); + Reason: cannot find symbol + symbol: method printn(java.lang.String) + location: variable out of type java.io.PrintStream + """.trim(); + + tapSystemErrAndOut(() -> { + new Server.JPv3Handler().handle(mockRequest, mockConsumer); + }); + + responseCaptor = ArgumentCaptor.forClass(Response.class); + verify(mockConsumer).accept(responseCaptor.capture()); + response = responseCaptor.getValue(); + + assertEquals(201, response.status()); + assertEquals(expected, new String(response.body(), StandardCharsets.UTF_8)); } } diff --git a/src/test/java/info/freelibrary/iiif/webrepl/WebReplTest.java b/src/test/java/info/freelibrary/iiif/webrepl/WebReplTest.java new file mode 100644 index 00000000..ea385a1a --- /dev/null +++ b/src/test/java/info/freelibrary/iiif/webrepl/WebReplTest.java @@ -0,0 +1,91 @@ + +package info.freelibrary.iiif.webrepl; + +import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemErrAndOutNormalized; +import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URI; +import java.net.URL; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import info.freelibrary.util.HTTP; +import info.freelibrary.util.StringUtils; + +/** + * Tests the WebRepl application. + */ +class WebReplTest { + + /** A constant for two seconds. */ + private static final int TWO_SECONDS = 2000; + + /** + * Tests the {@code WebRepl}'s main method. + * + * @throws Exception If starting or stopping the server fails + */ + @Test + final void testMain() throws Exception { + final int port = getOpenPort(); + + // Configure server with a unique port number + withEnvironmentVariable(Config.HTTP_PORT, Integer.toString(port)).execute(() -> { + final Thread mainThread = new Thread(() -> { + try { + // Prevent StdOut and StdErr from showing during testing + tapSystemErrAndOutNormalized(() -> { + WebRepl.main(new String[] {}); + }); + } catch (final Exception details) { + throw new RuntimeException("Error starting WebRepl", details); + } + }); + + // Start the server + mainThread.start(); + + // Check the server's endpoints until we can confirm it's started + await().atMost(12, TimeUnit.SECONDS).until(() -> { + try { + final URL url = URI.create(StringUtils.format("http://0.0.0.0:{}/editor/", port)).toURL(); + final HttpURLConnection http = (HttpURLConnection) url.openConnection(); + + http.setRequestMethod(HTTP.Method.GET); + http.setReadTimeout(TWO_SECONDS); + http.setConnectTimeout(TWO_SECONDS); + + if (http.getResponseCode() == HTTP.CREATED) { + return true; + } + } catch (final Exception details) { + // We're intentionally ignoring this + } + + return false; + }); + + assertDoesNotThrow((Executable) WebRepl::stop); + mainThread.join(); + }); + } + + /** + * Gets a port that's open on the local system. + * + * @return An open port + * @throws IOException If there is trouble finding an open port + */ + private static int getOpenPort() throws IOException { + try (ServerSocket serverSocket = new ServerSocket(0)) { + return serverSocket.getLocalPort(); + } + } +}