diff --git a/karate-core/src/main/java/com/intuit/karate/ScriptValue.java b/karate-core/src/main/java/com/intuit/karate/ScriptValue.java index b2fa3b5cf..ba6c85ea3 100755 --- a/karate-core/src/main/java/com/intuit/karate/ScriptValue.java +++ b/karate-core/src/main/java/com/intuit/karate/ScriptValue.java @@ -139,6 +139,14 @@ public boolean isBooleanTrue() { public boolean isPrimitive() { return type == Type.PRIMITIVE; } + + public Number getAsNumber() { + return getValue(Number.class); + } + + public boolean isNumber() { + return type == Type.PRIMITIVE && Number.class.isAssignableFrom(value.getClass()); + } public boolean isFunction() { return type == Type.JS_FUNCTION; @@ -264,14 +272,14 @@ public Map getAsMap() { } } - public ScriptValue invokeFunction(ScenarioContext context) { + public ScriptValue invokeFunction(ScenarioContext context, Object callArg) { ScriptObjectMirror som = getValue(ScriptObjectMirror.class); - return Script.evalFunctionCall(som, null, context); + return Script.evalFunctionCall(som, callArg, context); } public Map evalAsMap(ScenarioContext context) { if (isFunction()) { - ScriptValue sv = invokeFunction(context); + ScriptValue sv = invokeFunction(context, null); return sv.isMapLike() ? sv.getAsMap() : null; } else { return isMapLike() ? getAsMap() : null; diff --git a/karate-core/src/main/java/com/intuit/karate/core/FeatureBackend.java b/karate-core/src/main/java/com/intuit/karate/core/FeatureBackend.java index cb77db1d5..17cece56a 100644 --- a/karate-core/src/main/java/com/intuit/karate/core/FeatureBackend.java +++ b/karate-core/src/main/java/com/intuit/karate/core/FeatureBackend.java @@ -254,7 +254,7 @@ public HttpResponse buildResponse(HttpRequest request, long startTime) { // functions here are outside of the 'transaction' and should not mutate global state ! // typically this is where users can set up an artificial delay or sleep if (afterScenario != null && afterScenario.isFunction()) { - afterScenario.invokeFunction(context); + afterScenario.invokeFunction(context, null); } return response; } diff --git a/karate-core/src/main/java/com/intuit/karate/core/ScenarioContext.java b/karate-core/src/main/java/com/intuit/karate/core/ScenarioContext.java index 41aeb99b9..128286dd4 100755 --- a/karate-core/src/main/java/com/intuit/karate/core/ScenarioContext.java +++ b/karate-core/src/main/java/com/intuit/karate/core/ScenarioContext.java @@ -55,6 +55,8 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import jdk.nashorn.api.scripting.ScriptObjectMirror; @@ -492,7 +494,7 @@ public void invokeAfterHookIfConfigured(boolean afterFeature) { ScriptValue sv = afterFeature ? config.getAfterFeature() : config.getAfterScenario(); if (sv.isFunction()) { try { - sv.invokeFunction(this); + sv.invokeFunction(this, null); } catch (Exception e) { String prefix = afterFeature ? "afterFeature" : "afterScenario"; logger.warn("{} hook failed: {}", prefix, e.getMessage()); @@ -812,6 +814,40 @@ public void embed(byte[] bytes, String contentType) { prevEmbed = embed; } + private final Object LOCK = new Object(); + private ExecutorService executor; + + public void signal() { + logger.info("signal called"); + synchronized(LOCK) { + LOCK.notify(); + } + } + + public void listen(long timeout, Runnable runnable) { + if (executor == null) { + executor = Executors.newSingleThreadExecutor(); + } + logger.trace("submitting listen function"); + executor.submit(runnable); + synchronized(LOCK) { + try { + logger.info("entered listen wait state"); + LOCK.wait(timeout); + logger.info("exit listen wait state"); + } catch (InterruptedException e) { + logger.error("listen timed out: {}", e.getMessage()); + } + } + } + + public void listen(long timeout, ScriptValue callback) { + if (!callback.isFunction()) { + throw new RuntimeException("listen expression - expected function, but was: " + callback); + } + listen(timeout, () -> callback.invokeFunction(this, null)); + } + //========================================================================== public void driver(String expression) { @@ -847,6 +883,9 @@ public void submit(String name) { } public void stop() { + if (executor != null) { + executor.shutdownNow(); + } if (driver != null) { driver.quit(); driver = null; diff --git a/karate-core/src/main/java/com/intuit/karate/core/ScriptBridge.java b/karate-core/src/main/java/com/intuit/karate/core/ScriptBridge.java index d641f4946..446756232 100755 --- a/karate-core/src/main/java/com/intuit/karate/core/ScriptBridge.java +++ b/karate-core/src/main/java/com/intuit/karate/core/ScriptBridge.java @@ -40,6 +40,8 @@ import com.intuit.karate.http.HttpResponse; import com.intuit.karate.http.HttpUtils; import com.intuit.karate.http.MultiValuedMap; +import com.intuit.karate.netty.WebSocketClient; +import com.intuit.karate.netty.WebSocketListener; import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.JsonPath; import java.io.File; @@ -352,8 +354,41 @@ public void write(Object o, String path) { ScriptValue sv = new ScriptValue(o); path = Engine.getBuildDir() + File.separator + path; FileUtils.writeToFile(new File(path), sv.getAsByteArray()); + } + + public WebSocketClient websocket(String url, ScriptObjectMirror som) { + if (!som.isFunction()) { + throw new RuntimeException("not a JS function: " + som); + } + ScriptValue sv = new ScriptValue(som); + WebSocketClient client = new WebSocketClient(url, new WebSocketListener() { + @Override + public void onMessage(String text) { + sv.invokeFunction(context, text); + } + @Override + public void onMessage(byte[] bytes) { + this.onMessage(FileUtils.toString(bytes)); + } + }); + return client; + } + + public void signal() { + context.signal(); } + public void listen(long timeout, ScriptObjectMirror som) { + if (!som.isFunction()) { + throw new RuntimeException("not a JS function: " + som); + } + context.listen(timeout, new ScriptValue(som)); + } + + public void listen(long timeout) { + context.listen(timeout, () -> {}); + } + private ScriptValue getValue(String name) { ScriptValue sv = context.vars.get(name); return sv == null ? ScriptValue.NULL : sv; diff --git a/karate-core/src/main/java/com/intuit/karate/netty/WebSocketClient.java b/karate-core/src/main/java/com/intuit/karate/netty/WebSocketClient.java index 98d738d9c..1ead35b8e 100644 --- a/karate-core/src/main/java/com/intuit/karate/netty/WebSocketClient.java +++ b/karate-core/src/main/java/com/intuit/karate/netty/WebSocketClient.java @@ -57,7 +57,6 @@ public class WebSocketClient { private final Channel channel; private final EventLoopGroup group; - private boolean waiting; public WebSocketClient(String url, WebSocketListener listener) { diff --git a/karate-core/src/test/java/com/intuit/karate/ScriptValueTest.java b/karate-core/src/test/java/com/intuit/karate/ScriptValueTest.java index 0ef0c21ed..0820c78f3 100644 --- a/karate-core/src/test/java/com/intuit/karate/ScriptValueTest.java +++ b/karate-core/src/test/java/com/intuit/karate/ScriptValueTest.java @@ -50,6 +50,18 @@ public void testTypeDetection() { assertEquals(JSON, sv.getType()); Object temp = doc.read("$"); assertTrue(temp instanceof List); + sv = new ScriptValue(1); + assertTrue(sv.isPrimitive()); + assertTrue(sv.isNumber()); + assertEquals(1, sv.getAsNumber().intValue()); + sv = new ScriptValue(100L); + assertTrue(sv.isPrimitive()); + assertTrue(sv.isNumber()); + assertEquals(100, sv.getAsNumber().longValue()); + sv = new ScriptValue(1.0); + assertTrue(sv.isPrimitive()); + assertTrue(sv.isNumber()); + assertEquals(1.0, sv.getAsNumber().doubleValue(), 0); } } diff --git a/karate-demo/pom.xml b/karate-demo/pom.xml index db1b7289f..9e63ecbde 100755 --- a/karate-demo/pom.xml +++ b/karate-demo/pom.xml @@ -27,7 +27,7 @@ org.springframework.boot - spring-boot-starter-web + spring-boot-starter-websocket ${spring.boot.version} @@ -71,7 +71,7 @@ net.masterthought cucumber-reporting - 3.8.0 + ${cucumber.reporting.version} test diff --git a/karate-demo/src/main/java/com/intuit/karate/demo/config/WebSecurityConfig.java b/karate-demo/src/main/java/com/intuit/karate/demo/config/WebSecurityConfig.java index 4d457a295..9c63e646d 100644 --- a/karate-demo/src/main/java/com/intuit/karate/demo/config/WebSecurityConfig.java +++ b/karate-demo/src/main/java/com/intuit/karate/demo/config/WebSecurityConfig.java @@ -44,7 +44,9 @@ protected void configure(HttpSecurity http) throws Exception { "/redirect/**", "/graphql/**", "/soap/**", - "/echo/**" + "/echo/**", + "/websocket/**", + "/websocket-controller/**" ); } diff --git a/karate-demo/src/main/java/com/intuit/karate/demo/config/WebSocketConfig.java b/karate-demo/src/main/java/com/intuit/karate/demo/config/WebSocketConfig.java new file mode 100644 index 000000000..7a96c2401 --- /dev/null +++ b/karate-demo/src/main/java/com/intuit/karate/demo/config/WebSocketConfig.java @@ -0,0 +1,51 @@ +/* + * The MIT License + * + * Copyright 2018 Intuit Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.intuit.karate.demo.config; + +import com.intuit.karate.demo.controller.WebSocketHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * + * @author pthomas3 + */ +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(handler(), "/websocket"); + } + + @Bean + WebSocketHandler handler() { + return new WebSocketHandler(); + } + +} diff --git a/karate-demo/src/main/java/com/intuit/karate/demo/controller/GreetingController.java b/karate-demo/src/main/java/com/intuit/karate/demo/controller/GreetingController.java index a6a8b122b..0b1ff4197 100644 --- a/karate-demo/src/main/java/com/intuit/karate/demo/controller/GreetingController.java +++ b/karate-demo/src/main/java/com/intuit/karate/demo/controller/GreetingController.java @@ -38,9 +38,8 @@ @RequestMapping("/greeting") public class GreetingController { - private static final String TEMPLATE = "Hello %s!"; private final AtomicInteger counter = new AtomicInteger(); - + @GetMapping("/reset") public String reset() { int value = 0; @@ -49,8 +48,8 @@ public String reset() { } @GetMapping - public Greeting greeting(@RequestParam(value = "name", defaultValue = "World") String name) { - return new Greeting(counter.incrementAndGet(), String.format(TEMPLATE, name)); + public Greeting getGreeting(@RequestParam(value = "name", defaultValue = "World") String name) { + return new Greeting(counter.incrementAndGet(), "Hello " + name + "!"); } } diff --git a/karate-demo/src/main/java/com/intuit/karate/demo/controller/WebSocketController.java b/karate-demo/src/main/java/com/intuit/karate/demo/controller/WebSocketController.java new file mode 100644 index 000000000..d26d1967c --- /dev/null +++ b/karate-demo/src/main/java/com/intuit/karate/demo/controller/WebSocketController.java @@ -0,0 +1,61 @@ +/* + * The MIT License + * + * Copyright 2018 Intuit Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.intuit.karate.demo.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.intuit.karate.demo.domain.Greeting; +import com.intuit.karate.demo.domain.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * + * @author pthomas3 + */ +@RestController +@RequestMapping("/websocket-controller") +public class WebSocketController { + + private static final Logger logger = LoggerFactory.getLogger(WebSocketController.class); + + @Autowired(required = true) + private WebSocketHandler handler; + + private final ObjectMapper mapper = new ObjectMapper(); + + @PostMapping + public String greet(@RequestBody Message message) throws Exception { + long time = System.currentTimeMillis(); + Greeting greeting = new Greeting(time, "hello " + message.getText() + " !"); + String json = mapper.writeValueAsString(greeting); + handler.broadcast(json); + return "{ \"id\": " + time + " }"; + } + +} diff --git a/karate-demo/src/main/java/com/intuit/karate/demo/controller/WebSocketHandler.java b/karate-demo/src/main/java/com/intuit/karate/demo/controller/WebSocketHandler.java new file mode 100644 index 000000000..5710082b2 --- /dev/null +++ b/karate-demo/src/main/java/com/intuit/karate/demo/controller/WebSocketHandler.java @@ -0,0 +1,68 @@ +/* + * The MIT License + * + * Copyright 2018 Intuit Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.intuit.karate.demo.controller; + +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * + * @author pthomas3 + */ +public class WebSocketHandler extends TextWebSocketHandler { + + private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class); + + private final List sessions = new ArrayList(); + + @Override + protected void handleTextMessage(WebSocketSession wss, TextMessage message) throws Exception { + broadcast("hello " + message.getPayload() + " !"); + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + logger.debug("websocket session init: {}", session); + synchronized (sessions) { + sessions.add(session); + } + } + + public void broadcast(String message) throws Exception { + logger.debug("sleeping before broadcast: {}", message); + Thread.sleep(1000); + synchronized (sessions) { + for (WebSocketSession session : sessions) { + logger.debug("sending to websocket session: {}", session); + session.sendMessage(new TextMessage(message)); + } + } + } + +} diff --git a/karate-demo/src/test/java/demo/websocket/WebSocketClientRunner.java b/karate-demo/src/test/java/demo/websocket/WebSocketClientRunner.java new file mode 100644 index 000000000..1e8975c6b --- /dev/null +++ b/karate-demo/src/test/java/demo/websocket/WebSocketClientRunner.java @@ -0,0 +1,53 @@ +package demo.websocket; + +import com.intuit.karate.netty.WebSocketClient; +import com.intuit.karate.netty.WebSocketListener; +import demo.TestBase; +import static org.junit.Assert.*; +import org.junit.BeforeClass; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * + * @author pthomas3 + */ +public class WebSocketClientRunner implements WebSocketListener { + + private static final Logger logger = LoggerFactory.getLogger(WebSocketClientRunner.class); + + private WebSocketClient client; + private String received; + + @BeforeClass + public static void beforeClass() throws Exception { + TestBase.beforeClass(); + } + + @Test + public void testWebSocketClient() throws Exception { + String port = System.getProperty("demo.server.port"); + client = new WebSocketClient("ws://localhost:" + port + "/websocket", this); + client.send("Billie"); + synchronized (this) { + wait(); + assertEquals("hello Billie !", received); + } + } + + @Override + public void onMessage(String text) { + logger.debug("websocket listener text: {}", text); + synchronized (this) { + received = text; + notify(); + } + } + + @Override + public void onMessage(byte[] bytes) { + + } + +} diff --git a/karate-demo/src/test/java/demo/websocket/WebSocketRunner.java b/karate-demo/src/test/java/demo/websocket/WebSocketRunner.java new file mode 100644 index 000000000..e5dc11dbe --- /dev/null +++ b/karate-demo/src/test/java/demo/websocket/WebSocketRunner.java @@ -0,0 +1,13 @@ +package demo.websocket; + +import com.intuit.karate.KarateOptions; +import demo.TestBase; + +/** + * + * @author pthomas3 + */ +@KarateOptions(features = "classpath:demo/websocket/websocket.feature") +public class WebSocketRunner extends TestBase { + +} diff --git a/karate-demo/src/test/java/demo/websocket/websocket.feature b/karate-demo/src/test/java/demo/websocket/websocket.feature new file mode 100644 index 000000000..c440605f4 --- /dev/null +++ b/karate-demo/src/test/java/demo/websocket/websocket.feature @@ -0,0 +1,30 @@ +@mock-servlet-todo +Feature: websocket testing + +Background: + * def received = null + +Scenario: only listening to websocket messages + * def handler = function(msg){ if (!msg.startsWith('{')) return; karate.set('received', msg); karate.signal() } + * def socket = karate.websocket(demoBaseUrl + '/websocket', handler) + + # first we post to the /dogs end-point + # which will broadcast a message to any websocket clients that have connected + # after a delay of 1 second + Given url demoBaseUrl + And path 'websocket-controller' + And request { text: 'Rudy' } + When method post + Then status 200 + And def id = response.id + + # this line will wait until karate.signal() has been called + * eval karate.listen(5000) + * match received == { id: '#(id)', content: 'hello Rudy !' } + +Scenario: using the websocket instance to send as well as receive messages + * def handler = function(msg){ if (!msg.startsWith('hello')) return; karate.set('received', msg); karate.signal() } + * def socket = karate.websocket(demoBaseUrl + '/websocket', handler) + * eval socket.send('Billie') + * eval karate.listen(5000) + * match received == 'hello Billie !' diff --git a/karate-junit4/pom.xml b/karate-junit4/pom.xml index 47ecf3a2c..bf5094710 100755 --- a/karate-junit4/pom.xml +++ b/karate-junit4/pom.xml @@ -30,7 +30,7 @@ net.masterthought cucumber-reporting - 3.8.0 + ${cucumber.reporting.version} test diff --git a/karate-junit5/src/main/java/com/intuit/karate/junit5/KarateTest.java b/karate-junit5/src/main/java/com/intuit/karate/junit5/KarateTest.java index d6e25fc7f..38a24be42 100644 --- a/karate-junit5/src/main/java/com/intuit/karate/junit5/KarateTest.java +++ b/karate-junit5/src/main/java/com/intuit/karate/junit5/KarateTest.java @@ -11,5 +11,5 @@ @Retention(RetentionPolicy.RUNTIME) @TestFactory public @interface KarateTest { - + } diff --git a/karate-netty/pom.xml b/karate-netty/pom.xml index d632681f9..9fc2fe6bc 100644 --- a/karate-netty/pom.xml +++ b/karate-netty/pom.xml @@ -19,7 +19,7 @@ net.masterthought cucumber-reporting - 3.8.0 + ${cucumber.reporting.version} com.intuit.karate diff --git a/pom.xml b/pom.xml index d2f24ddc9..94981c909 100755 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,7 @@ 1.5.16.RELEASE 0.7.9 2.2.4 + 3.8.0