diff --git a/docs/guide/protocols/websocket.md b/docs/guide/protocols/websocket.md index b62c4a63..ca23abf8 100644 --- a/docs/guide/protocols/websocket.md +++ b/docs/guide/protocols/websocket.md @@ -49,10 +49,33 @@ public class Test { } ``` +::: tip +You can use JMeter variables or functions in the connect URL parts (host, port, path or query). Prefer this over putting the whole URL in a single variable: + +```java +vars() + .set("HOST", "ws.postman-echo.com") + .set("PORT", "443") + .set("TOKEN", "abc123"), +websocketConnect("wss://${HOST}:${PORT}/raw?token=${TOKEN}") +``` + +You can also use a Java variable when the full URL is known when building the test plan: + +```java +String wsUrl = "wss://ws.postman-echo.com/raw"; +websocketConnect(wsUrl) +``` +::: + ::: warning Only `ws://` and `wss://` protocols are supported. Using any other scheme will throw an `IllegalArgumentException`. ::: +::: warning +A full URL as a single JMeter expression (for example `websocketConnect("${URL}")`) is not supported. The underlying WebSocket plugin requires separate server, port, path and TLS fields, and the DSL parses the URL at build time to fill them. Without a literal `ws://` or `wss://` scheme in the string, those fields cannot be set correctly. Use expressions in URL parts (for example `ws://${HOST}:${PORT}/path`) instead. +::: + ::: tip You can use a non-blocking read if necessary in the following way: @@ -65,4 +88,4 @@ In this case, it is not recommended to add an assertion because the response cou ::: warning The WebSocket plugin only supports one connection per thread at a time. If you want to change the WebSocket server during execution, you should add a disconnect sampler and then establish a new connection. -::: \ No newline at end of file +::: diff --git a/jmeter-java-dsl-websocket/src/main/java/us/abstracta/jmeter/javadsl/websocket/WebsocketJMeterDsl.java b/jmeter-java-dsl-websocket/src/main/java/us/abstracta/jmeter/javadsl/websocket/WebsocketJMeterDsl.java index 1a82411e..35f4c100 100644 --- a/jmeter-java-dsl-websocket/src/main/java/us/abstracta/jmeter/javadsl/websocket/WebsocketJMeterDsl.java +++ b/jmeter-java-dsl-websocket/src/main/java/us/abstracta/jmeter/javadsl/websocket/WebsocketJMeterDsl.java @@ -11,8 +11,6 @@ import eu.luminis.jmeter.wssampler.SingleWriteWebSocketSampler; import eu.luminis.jmeter.wssampler.SingleWriteWebSocketSamplerGui; import java.lang.reflect.Method; -import java.net.URI; -import java.net.URISyntaxException; import java.util.Arrays; import java.util.List; import org.apache.jmeter.testelement.TestElement; @@ -25,6 +23,7 @@ import us.abstracta.jmeter.javadsl.codegeneration.params.EnumParam; import us.abstracta.jmeter.javadsl.codegeneration.params.StringParam; import us.abstracta.jmeter.javadsl.core.samplers.BaseSampler; +import us.abstracta.jmeter.javadsl.http.JmeterUrl; /** * Provides factory methods to create WebSocket samplers for performance @@ -71,6 +70,10 @@ public class WebsocketJMeterDsl { private WebsocketJMeterDsl() { } + private static boolean containsJmeterExpression(String value) { + return value != null && value.contains("${"); + } + /** * Creates a WebSocket connect sampler to establish a connection to the server. *

