diff --git a/Dockerfile b/Dockerfile index f6ce4fe..71a1234 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_IMAGE=gradle:7.4-jdk17-alpine -ARG RUN_IMAGE=tomcat:9.0.39-jdk8-adoptopenjdk-hotspot +ARG RUN_IMAGE=tomcat:9.0.68-jdk11-temurin ################## Stage 0 FROM ${BUILD_IMAGE} as builder diff --git a/README.md b/README.md index 24bcd2e..e1388b2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A gateway server and accompanying JavaScript client API to monitor EPICS Channel - [API](https://github.com/JeffersonLab/epics2web#api) - [Configure](https://github.com/JeffersonLab/epics2web#configure) - [Build](https://github.com/JeffersonLab/epics2web#build) +- [Test](https://github.com/JeffersonLab/epics2web#test) - [Release](https://github.com/JeffersonLab/epics2web#release) - [See Also](https://github.com/JeffersonLab/epics2web#see-also) --- @@ -53,7 +54,7 @@ PV name: `HELLO` This application uses the [Java Channel Access](https://github.com/epics-base/jca) library. It requires a working EPICS channel access environment with the environment variable *EPICS_CA_ADDR_LIST* set. See Also: [Advanced Configuration](https://github.com/JeffersonLab/epics2web/wiki/Advanced-Configuration). ## Build -This project is built with [Java 17](https://adoptium.net/) (compiled to Java 8 bytecode), and uses the [Gradle 7](https://gradle.org/) build tool to automatically download dependencies and build the project from source: +This project is built with [Java 17](https://adoptium.net/) (compiled to Java 11 bytecode), and uses the [Gradle 7](https://gradle.org/) build tool to automatically download dependencies and build the project from source: ``` git clone https://github.com/JeffersonLab/epics2web @@ -66,6 +67,14 @@ gradlew build **See**: [Docker Development Quick Reference](https://gist.github.com/slominskir/a7da801e8259f5974c978f9c3091d52c#development-quick-reference) +## Test +``` +docker compose -f build.yml up +``` +Wait for containers to start then: +``` +gradlew integrationTest +``` ## Release 1. Bump the version number and release date in build.gradle and commit and push to GitHub (using [Semantic Versioning](https://semver.org/)). 2. Create a new release on the GitHub [Releases](https://github.com/JeffersonLab/epics2web/releases) page corresponding to same version in build.gradle (Enumerate changes and link issues). Attach war file for users to download. diff --git a/build.gradle b/build.gradle index 88b6f55..899502d 100644 --- a/build.gradle +++ b/build.gradle @@ -5,23 +5,30 @@ plugins { description = "EPICS to web gateway" group 'org.jlab' -version '1.13.0' +version '1.14.0' ext { - releaseDate = 'March 21 2022' + releaseDate = 'Nov 9 2022' productionRelease = 'true' } -repositories { - mavenCentral() +compileJava { + options.release = 8 +} + +compileTestJava { + options.release = 11 } tasks.withType(JavaCompile) { - options.release = 8 options.encoding = 'UTF-8' options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" } +repositories { + mavenCentral() +} + sourceSets { integration { java.srcDir "${projectDir}/src/integration/java" @@ -32,22 +39,17 @@ sourceSets { } configurations { - testImplementation.extendsFrom compileOnly - integrationImplementation.extendsFrom implementation + integrationImplementation.extendsFrom testImplementation integrationRuntimeOnly.extendsFrom runtimeOnly } dependencies { implementation 'javax.servlet:jstl:1.2', 'org.glassfish:javax.json:1.1.4', - files('lib/jca-2.4.6.jar') - //implementation 'org.epics:jca:2.4.6' // Only works with Java 11 runtime - providedCompile 'javax:javaee-api:7.0' - - testImplementation 'junit:junit:4.13' + 'org.epics:jca:2.4.7' + providedCompile 'javax:javaee-api:8.0.1' - integrationImplementation 'org.testcontainers:testcontainers:1.16.3', - 'org.slf4j:slf4j-log4j12:1.7.28' + testImplementation 'junit:junit:4.13.2' } task integrationTest(type: Test) { @@ -56,17 +58,10 @@ task integrationTest(type: Test) { testClassesDirs = sourceSets.integration.output.classesDirs classpath = sourceSets.integration.runtimeClasspath - //shouldRunAfter test - - testLogging { - showStandardStreams = true - } -} - -test { - classpath = project.sourceSets.test.runtimeClasspath + files("${projectDir}/examples/softioc-db") testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" showStandardStreams = true } } diff --git a/src/integration/java/org/jlab/epics2web/GetTest.java b/src/integration/java/org/jlab/epics2web/GetTest.java new file mode 100644 index 0000000..2ea80f1 --- /dev/null +++ b/src/integration/java/org/jlab/epics2web/GetTest.java @@ -0,0 +1,43 @@ +package org.jlab.epics2web; + +import org.junit.Test; + +import javax.json.Json; +import javax.json.JsonArray; +import javax.json.JsonObject; +import javax.json.JsonReader; +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.Assert.assertEquals; + +public class GetTest { + @Test + public void doTest() throws IOException, InterruptedException { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder().uri(URI.create("http://localhost:8080/epics2web/caget?pv=channel1")).build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + System.out.println(response.body()); + + assertEquals(200, response.statusCode()); + + try(JsonReader reader = Json.createReader(new StringReader(response.body()))) { + JsonObject json = reader.readObject(); + + JsonArray data = json.getJsonArray("data"); + + JsonObject first = data.getJsonObject(0); + + String name = first.getString("name"); + double value = first.getJsonNumber("value").doubleValue(); + + assertEquals("channel1", name); + assertEquals(0.0, value, 0.1); + } + } +} diff --git a/src/integration/java/org/jlab/epics2web/IntegrationErrorTest.java b/src/integration/java/org/jlab/epics2web/IntegrationErrorTest.java deleted file mode 100644 index cb0c5e7..0000000 --- a/src/integration/java/org/jlab/epics2web/IntegrationErrorTest.java +++ /dev/null @@ -1,376 +0,0 @@ -package org.jlab.epics2web; - -import com.cosylab.epics.caj.CAJChannel; -import com.cosylab.epics.caj.CAJContext; -import gov.aps.jca.CAException; -import gov.aps.jca.TimeoutException; -import gov.aps.jca.configuration.DefaultConfiguration; -import gov.aps.jca.dbr.DBR; -import gov.aps.jca.dbr.DBRType; -import gov.aps.jca.dbr.DBR_Double; -import gov.aps.jca.event.*; -import org.jlab.epics2web.epics.ChannelManager; -import org.jlab.epics2web.epics.ContextFactory; -import org.jlab.epics2web.epics.PvListener; -import org.junit.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.testcontainers.containers.*; -import org.testcontainers.containers.output.Slf4jLogConsumer; -import org.testcontainers.containers.wait.strategy.Wait; - -import java.io.IOException; -import java.util.List; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; - -public class IntegrationErrorTest { - private static ChannelManager channelManager; - private static CAJContext context; - private static ScheduledExecutorService timeoutExecutor; - private static ExecutorService callbackExecutor; - - private static Logger LOGGER = LoggerFactory.getLogger(IntegrationErrorTest.class); - - @ClassRule - public static Network network = Network.newNetwork(); - - - public static GenericContainer softioc = new FixedHostPortGenericContainer<>("slominskir/softioc:1.1.0") - .withFixedExposedPort(5064, 5064, InternetProtocol.TCP) - .withFixedExposedPort(5065, 5065, InternetProtocol.TCP) - .withFixedExposedPort(5064, 5064, InternetProtocol.UDP) - .withFixedExposedPort(5065, 5065, InternetProtocol.UDP) - .withNetwork(network) - .withPrivilegedMode(true) - .withCreateContainerCmdModifier(cmd -> cmd - .withHostName("softioc") - .withName("softioc") - .withUser("root") - .withAttachStdin(true) - .withStdinOpen(true) - .withTty(true)) - .withLogConsumer(new Slf4jLogConsumer(LOGGER).withPrefix("softioc")) - .waitingFor(Wait.forLogMessage("iocRun: All initialization complete", 1)) - .withFileSystemBind("examples/integration/softioc", "/db", BindMode.READ_ONLY); - - @BeforeClass - public static void setUp() throws CAException { - softioc.start(); - - String hostname = softioc.getHost(); - //Integer port = softioc.getFirstMappedPort(); - - // Set EPICS_CA_ADDR_LIST - DefaultConfiguration config = ContextFactory.getDefault(); - config.setAttribute("addr_list", hostname); - config.setAttribute("beacon_period", "0.5"); - config.setAttribute("connection_timeout", "0.5"); - - ContextFactory factory = new ContextFactory(config); - - context = factory.newContext(); - - context.printInfo(System.err); - - timeoutExecutor = Executors.newScheduledThreadPool(1, new CustomPrefixThreadFactory("CA-Timeout-")); - callbackExecutor = Executors.newCachedThreadPool(new CustomPrefixThreadFactory("Callback-")); - - channelManager = new ChannelManager(context, timeoutExecutor, callbackExecutor); - } - - @AfterClass - public static void tearDown() { - if(context != null) { - try { - context.destroy(); - } catch(Exception e) { - LOGGER.warn("Unable to cleanup CA Context", e); - } - } - - softioc.stop(); - - if(timeoutExecutor != null) { - timeoutExecutor.shutdownNow(); - } - - if(callbackExecutor != null) { - callbackExecutor.shutdownNow(); - } - } - - /** - * This test simply creates a monitor, triggers a change via caput, then confirms that the monitor callback received - * the initialization value (0) plus the caput value (1). - * - * @throws InterruptedException If a thread is interrupted - * @throws IOException If unable to perform IO - * @throws CAException If unable to communicate over CA - */ - @Test - public void testBasicMonitor() throws InterruptedException, IOException, CAException { - final CountDownLatch latch = new CountDownLatch(2); - - PvListener listener = new LatchPvListener(latch); - - channelManager.addPv(listener, "channel1"); - - Container.ExecResult result = softioc.execInContainer("caput", "channel1", "1"); - System.out.println("err: " + result.getStderr()); - System.out.println("out: " + result.getStdout()); - System.out.println("exit: " + result.getExitCode()); - - latch.await(5, TimeUnit.SECONDS); - - channelManager.removeListener(listener); - - Assert.assertEquals(0, latch.getCount()); - } - - /** - * This test creates a monitor on a channel hosted on the "softioc", then cleanly shuts the softioc down, waits a - * few seconds, and starts it back up (restart IOC simulation), then checks if connection callback detected - * disconnect followed by re-connect plus Context Exception callback should detect CA Disconnect code 24. - * - * @throws IOException If unable to perform IO - * @throws InterruptedException If a thread is interrupted - * @throws CAException If unable to communicate over CA - */ - @Test - public void testCleanRestartCode24() throws IOException, InterruptedException, CAException { - final CountDownLatch latch = new CountDownLatch(2); - final AtomicInteger code = new AtomicInteger(); - - PvListener listener = new PvListener() { - @Override - public void notifyPvInfo(String pv, boolean couldConnect, DBRType type, Integer count, String[] enumLabels) { - System.err.println("ConnectionListener: connected: " + couldConnect); - if(couldConnect) { - latch.countDown(); - } - } - - @Override - public void notifyPvUpdate(String pv, DBR dbr) { - // Do nothing - we don't care for this test - } - }; - - // Log any exceptions and check for code 24 - context.addContextExceptionListener(new ContextExceptionListener() { - @Override - public void contextException(ContextExceptionEvent ev) { - System.err.println("ContextException: " + ev); - } - - @Override - public void contextVirtualCircuitException(ContextVirtualCircuitExceptionEvent ev) { - System.err.println("ContextVirtualCircuitException: Status: " + ev.getStatus() + ", IP: " + ev.getVirtualCircuit() + ", Source: "); - ((CAJContext)ev.getSource()).printInfo(System.err); - - System.err.println("status: " + ev.getStatus()); - System.err.println("value: " + ev.getStatus().getValue()); // We want value, not code; this is very confusing - System.err.println("code: " + ev.getStatus().getStatusCode()); // 192? What the heck? - System.err.println("severity: " + ev.getStatus().getSeverity()); - - code.set(ev.getStatus().getValue()); - } - }); - - context.addContextMessageListener(new ContextMessageListener() { - @Override - public void contextMessage(ContextMessageEvent ev) { - System.err.println("ContextMessage: " + ev); - } - }); - - channelManager.addPv(listener, "channel2"); - - Thread.sleep(1000); - - softioc.stop(); // Clean stop should result in status 24 plus connection callback informs us when no longer connected! - - Thread.sleep(1000); - - softioc.start(); - - latch.await(5, TimeUnit.SECONDS); // If this is too short and earlier sleep calls too long then final ConnectionListener update is not received! - - channelManager.removeListener(listener); - - Assert.assertEquals(0, latch.getCount()); - Assert.assertEquals(24, code.get()); - } - - /** - * This test creates a monitor on a channel hosted on the "softioc", then severs the connection abruptly by - * disabling the network interface on the softioc, waits for unresponsive timeout periods to elapse, then - * restores the network interface; an error code 60 "unresponsive", should be detected. - * - * @throws IOException If unable to perform IO - * @throws InterruptedException If a thread is interrupted - * @throws CAException If unable to communicate over CA - */ - //@Test // Oddly doesn't result in code 60 on GitHub Action - public void testUnresponsiveCode60() throws IOException, InterruptedException, CAException { - final CountDownLatch latch = new CountDownLatch(2); - final AtomicInteger code = new AtomicInteger(); - - PvListener listener = new LatchPvListener(latch); - - // TODO: Looks like lots of ways connections fail since so many different ports are being used to communicate over both UDP and TCP - lots of combinations of failure modes - // https://epics.anl.gov/docs/APS2014/05-CA-Concepts.pdf - // https://epics-controls.org/resources-and-support/documents/ca/ - - // TODO: explore CAJ_DO_NOT_SHARE_CHANNELS option - //context.setDoNotShareChannels(true); - - context.addContextExceptionListener(new ContextExceptionListener() { - @Override - public void contextException(ContextExceptionEvent ev) { - System.err.println("ContextException: " + ev); - } - - @Override - public void contextVirtualCircuitException(ContextVirtualCircuitExceptionEvent ev) { - System.err.println("ContextVirtualCircuitException: Status: " + ev.getStatus() + ", IP: " + ev.getVirtualCircuit() + ", Source: "); - ((CAJContext)ev.getSource()).printInfo(System.err); - code.set(ev.getStatus().getValue()); - } - }); - - context.addContextMessageListener(new ContextMessageListener() { - @Override - public void contextMessage(ContextMessageEvent ev) { - System.err.println("ContextMessage: " + ev); - } - }); - - channelManager.addPv(listener, "channel3"); - - Thread.sleep(1000); - - Container.ExecResult result = softioc.execInContainer("ip", "link", "set", "eth0", "down"); - System.out.println("err: " + result.getStderr()); - System.out.println("out: " + result.getStdout()); - System.out.println("exit: " + result.getExitCode()); - - Thread.sleep(5000); - - result = softioc.execInContainer("ip", "link", "set", "eth0", "up"); - System.out.println("err: " + result.getStderr()); - System.out.println("out: " + result.getStdout()); - System.out.println("exit: " + result.getExitCode()); - - Thread.sleep(5000); - - latch.await(10, TimeUnit.SECONDS); - - channelManager.removeListener(listener); - - Assert.assertEquals(60, code.get()); - } - - /** - * This test creates a monitor on a channel then performs a get request on that same channel. Note that the - * CAJChannel object is shared via internal JCA/CAJ lookup (CAJContext.createChannel returns existing CAJChannel if - * it already exists for a given channel name and priority). - * - * @throws IOException If unable to perform IO - * @throws InterruptedException If a thread is interrupted - * @throws CAException If unable to communicate over CA - */ - @Test - public void testSharedChannel() throws IOException, InterruptedException, CAException, TimeoutException { - final CountDownLatch latch = new CountDownLatch(2); - final AtomicInteger code = new AtomicInteger(); - - PvListener listener = new LatchPvListener(latch); - - context.addContextExceptionListener(new ContextExceptionListener() { - @Override - public void contextException(ContextExceptionEvent ev) { - System.err.println("ContextException: " + ev); - } - - @Override - public void contextVirtualCircuitException(ContextVirtualCircuitExceptionEvent ev) { - System.err.println("ContextVirtualCircuitException: Status: " + ev.getStatus() + ", IP: " + ev.getVirtualCircuit() + ", Source: "); - ((CAJContext)ev.getSource()).printInfo(System.err); - code.set(ev.getStatus().getValue()); - } - }); - - context.addContextMessageListener(new ContextMessageListener() { - @Override - public void contextMessage(ContextMessageEvent ev) { - System.err.println("ContextMessage: " + ev); - } - }); - - channelManager.addPv(listener, "channel3"); - - Thread.sleep(1000); - - try { - List valueList = channelManager.get(new String[]{"channel3"}, false); - - System.out.println("Get Value: " + ((DBR_Double)valueList.get(0)).getDoubleValue()[0]); - } catch(gov.aps.jca.TimeoutException e) { - e.printStackTrace(); - } - - CAJChannel channel = (CAJChannel)context.createChannel("channel3", new ConnectionListener() { - @Override - public void connectionChanged(ConnectionEvent ev) { - System.out.println("Custom ConnectionListener: Connected: " + ev.isConnected()); - } - }); - - context.pendIO(1000); - - double value = ((DBR_Double)channel.get()).getDoubleValue()[0]; - - context.pendIO(1000); - - channel.destroyChannel(true); // Force ignores reference count and destroys channel even if others are using it! - - Thread.sleep(1000); - - latch.await(5, TimeUnit.SECONDS); - - - - - IllegalStateException e = Assert.assertThrows(IllegalStateException.class, () -> channelManager.removeListener(listener)); - - Assert.assertEquals("Channel already destroyed.", e.getMessage()); - - Thread.sleep(1000); - } - - /** - * PvListener for Testing that simply logs updates/info from monitor and counts down the provided latch on updates. - * - * This listener assumes it is listening to scalar double typed data (like from the test softioc) - * */ - private class LatchPvListener implements PvListener { - private CountDownLatch latch; - - LatchPvListener(CountDownLatch latch) { - this.latch = latch; - } - - @Override - public void notifyPvInfo(String pv, boolean couldConnect, DBRType type, Integer count, String[] enumLabels) { - System.out.println("Info: " + pv + " " + couldConnect); - } - - @Override - public void notifyPvUpdate(String pv, DBR dbr) { - System.out.println("Update: " + pv + " " + ((DBR_Double)dbr).getDoubleValue()[0]); - latch.countDown(); - } - } -} diff --git a/src/integration/java/org/jlab/epics2web/WebSocketTest.java b/src/integration/java/org/jlab/epics2web/WebSocketTest.java new file mode 100644 index 0000000..7877476 --- /dev/null +++ b/src/integration/java/org/jlab/epics2web/WebSocketTest.java @@ -0,0 +1,68 @@ +package org.jlab.epics2web; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.*; +import org.junit.rules.Timeout; + +public class WebSocketTest { + + private CountDownLatch latch; + private WebSocket socket; + + @Rule + public Timeout globalTimeout = Timeout.seconds(10); + + @Before + public void setUp() { + latch = new CountDownLatch(1); + + socket = HttpClient + .newHttpClient() + .newWebSocketBuilder() + .buildAsync(URI.create("ws://localhost:8080/epics2web/monitor"), new WebSocketClient(latch)) + .join(); + } + + @After + public void tearDown() { + socket.sendClose(1000, "Done"); + } + + @Test + public void simpleTest() throws Exception { + socket.sendText("{\"type\": \"monitor\",\"pvs\": [\"channel1\"]}", true); + latch.await(5, TimeUnit.SECONDS); + } + + private static class WebSocketClient implements WebSocket.Listener { + private final CountDownLatch latch; + + public WebSocketClient(CountDownLatch latch) { this.latch = latch; } + + @Override + public void onOpen(WebSocket ws) { + System.out.println("onOpen: "); + WebSocket.Listener.super.onOpen(ws); + } + + @Override + public CompletionStage onText(WebSocket ws, CharSequence data, boolean last) { + System.out.println("onText: " + data); + latch.countDown(); + return WebSocket.Listener.super.onText(ws, data, last); + } + + @Override + public void onError(WebSocket ws, Throwable error) { + System.out.println("onError: " + ws.toString()); + WebSocket.Listener.super.onError(ws, error); + } + } + +}