@@ -84,9 +87,26 @@ private WebsocketJMeterDsl() { * URL Format: {@code ws://host:port/path?query} or * {@code wss://host:port/path?query} *

+ * The URL may be a fixed value or include JMeter expressions (variables or + * functions) in its parts. For example: + *

+ * The DSL parses the URL at build time into the WebSocket plugin fields (server, + * port, path and TLS). JMeter then resolves expressions in each field at + * runtime. + *

+ * A full URL as a single JMeter expression (e.g. {@code ${URL}}) is not + * supported: without a literal {@code ws://} or {@code wss://} scheme the URL + * cannot be split into those fields. Prefer expressions in URL parts as shown + * above, or use a Java variable when the full URL is known at plan build time. * * @param url the WebSocket server URL. Supported schemes: {@code ws://} (plain) - * and {@code wss://} (TLS) + * and {@code wss://} (TLS). May contain JMeter expressions in host, + * port, path or query. * @return the connect sampler for further configuration or usage * @since 2.2 */ @@ -123,6 +143,31 @@ public static DslWriteSampler websocketWrite(String requestData) { return new DslWriteSampler(requestData); } + /** + * Same as {@link #websocketWrite(String)} but allowing to specify the payload data type. + * + * @param requestData the message to send to the WebSocket server + * @param dataType the payload data type + * @return the write sampler for further configuration or usage + * @since 2.2 + */ + public static DslWriteSampler websocketWrite(String requestData, PayloadDataType dataType) { + return new DslWriteSampler(requestData, dataType); + } + + /** + * Same as {@link #websocketWrite(String, PayloadDataType)} but allowing to use JMeter + * expressions (variables or functions) to solve the actual data type value. + * + * @param requestData the message to send to the WebSocket server + * @param dataType a JMeter expression that returns the payload data type + * @return the write sampler for further configuration or usage + * @since 2.2 + */ + public static DslWriteSampler websocketWrite(String requestData, String dataType) { + return new DslWriteSampler(requestData, dataType); + } + /** * Creates a WebSocket read sampler to receive a message from the server. *

@@ -140,6 +185,56 @@ public static DslReadSampler websocketRead() { return new DslReadSampler(); } + /** + * Same as {@link #websocketRead()} but allowing to specify the data type to read from the + * server. + * + * @param dataType the data type to read from the server + * @return the read sampler for further configuration or usage + * @since 2.2 + */ + public static DslReadSampler websocketRead(PayloadDataType dataType) { + return new DslReadSampler(dataType); + } + + /** + * Same as {@link #websocketRead(PayloadDataType)} but allowing to use JMeter expressions + * (variables or functions) to solve the actual data type value. + * + * @param dataType a JMeter expression that returns the data type to read from the server + * @return the read sampler for further configuration or usage + * @since 2.2 + */ + public static DslReadSampler websocketRead(String dataType) { + return new DslReadSampler(dataType); + } + + public enum PayloadDataType implements EnumParam.EnumPropertyValue { + TEXT("Text"), + BINARY("Binary"); + + private final String propertyValue; + + PayloadDataType(String propertyValue) { + this.propertyValue = propertyValue; + } + + @Override + public String propertyValue() { + return propertyValue; + } + + public static boolean isText(String value) { + return value == null || TEXT.propertyValue().equalsIgnoreCase(value) + || "text".equalsIgnoreCase(value); + } + + public static boolean isValidDataType(String value) { + return isText(value) || BINARY.propertyValue().equalsIgnoreCase(value) + || "binary".equalsIgnoreCase(value); + } + } + public static class DslConnectSampler extends BaseSampler { private String connectionTimeoutMillis; private String responseTimeoutMillis; @@ -150,43 +245,36 @@ public static class DslConnectSampler extends BaseSampler { private DslConnectSampler(String url) { super("WebSocket Open Connection", OpenWebSocketSamplerGui.class); - try { - URI uri = new URI(url); + parseUrl(url); + } - String scheme = uri.getScheme(); - if (scheme == null || (!"ws".equals(scheme) && !"wss".equals(scheme))) { + private void parseUrl(String url) { + JmeterUrl parsed = JmeterUrl.valueOf(url); + String scheme = parsed.protocol(); + if (scheme != null && !containsJmeterExpression(scheme)) { + if (!"ws".equals(scheme) && !"wss".equals(scheme)) { throw new IllegalArgumentException( "Invalid WebSocket URL. Must start with 'ws://' or 'wss://'"); } - - this.tls = "wss".equals(scheme); - this.server = uri.getHost(); - if (this.server == null) { - throw new IllegalArgumentException("Invalid WebSocket URL. Host is required"); - } - - int port = uri.getPort(); - if (port == -1) { - this.port = this.tls ? "443" : "80"; - } else { - this.port = String.valueOf(port); - } - - String path = uri.getPath(); - if (path == null || path.isEmpty()) { - this.path = "/"; + tls = "wss".equals(scheme); + } + server = parsed.host(); + if ((server == null || server.isEmpty()) && scheme != null + && !containsJmeterExpression(scheme)) { + throw new IllegalArgumentException("Invalid WebSocket URL. Host is required"); + } + String parsedPort = parsed.port(); + if (parsedPort == null || parsedPort.isEmpty()) { + if (scheme != null && !containsJmeterExpression(scheme)) { + port = tls ? "443" : "80"; } else { - this.path = path; - } - - String query = uri.getQuery(); - if (query != null && !query.isEmpty()) { - this.path = this.path + "?" + query; + port = ""; } - - } catch (URISyntaxException e) { - throw new IllegalArgumentException("Invalid WebSocket URL: " + url, e); + } else { + port = parsedPort; } + String parsedPath = parsed.path(); + path = (parsedPath == null || parsedPath.isEmpty()) ? "/" : parsedPath; } @Override @@ -199,9 +287,15 @@ protected TestElement buildTestElement() { ret.setReadTimeout(responseTimeoutMillis); } ret.setTLS(tls); - ret.setServer(server); - ret.setPort(port); - ret.setPath(path); + if (server != null) { + ret.setServer(server); + } + if (port != null) { + ret.setPort(port); + } + if (path != null) { + ret.setPath(path); + } return ret; } @@ -424,16 +518,44 @@ protected MethodCall buildMethodCall(CloseWebSocketSampler testElement, public static class DslWriteSampler extends BaseSampler { private String requestData; + private String dataType; private DslWriteSampler(String requestData) { + this(requestData, PayloadDataType.TEXT); + } + + private DslWriteSampler(String requestData, PayloadDataType dataType) { + this(requestData, dataType.propertyValue()); + } + + private DslWriteSampler(String requestData, String dataType) { super("WebSocket Single Write", SingleWriteWebSocketSamplerGui.class); this.requestData = requestData; + this.dataType = dataType; + validateDataType(dataType); + } + + private static void validateDataType(String dataType) { + if (dataType != null && !containsJmeterExpression(dataType) + && !PayloadDataType.isValidDataType(dataType)) { + throw new IllegalArgumentException("Invalid data type. Must be 'text' or 'binary'"); + } + } + + private static void setWriteDataType(SingleWriteWebSocketSampler write, String dataType) { + if (containsJmeterExpression(dataType)) { + write.setProperty("payloadType", dataType); + } else if (PayloadDataType.isText(dataType)) { + write.setType(DataPayloadType.Text); + } else { + write.setType(DataPayloadType.Binary); + } } @Override protected TestElement buildTestElement() { SingleWriteWebSocketSampler write = new SingleWriteWebSocketSampler(); - write.setType(DataPayloadType.Text); + setWriteDataType(write, dataType); write.setRequestData(requestData); write.setCreateNewConnection(false); return write; @@ -451,8 +573,14 @@ protected MethodCall buildMethodCall(SingleWriteWebSocketSampler testElement, MethodCallContext context) { TestElementParamBuilder paramBuilder = new TestElementParamBuilder(testElement); MethodParam requestData = paramBuilder.stringParam("requestData", ""); - return new MethodCall("websocketWrite", DslWriteSampler.class, + MethodParam dataTypeParam = paramBuilder.enumParam("payloadType", PayloadDataType.TEXT); + MethodCall ret = new MethodCall("websocketWrite", DslWriteSampler.class, new StringParam(requestData.getExpression())); + if (!dataTypeParam.isDefault()) { + ret = new MethodCall("websocketWrite", DslWriteSampler.class, + new StringParam(requestData.getExpression()), dataTypeParam); + } + return ret; } } } @@ -460,9 +588,37 @@ protected MethodCall buildMethodCall(SingleWriteWebSocketSampler testElement, public static class DslReadSampler extends BaseSampler { private String responseTimeoutMillis; private boolean waitForResponse = true; + private String dataType; private DslReadSampler() { + this(PayloadDataType.TEXT); + } + + private DslReadSampler(PayloadDataType dataType) { + this(dataType.propertyValue()); + } + + private DslReadSampler(String dataType) { super("WebSocket Single Read", SingleReadWebSocketSamplerGui.class); + this.dataType = dataType; + validateDataType(dataType); + } + + private static void validateDataType(String dataType) { + if (dataType != null && !containsJmeterExpression(dataType) + && !PayloadDataType.isValidDataType(dataType)) { + throw new IllegalArgumentException("Invalid data type. Must be 'text' or 'binary'"); + } + } + + private static void setReadDataType(SingleReadWebSocketSampler read, String dataType) { + if (containsJmeterExpression(dataType)) { + read.setProperty("dataType", dataType); + } else if (PayloadDataType.isText(dataType)) { + read.setDataType(DataType.Text); + } else { + read.setDataType(DataType.Binary); + } } @Override @@ -471,7 +627,7 @@ protected TestElement buildTestElement() { if (responseTimeoutMillis != null) { read.setReadTimeout(responseTimeoutMillis); } - read.setDataType(DataType.Text); + setReadDataType(read, dataType); read.setOptional(!waitForResponse); read.setCreateNewConnection(false); return read; @@ -535,7 +691,12 @@ protected MethodCall buildMethodCall(SingleReadWebSocketSampler testElement, TestElementParamBuilder paramBuilder = new TestElementParamBuilder(testElement); boolean optionalParam = !paramBuilder.boolParam("optional", false) .getExpression().equals("true"); - return new MethodCall("websocketRead", DslReadSampler.class) + MethodParam dataTypeParam = paramBuilder.enumParam("dataType", PayloadDataType.TEXT); + MethodCall ret = new MethodCall("websocketRead", DslReadSampler.class); + if (!dataTypeParam.isDefault()) { + ret = new MethodCall("websocketRead", DslReadSampler.class, dataTypeParam); + } + return ret .chain("responseTimeout", paramBuilder.intParam("readTimeout", 6000)) .chain("waitForResponse", new BoolParam(optionalParam, true)) .chain("createNewConnection", paramBuilder.boolParam("createNewConnection", false)); diff --git a/jmeter-java-dsl-websocket/src/test/java/DslWebsocketSamplerTest.java b/jmeter-java-dsl-websocket/src/test/java/DslWebsocketSamplerTest.java index a1122587..9030d66a 100644 --- a/jmeter-java-dsl-websocket/src/test/java/DslWebsocketSamplerTest.java +++ b/jmeter-java-dsl-websocket/src/test/java/DslWebsocketSamplerTest.java @@ -1,11 +1,14 @@ +import java.net.InetSocketAddress; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; -import static us.abstracta.jmeter.javadsl.JmeterDsl.responseAssertion; -import static us.abstracta.jmeter.javadsl.JmeterDsl.testPlan; -import static us.abstracta.jmeter.javadsl.JmeterDsl.threadGroup; +import static us.abstracta.jmeter.javadsl.JmeterDsl.*; import static us.abstracta.jmeter.javadsl.websocket.WebsocketJMeterDsl.*; +import org.java_websocket.WebSocket; +import org.java_websocket.handshake.ClientHandshake; +import org.java_websocket.server.WebSocketServer; import org.junit.jupiter.api.Test; import us.abstracta.jmeter.javadsl.core.TestPlanStats; @@ -19,13 +22,15 @@ public void shouldConnectAndEchoMessageWhenWebSocketTestPlanWithEchoServer() thr String wsUri = echoServer.getUri(); TestPlanStats stats = testPlan( threadGroup(1, 1, - websocketConnect(wsUri), + vars().set("stream_key", "1234567890"), + websocketConnect(wsUri + "/test?stream_key=${stream_key}"), websocketWrite("Hello WebSocket Test!"), websocketRead() .children( responseAssertion() .containsSubstrings("Hello WebSocket Test!")), - websocketDisconnect())) + websocketDisconnect() + )) .run(); assertThat(stats.overall().errorsCount()).isEqualTo(0); } @@ -83,4 +88,46 @@ public void shouldErrorSamplerWhenDisconnectOperationWhenNoPreviousConnection() .run(); assertThat(stats.overall().errorsCount()).isEqualTo(1); } + + private static class WebSocketEchoServer extends WebSocketServer { + + private final CountDownLatch startLatch = new CountDownLatch(1); + + WebSocketEchoServer(int port) { + super(new InetSocketAddress(port)); + } + + @Override + public void onOpen(WebSocket conn, ClientHandshake handshake) { + } + + @Override + public void onClose(WebSocket conn, int code, String reason, boolean remote) { + } + + @Override + public void onMessage(WebSocket conn, String message) { + conn.send(message); + } + + @Override + public void onError(WebSocket conn, Exception ex) { + ex.printStackTrace(); + } + + @Override + public void onStart() { + startLatch.countDown(); + } + + void awaitStart(long timeout, TimeUnit unit) throws InterruptedException { + if (!startLatch.await(timeout, unit)) { + throw new RuntimeException("WebSocket server failed to start within timeout"); + } + } + + String getUri() { + return "ws://localhost:" + getPort(); + } + } } \ No newline at end of file