Repository: api-ghost/api-ghost
Pull request #54: feat(core): Implement websocket executor
Author: login: kobenlys
Labels: name: BETA-0.0.1
name: feature
Created: 2025-05-15T17:29:00Z | Merged: 2025-05-16T05:45:58Z

<!--
Thank you for contributing to the API GHOST project.
This document provides guidelines to ensure smooth collaboration and maintain high code quality.
-->

<!--
PR Title Format Guideline

Use the following format for your PR title:

    type(scope): concise description

Examples:
    feat(api): add scenario execution support
    fix(parser): resolve YAML parsing error
    docs(readme): update CLI usage instructions

Available types:
    feat:     Add a new feature
    fix:      Fix a bug
    build:    Modify build system or external dependencies
    chore:    Modify configuration files unrelated to source or test files (e.g., .gitignore, .editorconfig)
    ci:       Update CI configuration files and scripts
    test:     Add or update tests
    docs:     Update documentation (e.g., README)
    refactor: Code changes that neither fix a bug nor add a feature
    style:    modify CSS styles
-->

### Related Issue

- #53 

### Description

- Designed to support WebSocket testing in scenario-based and load testing environments.
- Implemented with concurrency considerations for multi-threaded environments.

### Testing

#### 1. WebSocket Test Result (SSE)
<p align="center">
  <img src="https://github.com/user-attachments/assets/1905c942-0210-4dc2-a64a-5c222d77f94b" width="600"/>
</p>

#### 2. WebSocket Result (Console) - Handles concurrency issues by using thread-specific (unique) IDs.
- Socket connection success!
<p align="center">
  <img src="https://github.com/user-attachments/assets/3aaf4d83-af70-497b-aff0-d4c577dbd8a3" width="600"/>
</p>
### Additional Notes


### Pre-Submission Checklist

- [x] Have you completed code style (convention) checks locally?
- [x] Have you passed the CI tests in your local environment?



Diff:
diff --git a/core/src/main/java/com/apighost/scenario/executor/WebSocketManager.java b/core/src/main/java/com/apighost/scenario/executor/WebSocketManager.java
new file mode 100644
index 0000000..58b06ad
--- /dev/null
+++ b/core/src/main/java/com/apighost/scenario/executor/WebSocketManager.java
@@ -0,0 +1,156 @@
+package com.apighost.scenario.executor;
+
+import java.net.http.WebSocket;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.concurrent.*;
+
+/**
+ * Manages WebSocket connections, subscriptions, and connection acknowledgments per thread.
+ * <p>
+ * This class provides thread-local storage and utilities for:
+ * <ul>
+ *   <li>Tracking individual WebSocket sessions</li>
+ *   <li>Managing topic subscriptions per thread</li>
+ *   <li>Handling connection acknowledgment using {@link CompletableFuture}</li>
+ * </ul>
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketManager {
+
+    /** Stores WebSocket sessions per thread */
+    private static final ConcurrentHashMap<Long, WebSocket> webSocketStore =
+        new ConcurrentHashMap<>();
+
+    /** Stores topic-to-subscription ID mappings per thread */
+    private static final ConcurrentHashMap<Long, ConcurrentHashMap<String, Set<String>>>
+        subscribeStore = new ConcurrentHashMap<>();
+
+    /** Stores connection acknowledgment futures per thread */
+    private static final ConcurrentHashMap<Long, CompletableFuture<Boolean>> connectAckStore =
+        new ConcurrentHashMap<>();
+
+    /**
+     * Associates the current thread's ID with the given WebSocket instance.
+     *
+     * @param webSocket the WebSocket instance to store
+     */
+    public static void putWebSocket(WebSocket webSocket) {
+        webSocketStore.put(getCallerThreadId(), webSocket);
+    }
+
+    /**
+     * Retrieves the WebSocket associated with the current thread.
+     *
+     * @return the WebSocket for the current thread, or null if not set
+     */
+    public static WebSocket getWebSocket() {
+        return webSocketStore.get(getCallerThreadId());
+    }
+
+    /**
+     * Removes the WebSocket associated with the current thread.
+     */
+    public static void removeWebSocket() {
+        webSocketStore.remove(getCallerThreadId());
+    }
+
+    /**
+     * Retrieves and removes a single subscription ID for the given topic.
+     *
+     * @param topic the topic to unsubscribe from
+     * @return a subscription ID if available; otherwise, null
+     */
+    public static String getSubscribeAndRemove(String topic){
+        Set<String> subscriptions = subscribeStore
+            .getOrDefault(getCallerThreadId(), new ConcurrentHashMap<>())
+            .get(topic);
+
+        if (subscriptions != null && !subscriptions.isEmpty()) {
+            Iterator<String> iterator = subscriptions.iterator();
+            if (iterator.hasNext()) {
+                String removed = iterator.next();
+                iterator.remove();
+                return removed;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Saves a subscription ID for the specified topic under the current thread.
+     *
+     * @param topic the topic being subscribed to
+     * @param subscribeId the subscription ID to store
+     */
+    public static void saveSubScribeId(String topic, String subscribeId){
+        subscribeStore.computeIfAbsent(getCallerThreadId(), K -> new ConcurrentHashMap<>())
+            .computeIfAbsent(topic, t -> Collections.newSetFromMap(new ConcurrentHashMap<>()))
+            .add(subscribeId);
+    }
+
+    /**
+     * Retrieves the connection acknowledgment future for the specified thread.
+     *
+     * @param threadId the thread ID
+     * @return the corresponding CompletableFuture, or null if not present
+     */
+    public static CompletableFuture<Boolean> getConnectAck(long threadId){
+        return connectAckStore.get(threadId);
+    }
+
+    /**
+     * Stores a new connection acknowledgment future for the current thread.
+     *
+     * @param connectAck the CompletableFuture to store
+     */
+    public static void putConnectAck(CompletableFuture<Boolean> connectAck){
+        connectAckStore.put(getCallerThreadId(), connectAck);
+    }
+
+    /**
+     * Removes the connection acknowledgment future for the current thread.
+     */
+    public static void removeConnectAck(){
+        connectAckStore.remove(getCallerThreadId());
+    }
+
+    /**
+     * Waits for a connection acknowledgment response within the specified timeout.
+     *
+     * @param timeoutMs the timeout duration
+     * @param timeUnit the unit of time
+     * @return true if acknowledgment was received; false otherwise
+     */
+    public static boolean connectedAckResponse(long timeoutMs, TimeUnit timeUnit){
+        try{
+            return connectAckStore.get(getCallerThreadId()).get(timeoutMs, timeUnit);
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /**
+     * Clears all stored WebSocket sessions, subscriptions, and acknowledgment futures.
+     * <p>
+     * Sends a close frame to all active WebSocket sessions before clearing.
+     */
+    public static void removeAll() {
+        webSocketStore.values().forEach(ws -> ws.sendClose(WebSocket.NORMAL_CLOSURE, "Clear"));
+        connectAckStore.clear();
+        subscribeStore.clear();
+        webSocketStore.clear();
+    }
+
+    /**
+     * Retrieves the ID of the current thread.
+     *
+     * @return the current thread ID
+     */
+    private static long getCallerThreadId(){
+        return Thread.currentThread().getId();
+    }
+}
diff --git a/core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java b/core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
new file mode 100644
index 0000000..79ecfc8
--- /dev/null
+++ b/core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
@@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
+
+        Request request = step.getRequest();
+        boolean isRequestSuccess = true;
+
+        ExecutionTimer.DurationResult<WebSocketStatusCode> summary = ExecutionTimer.execute(
+            () -> switch (request.getMethod()) {
+                case CONNECT -> connect(request.getUrl(), timeoutMs);
+                case SEND -> send(request.getUrl(), request.getBody().getJson());
+                case SUBSCRIBE -> subscribe(request);
+                case UNSUBSCRIBE -> unSubscribe(request);
+                case DISCONNECT -> disconnect(timeoutMs);
+                default -> throw new UnsupportedOperationException(
+                    "Unknown method: " + request.getMethod());
+            });
+
+        String nextStep =
+            step.getRoute().stream().findFirst().map(route -> route.getThen().getStep()).orElse("");
+
+        if (EnumSet.of(WebSocketStatusCode.CONNECT_FAILED, WebSocketStatusCode.CLOSE_FAILED,
+                WebSocketStatusCode.SEND_FAILED, WebSocketStatusCode.SUBSCRIBE_FAILED,
+                WebSocketStatusCode.UNSUBSCRIBE_FAILED, WebSocketStatusCode.NULL_SESSION)
+            .contains(summary.result)) {
+            nextStep = null;
+            isRequestSuccess = false;
+        }
+
+        return new ResultStep.Builder().stepName(stepKey).type(step.getType())
+            .method(request.getMethod()).url(request.getUrl()).requestHeader(request.getHeader())
+            .requestBody(request.getBody()).responseHeaders(Map.of()).responseBody("{}")
+            .status(summary.result.getStatusCode()).startTime(String.valueOf(summary.startTime))
+            .endTime(TimeUtils.convertFormat(summary.startTime)).durationMs(summary.durationTime)
+            .isRequestSuccess(isRequestSuccess).nextStep(nextStep).route(step.getRoute()).build();
+    }
+
+    /**
+     * Initiates a WebSocket connection and sends a STOMP CONNECT frame. Awaits connection
+     * acknowledgment within the specified timeout.
+     *
+     * @param url       the WebSocket server URL
+     * @param timeoutMs the timeout in milliseconds to wait for connection acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode connect(String url, long timeoutMs) {
+
+        try {
+
+            CompletableFuture<Boolean> connectAck = new CompletableFuture<>();
+            putConnectAck(connectAck);
+            WebSocket connectWebSocket = HttpClient.newHttpClient().newWebSocketBuilder()
+                .buildAsync(URI.create(url), new BasicListener(Thread.currentThread().getId()))
+                .join();
+
+            putWebSocket(connectWebSocket);
+            connectWebSocket.sendText(buildStompConnectFrame(), true);
+
+            boolean connected = connectedAckResponse(timeoutMs, TimeUnit.MILLISECONDS);
+
+            return connected ?
+                WebSocketStatusCode.CONNECT_SUCCESS :
+                WebSocketStatusCode.CONNECT_FAILED;
+        } catch (NullPointerException e) {
+
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+
+            return WebSocketStatusCode.CONNECT_FAILED;
+        } finally {
+            removeConnectAck();
+        }
+    }
+
+    /**
+     * Sends a STOMP SUBSCRIBE frame to the given destination.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode subscribe(Request request) {
+
+        try {
+
+            String subscribeId = UUID.randomUUID().toString();
+            String subscribeFrame = buildStompSubscribeFrame(request.getUrl(), subscribeId);
+            getWebSocket().sendText(subscribeFrame, true);
+            saveSubScribeId(request.getUrl(), subscribeId);
+
+            return WebSocketStatusCode.SUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP UNSUBSCRIBE frame to cancel a previous subscription.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode unSubscribe(Request request) {
+
+        try {
+
+            String subscribeId = getSubscribeAndRemove(request.getUrl());
+            if (subscribeId == null || subscribeId.isEmpty()) {
+                return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+            }
+
+            String frame = buildStompUnsubscribeFrame(subscribeId);
+            getWebSocket().sendText(frame, true);
+
+            return WebSocketStatusCode.UNSUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP SEND frame to a specific topic with the given payload.
+     *
+     * @param topic   the STOMP destination topic
+     * @param payload the data to be serialized and sent
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode send(String topic, Object payload) {
+
+        try {
+
+            String sendFrame = buildStompSendFrame(topic, payload);
+            getWebSocket().sendText(sendFrame, true);
+
+            return WebSocketStatusCode.SEND_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SEND_FAILED;
+        }
+    }
+
+    /**
+     * Closes the WebSocket session by sending a CLOSE frame.
+     *
+     * @param timeoutMs the timeout for waiting on the close acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode disconnect(long timeoutMs) {
+
+        try {
+
+            getWebSocket().sendClose(WebSocket.NORMAL_CLOSURE, "Close from ApiGhost")
+                .orTimeout(timeoutMs, TimeUnit.MILLISECONDS).join();
+            removeWebSocket();
+
+            return WebSocketStatusCode.CLOSE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.CLOSE_FAILED;
+        }
+    }
+
+    /**
+     * Clears all WebSocket-related resources from the current thread context.
+     */
+    private static void clearAll() {
+        WebSocketManager.removeAll();
+    }
+
+    /**
+     * Basic WebSocket listener implementation that handles CONNECTED frame acknowledgment.
+     */
+    private static class BasicListener implements WebSocket.Listener {
+
+        private final long originalThreadId;
+
+        public BasicListener(long originalThreadId) {
+            this.originalThreadId = originalThreadId;
+        }
+
+        @Override
+        public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
+
+            if (data.toString().startsWith("CONNECTED")) {
+                CompletableFuture<Boolean> future = getConnectAck(originalThreadId);
+                if (future != null) {
+                    future.complete(true);
+                }
+            }
+            webSocket.request(1);
+            return CompletableFuture.completedFuture(null);
+        }
+
+        @Override
+        public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
+            return CompletableFuture.completedFuture(null);
+        }
+    }
+
+    /**
+     * Stomp Message Builder
+     **/
+    private static String buildStompConnectFrame() {
+        return "CONNECT\n" +
+            "accept-version:1.2\n" +
+            "host:localhost\n" +
+            "heart-beat:10000,10000\n\n" +
+            "\u0000";
+    }
+
+    private static String buildStompSendFrame(String topic, Object payload)
+        throws JsonProcessingException {
+        String jsonObject = objectMapper.writeValueAsString(payload);
+        return "SEND\ndestination:" + topic + "\ncontent-type:text/plain\n\n" + jsonObject
+            + "\u0000";
+    }
+
+    private static String buildStompSubscribeFrame(String topic, String id) {
+        return "SUBSCRIBE\nid:" + id + "\ndestination:" + topic + "\n\n\u0000";
+    }
+
+    private static String buildStompUnsubscribeFrame(String id) {
+        return "UNSUBSCRIBE\nid:" + id + "\n\n\u0000";
+    }
+}
diff --git a/core/src/main/java/com/apighost/util/ExecutionTimer.java b/core/src/main/java/com/apighost/util/ExecutionTimer.java
new file mode 100644
index 0000000..26ca9fe
--- /dev/null
+++ b/core/src/main/java/com/apighost/util/ExecutionTimer.java
@@ -0,0 +1,51 @@
+package com.apighost.util;
+
+import java.util.function.Supplier;
+
+/**
+ * Utility class for measuring the execution duration of a code block.
+ * <p>
+ * This class provides a method to time the execution of a {@link Supplier} operation,
+ * returning both the result and timing metadata.
+ * </p>
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class ExecutionTimer {
+
+    /**
+     * Executes the given supplier and measures the duration of its execution.
+     *
+     * @param supplier the operation to be executed and timed
+     * @param <T>      the type of the result
+     * @return a {@link DurationResult} containing the result and timing information
+     */
+    public static <T> DurationResult<T> execute(Supplier<T> supplier) {
+
+        Long startTime = System.currentTimeMillis();
+        T result = supplier.get();
+        Long endTime = System.currentTimeMillis();
+        return new DurationResult<>(result, startTime, endTime);
+    }
+
+    /**
+     * A container for the result of an execution along with its timing data.
+     *
+     * @param <T> the type of the execution result
+     */
+    public static class DurationResult<T> {
+
+        public final T result;
+        public final Long startTime;
+        public final Long endTime;
+        public final Long durationTime;
+
+        public DurationResult(T result, Long startTime, Long endTime) {
+            this.result = result;
+            this.startTime = startTime;
+            this.endTime = endTime;
+            this.durationTime = endTime - startTime;
+        }
+    }
+}
diff --git a/core/src/main/java/com/apighost/util/TimeUtils.java b/core/src/main/java/com/apighost/util/TimeUtils.java
new file mode 100644
index 0000000..f91627f
--- /dev/null
+++ b/core/src/main/java/com/apighost/util/TimeUtils.java
@@ -0,0 +1,54 @@
+package com.apighost.util;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Utility class for handling and formatting time-related operations.
+ * <p>
+ * This class provides methods to get the current timestamp in a specific format
+ * and to convert a {@link LocalDateTime} or millisecond-based timestamp into
+ * a formatted ISO-8601-like string.
+ * </p>
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class TimeUtils {
+
+    /**
+     * Returns the current system time formatted as "yyyy-MM-dd'T'HH:mm:ss.SSS".
+     *
+     * @return the formatted current time string
+     * <p>Example: 2025-05-09T17:45:12.345</p>
+     */
+    public static String getNow(){
+        return convertFormat(LocalDateTime.now());
+    }
+
+    /**
+     * Returns the current system time formatted as "yyyy-MM-dd'T'HH:mm:ss.SSS".
+     *
+     * @return the formatted current time string
+     */
+    public static String convertFormat(LocalDateTime localDateTime) {
+        return localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"));
+    }
+
+    /**
+     * Converts a millisecond-based timestamp to a formatted string
+     *
+     * @param timeMs the timestamp in milliseconds since the epoch (UTC)
+     * @return the formatted time string
+     * <p>Example: 2025-05-09T17:45:12.345</p>
+     */
+    public static String convertFormat(Long timeMs) {
+        LocalDateTime dateTime = Instant.ofEpochMilli(timeMs)
+            .atZone(ZoneId.systemDefault())
+            .toLocalDateTime();
+        return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"));
+    }
+}
+
diff --git a/model/src/main/java/com/apighost/model/scenario/WebSocketStatusCode.java b/model/src/main/java/com/apighost/model/scenario/WebSocketStatusCode.java
new file mode 100644
index 0000000..615d19a
--- /dev/null
+++ b/model/src/main/java/com/apighost/model/scenario/WebSocketStatusCode.java
@@ -0,0 +1,25 @@
+package com.apighost.model.scenario;
+
+public enum WebSocketStatusCode {
+    CONNECT_SUCCESS(101),
+    CONNECT_FAILED(1006),
+    SUBSCRIBE_SUCCESS(0),
+    SUBSCRIBE_FAILED(500),
+    UNSUBSCRIBE_SUCCESS(0),
+    UNSUBSCRIBE_FAILED(500),
+    SEND_SUCCESS(0),
+    SEND_FAILED(500),
+    CLOSE_SUCCESS(100),
+    CLOSE_FAILED(500),
+    NULL_SESSION(404);
+
+    private final int statusCode;
+
+    WebSocketStatusCode(int statusCode) {
+        this.statusCode = statusCode;
+    }
+
+    public int getStatusCode() {
+        return statusCode;
+    }
+}
diff --git a/model/src/main/java/com/apighost/model/scenario/step/HTTPMethod.java b/model/src/main/java/com/apighost/model/scenario/step/HTTPMethod.java
index 1784b64..c9745f1 100644
--- a/model/src/main/java/com/apighost/model/scenario/step/HTTPMethod.java
+++ b/model/src/main/java/com/apighost/model/scenario/step/HTTPMethod.java
@@ -10,4 +10,8 @@ public enum HTTPMethod {
     OPTIONS,
     PATCH,
     CONNECT,
+    SEND,
+    SUBSCRIBE,
+    UNSUBSCRIBE,
+    DISCONNECT
 }
diff --git a/parser/src/test/java/parser/scenario/reader/JsonScenarioResultReaderTest.java b/parser/src/test/java/parser/scenario/reader/JsonScenarioResultReaderTest.java
index 3f514de..a257ccb 100644
--- a/parser/src/test/java/parser/scenario/reader/JsonScenarioResultReaderTest.java
+++ b/parser/src/test/java/parser/scenario/reader/JsonScenarioResultReaderTest.java
@@ -125,7 +125,7 @@ void readScenarioResult_shouldReturnValidScenarioResult() throws IOException {
         assertNotNull(result);
         assertEquals("Signup Scenario", result.getName());
         assertEquals("/local/result/user", result.getFilePath());
-        assertTrue(result.isScenarioSuccess());
+        assertTrue(result.getIsScenarioSuccess());
         assertEquals(1, result.getResults().size());
 
         ResultStep step = result.getResults().get(0);


Reviews:
user: login: haazz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T00:53:22Z
user: login: oneweeeek
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T01:00:07Z
user: login: haazz
body: Thanks for your hard work!! 👍
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T01:30:39Z
user: login: haerim-kweon
body: That's impressive. Thank you for your hard work.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T02:47:57Z
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T02:49:25Z
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T02:49:49Z
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T02:53:08Z
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T02:54:40Z
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T03:01:04Z
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T03:01:30Z
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T03:03:29Z
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T03:06:09Z
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T03:18:51Z
user: login: cod0216
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T03:43:05Z
user: login: kobenlys
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T05:29:52Z

Review comments:
diff_hunk: @@ -0,0 +1,156 @@
+package com.apighost.scenario.executor;
+
+import java.net.http.WebSocket;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.concurrent.*;
+
+/**
+ * Manages WebSocket connections, subscriptions, and connection acknowledgments per thread.
+ * <p>
+ * This class provides thread-local storage and utilities for:
+ * <ul>
+ *   <li>Tracking individual WebSocket sessions</li>
+ *   <li>Managing topic subscriptions per thread</li>
+ *   <li>Handling connection acknowledgment using {@link CompletableFuture}</li>
+ * </ul>
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketManager {
+
+    /** Stores WebSocket sessions per thread */
+    private static final ConcurrentHashMap<Long, WebSocket> webSocketStore =
+        new ConcurrentHashMap<>();
+
+    /** Stores topic-to-subscription ID mappings per thread */
+    private static final ConcurrentHashMap<Long, ConcurrentHashMap<String, Set<String>>>
+        subscribeStore = new ConcurrentHashMap<>();
path: core/src/main/java/com/apighost/scenario/executor/WebSocketManager.java
user: login: haazz
body: In the future, we could consider handling STOMP subscribe IDs.
created_at: 2025-05-16T00:53:22Z
author_association: CONTRIBUTOR
path: core/src/main/java/com/apighost/util/ExecutionTimer.java
user: login: oneweeeek
body: Thanks to the execute(Supplier) structure, it is easy to wrap around any work and measure execution time.
→ Reusable is the best. The cord is clean. Cool👍
created_at: 2025-05-16T01:00:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: haazz
body: Considering adding this logic to the validator might help improve reusability.
created_at: 2025-05-16T01:08:59Z
author_association: CONTRIBUTOR
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: haazz
body: Consider adding parsing logic for the global values in the store.
created_at: 2025-05-16T01:10:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
+
+        Request request = step.getRequest();
+        boolean isRequestSuccess = true;
+
+        ExecutionTimer.DurationResult<WebSocketStatusCode> summary = ExecutionTimer.execute(
+            () -> switch (request.getMethod()) {
+                case CONNECT -> connect(request.getUrl(), timeoutMs);
+                case SEND -> send(request.getUrl(), request.getBody().getJson());
+                case SUBSCRIBE -> subscribe(request);
+                case UNSUBSCRIBE -> unSubscribe(request);
+                case CLOSE -> close(timeoutMs);
+                default -> throw new UnsupportedOperationException(
+                    "Unknown method: " + request.getMethod());
+            });
+
+        String nextStep =
+            step.getRoute().stream().findFirst().map(route -> route.getThen().getStep()).orElse("");
+
+        if (EnumSet.of(WebSocketStatusCode.CONNECT_FAILED, WebSocketStatusCode.CLOSE_FAILED,
+                WebSocketStatusCode.SEND_FAILED, WebSocketStatusCode.SUBSCRIBE_FAILED,
+                WebSocketStatusCode.UNSUBSCRIBE_FAILED, WebSocketStatusCode.NULL_SESSION)
+            .contains(summary.result)) {
+            nextStep = null;
+            isRequestSuccess = false;
+        }
+
+        return new ResultStep.Builder().stepName(stepKey).type(step.getType())
+            .method(request.getMethod()).url(request.getUrl()).requestHeader(request.getHeader())
+            .requestBody(request.getBody()).responseHeaders(Map.of()).responseBody("{}")
+            .status(summary.result.getStatusCode()).startTime(String.valueOf(summary.startTime))
+            .endTime(TimeUtils.convertFormat(summary.startTime)).durationMs(summary.durationTime)
+            .isRequestSuccess(isRequestSuccess).nextStep(nextStep).route(step.getRoute()).build();
+    }
+
+    /**
+     * Initiates a WebSocket connection and sends a STOMP CONNECT frame. Awaits connection
+     * acknowledgment within the specified timeout.
+     *
+     * @param url       the WebSocket server URL
+     * @param timeoutMs the timeout in milliseconds to wait for connection acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode connect(String url, long timeoutMs) {
+
+        try {
+
+            CompletableFuture<Boolean> connectAck = new CompletableFuture<>();
+            putConnectAck(connectAck);
+            WebSocket connectWebSocket = HttpClient.newHttpClient().newWebSocketBuilder()
+                .buildAsync(URI.create(url), new BasicListener(Thread.currentThread().getId()))
+                .join();
+
+            putWebSocket(connectWebSocket);
+            connectWebSocket.sendText(buildStompConnectFrame(), true);
+
+            boolean connected = connectedAckResponse(timeoutMs, TimeUnit.MILLISECONDS);
+
+            return connected ?
+                WebSocketStatusCode.CONNECT_SUCCESS :
+                WebSocketStatusCode.CONNECT_FAILED;
+        } catch (NullPointerException e) {
+
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+
+            return WebSocketStatusCode.CONNECT_FAILED;
+        } finally {
+            removeConnectAck();
+        }
+    }
+
+    /**
+     * Sends a STOMP SUBSCRIBE frame to the given destination.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode subscribe(Request request) {
+
+        try {
+
+            String subscribeId = UUID.randomUUID().toString();
+            String subscribeFrame = buildStompSubscribeFrame(request.getUrl(), subscribeId);
+            getWebSocket().sendText(subscribeFrame, true);
+            saveSubScribeId(request.getUrl(), subscribeId);
+
+            return WebSocketStatusCode.SUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP UNSUBSCRIBE frame to cancel a previous subscription.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode unSubscribe(Request request) {
+
+        try {
+
+            String subscribeId = getSubscribeAndRemove(request.getUrl());
+            if (subscribeId == null || subscribeId.isEmpty()) {
+                return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+            }
+
+            String frame = buildStompUnsubscribeFrame(subscribeId);
+            getWebSocket().sendText(frame, true);
+
+            return WebSocketStatusCode.UNSUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP SEND frame to a specific topic with the given payload.
+     *
+     * @param topic   the STOMP destination topic
+     * @param payload the data to be serialized and sent
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode send(String topic, Object payload) {
+
+        try {
+
+            String sendFrame = buildStompSendFrame(topic, payload);
+            getWebSocket().sendText(sendFrame, true);
+
+            return WebSocketStatusCode.SEND_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SEND_FAILED;
+        }
+    }
+
+    /**
+     * Closes the WebSocket session by sending a CLOSE frame.
+     *
+     * @param timeoutMs the timeout for waiting on the close acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode close(long timeoutMs) {
+
+        try {
+
+            getWebSocket().sendClose(WebSocket.NORMAL_CLOSURE, "Close from ApiGhost")
+                .orTimeout(timeoutMs, TimeUnit.MILLISECONDS).join();
+            removeWebSocket();
+
+            return WebSocketStatusCode.CLOSE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.CLOSE_FAILED;
+        }
+    }
+
+    /**
+     * Clears all WebSocket-related resources from the current thread context.
+     */
+    private static void clearAll() {
+        WebSocketManager.removeAll();
+    }
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: haazz
body: It seems like this doesn’t need to be split out if it's only doing single operation.
created_at: 2025-05-16T01:13:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
+
+        Request request = step.getRequest();
+        boolean isRequestSuccess = true;
+
+        ExecutionTimer.DurationResult<WebSocketStatusCode> summary = ExecutionTimer.execute(
+            () -> switch (request.getMethod()) {
+                case CONNECT -> connect(request.getUrl(), timeoutMs);
+                case SEND -> send(request.getUrl(), request.getBody().getJson());
+                case SUBSCRIBE -> subscribe(request);
+                case UNSUBSCRIBE -> unSubscribe(request);
+                case CLOSE -> close(timeoutMs);
+                default -> throw new UnsupportedOperationException(
+                    "Unknown method: " + request.getMethod());
+            });
+
+        String nextStep =
+            step.getRoute().stream().findFirst().map(route -> route.getThen().getStep()).orElse("");
+
+        if (EnumSet.of(WebSocketStatusCode.CONNECT_FAILED, WebSocketStatusCode.CLOSE_FAILED,
+                WebSocketStatusCode.SEND_FAILED, WebSocketStatusCode.SUBSCRIBE_FAILED,
+                WebSocketStatusCode.UNSUBSCRIBE_FAILED, WebSocketStatusCode.NULL_SESSION)
+            .contains(summary.result)) {
+            nextStep = null;
+            isRequestSuccess = false;
+        }
+
+        return new ResultStep.Builder().stepName(stepKey).type(step.getType())
+            .method(request.getMethod()).url(request.getUrl()).requestHeader(request.getHeader())
+            .requestBody(request.getBody()).responseHeaders(Map.of()).responseBody("{}")
+            .status(summary.result.getStatusCode()).startTime(String.valueOf(summary.startTime))
+            .endTime(TimeUtils.convertFormat(summary.startTime)).durationMs(summary.durationTime)
+            .isRequestSuccess(isRequestSuccess).nextStep(nextStep).route(step.getRoute()).build();
+    }
+
+    /**
+     * Initiates a WebSocket connection and sends a STOMP CONNECT frame. Awaits connection
+     * acknowledgment within the specified timeout.
+     *
+     * @param url       the WebSocket server URL
+     * @param timeoutMs the timeout in milliseconds to wait for connection acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode connect(String url, long timeoutMs) {
+
+        try {
+
+            CompletableFuture<Boolean> connectAck = new CompletableFuture<>();
+            putConnectAck(connectAck);
+            WebSocket connectWebSocket = HttpClient.newHttpClient().newWebSocketBuilder()
+                .buildAsync(URI.create(url), new BasicListener(Thread.currentThread().getId()))
+                .join();
+
+            putWebSocket(connectWebSocket);
+            connectWebSocket.sendText(buildStompConnectFrame(), true);
+
+            boolean connected = connectedAckResponse(timeoutMs, TimeUnit.MILLISECONDS);
+
+            return connected ?
+                WebSocketStatusCode.CONNECT_SUCCESS :
+                WebSocketStatusCode.CONNECT_FAILED;
+        } catch (NullPointerException e) {
+
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+
+            return WebSocketStatusCode.CONNECT_FAILED;
+        } finally {
+            removeConnectAck();
+        }
+    }
+
+    /**
+     * Sends a STOMP SUBSCRIBE frame to the given destination.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode subscribe(Request request) {
+
+        try {
+
+            String subscribeId = UUID.randomUUID().toString();
+            String subscribeFrame = buildStompSubscribeFrame(request.getUrl(), subscribeId);
+            getWebSocket().sendText(subscribeFrame, true);
+            saveSubScribeId(request.getUrl(), subscribeId);
+
+            return WebSocketStatusCode.SUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP UNSUBSCRIBE frame to cancel a previous subscription.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode unSubscribe(Request request) {
+
+        try {
+
+            String subscribeId = getSubscribeAndRemove(request.getUrl());
+            if (subscribeId == null || subscribeId.isEmpty()) {
+                return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+            }
+
+            String frame = buildStompUnsubscribeFrame(subscribeId);
+            getWebSocket().sendText(frame, true);
+
+            return WebSocketStatusCode.UNSUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP SEND frame to a specific topic with the given payload.
+     *
+     * @param topic   the STOMP destination topic
+     * @param payload the data to be serialized and sent
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode send(String topic, Object payload) {
+
+        try {
+
+            String sendFrame = buildStompSendFrame(topic, payload);
+            getWebSocket().sendText(sendFrame, true);
+
+            return WebSocketStatusCode.SEND_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SEND_FAILED;
+        }
+    }
+
+    /**
+     * Closes the WebSocket session by sending a CLOSE frame.
+     *
+     * @param timeoutMs the timeout for waiting on the close acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode close(long timeoutMs) {
+
+        try {
+
+            getWebSocket().sendClose(WebSocket.NORMAL_CLOSURE, "Close from ApiGhost")
+                .orTimeout(timeoutMs, TimeUnit.MILLISECONDS).join();
+            removeWebSocket();
+
+            return WebSocketStatusCode.CLOSE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.CLOSE_FAILED;
+        }
+    }
+
+    /**
+     * Clears all WebSocket-related resources from the current thread context.
+     */
+    private static void clearAll() {
+        WebSocketManager.removeAll();
+    }
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: haazz
body: Also, this code isn't being used and likely won't be used.
created_at: 2025-05-16T01:13:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
+
+        Request request = step.getRequest();
+        boolean isRequestSuccess = true;
+
+        ExecutionTimer.DurationResult<WebSocketStatusCode> summary = ExecutionTimer.execute(
+            () -> switch (request.getMethod()) {
+                case CONNECT -> connect(request.getUrl(), timeoutMs);
+                case SEND -> send(request.getUrl(), request.getBody().getJson());
+                case SUBSCRIBE -> subscribe(request);
+                case UNSUBSCRIBE -> unSubscribe(request);
+                case CLOSE -> close(timeoutMs);
+                default -> throw new UnsupportedOperationException(
+                    "Unknown method: " + request.getMethod());
+            });
+
+        String nextStep =
+            step.getRoute().stream().findFirst().map(route -> route.getThen().getStep()).orElse("");
+
+        if (EnumSet.of(WebSocketStatusCode.CONNECT_FAILED, WebSocketStatusCode.CLOSE_FAILED,
+                WebSocketStatusCode.SEND_FAILED, WebSocketStatusCode.SUBSCRIBE_FAILED,
+                WebSocketStatusCode.UNSUBSCRIBE_FAILED, WebSocketStatusCode.NULL_SESSION)
+            .contains(summary.result)) {
+            nextStep = null;
+            isRequestSuccess = false;
+        }
+
+        return new ResultStep.Builder().stepName(stepKey).type(step.getType())
+            .method(request.getMethod()).url(request.getUrl()).requestHeader(request.getHeader())
+            .requestBody(request.getBody()).responseHeaders(Map.of()).responseBody("{}")
+            .status(summary.result.getStatusCode()).startTime(String.valueOf(summary.startTime))
+            .endTime(TimeUtils.convertFormat(summary.startTime)).durationMs(summary.durationTime)
+            .isRequestSuccess(isRequestSuccess).nextStep(nextStep).route(step.getRoute()).build();
+    }
+
+    /**
+     * Initiates a WebSocket connection and sends a STOMP CONNECT frame. Awaits connection
+     * acknowledgment within the specified timeout.
+     *
+     * @param url       the WebSocket server URL
+     * @param timeoutMs the timeout in milliseconds to wait for connection acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode connect(String url, long timeoutMs) {
+
+        try {
+
+            CompletableFuture<Boolean> connectAck = new CompletableFuture<>();
+            putConnectAck(connectAck);
+            WebSocket connectWebSocket = HttpClient.newHttpClient().newWebSocketBuilder()
+                .buildAsync(URI.create(url), new BasicListener(Thread.currentThread().getId()))
+                .join();
+
+            putWebSocket(connectWebSocket);
+            connectWebSocket.sendText(buildStompConnectFrame(), true);
+
+            boolean connected = connectedAckResponse(timeoutMs, TimeUnit.MILLISECONDS);
+
+            return connected ?
+                WebSocketStatusCode.CONNECT_SUCCESS :
+                WebSocketStatusCode.CONNECT_FAILED;
+        } catch (NullPointerException e) {
+
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+
+            return WebSocketStatusCode.CONNECT_FAILED;
+        } finally {
+            removeConnectAck();
+        }
+    }
+
+    /**
+     * Sends a STOMP SUBSCRIBE frame to the given destination.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode subscribe(Request request) {
+
+        try {
+
+            String subscribeId = UUID.randomUUID().toString();
+            String subscribeFrame = buildStompSubscribeFrame(request.getUrl(), subscribeId);
+            getWebSocket().sendText(subscribeFrame, true);
+            saveSubScribeId(request.getUrl(), subscribeId);
+
+            return WebSocketStatusCode.SUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP UNSUBSCRIBE frame to cancel a previous subscription.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode unSubscribe(Request request) {
+
+        try {
+
+            String subscribeId = getSubscribeAndRemove(request.getUrl());
+            if (subscribeId == null || subscribeId.isEmpty()) {
+                return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+            }
+
+            String frame = buildStompUnsubscribeFrame(subscribeId);
+            getWebSocket().sendText(frame, true);
+
+            return WebSocketStatusCode.UNSUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP SEND frame to a specific topic with the given payload.
+     *
+     * @param topic   the STOMP destination topic
+     * @param payload the data to be serialized and sent
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode send(String topic, Object payload) {
+
+        try {
+
+            String sendFrame = buildStompSendFrame(topic, payload);
+            getWebSocket().sendText(sendFrame, true);
+
+            return WebSocketStatusCode.SEND_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SEND_FAILED;
+        }
+    }
+
+    /**
+     * Closes the WebSocket session by sending a CLOSE frame.
+     *
+     * @param timeoutMs the timeout for waiting on the close acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode close(long timeoutMs) {
+
+        try {
+
+            getWebSocket().sendClose(WebSocket.NORMAL_CLOSURE, "Close from ApiGhost")
+                .orTimeout(timeoutMs, TimeUnit.MILLISECONDS).join();
+            removeWebSocket();
+
+            return WebSocketStatusCode.CLOSE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.CLOSE_FAILED;
+        }
+    }
+
+    /**
+     * Clears all WebSocket-related resources from the current thread context.
+     */
+    private static void clearAll() {
+        WebSocketManager.removeAll();
+    }
+
+    /**
+     * Basic WebSocket listener implementation that handles CONNECTED frame acknowledgment.
+     */
+    static class BasicListener implements WebSocket.Listener {
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: haazz
body: Could you make the access modifiers (e.g., private, public) explicit for clarity?
created_at: 2025-05-16T01:15:19Z
author_association: CONTRIBUTOR
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: haazz
body: It might be better to separate WebSocketStep execution and actual WebSocket handling into different files.
created_at: 2025-05-16T01:16:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
+
+        Request request = step.getRequest();
+        boolean isRequestSuccess = true;
+
+        ExecutionTimer.DurationResult<WebSocketStatusCode> summary = ExecutionTimer.execute(
+            () -> switch (request.getMethod()) {
+                case CONNECT -> connect(request.getUrl(), timeoutMs);
+                case SEND -> send(request.getUrl(), request.getBody().getJson());
+                case SUBSCRIBE -> subscribe(request);
+                case UNSUBSCRIBE -> unSubscribe(request);
+                case CLOSE -> close(timeoutMs);
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: haazz
body: Following the STOMP protocol naming brings better consistency.
created_at: 2025-05-16T01:19:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
+
+        Request request = step.getRequest();
+        boolean isRequestSuccess = true;
+
+        ExecutionTimer.DurationResult<WebSocketStatusCode> summary = ExecutionTimer.execute(
+            () -> switch (request.getMethod()) {
+                case CONNECT -> connect(request.getUrl(), timeoutMs);
+                case SEND -> send(request.getUrl(), request.getBody().getJson());
+                case SUBSCRIBE -> subscribe(request);
+                case UNSUBSCRIBE -> unSubscribe(request);
+                case CLOSE -> close(timeoutMs);
+                default -> throw new UnsupportedOperationException(
+                    "Unknown method: " + request.getMethod());
+            });
+
+        String nextStep =
+            step.getRoute().stream().findFirst().map(route -> route.getThen().getStep()).orElse("");
+
+        if (EnumSet.of(WebSocketStatusCode.CONNECT_FAILED, WebSocketStatusCode.CLOSE_FAILED,
+                WebSocketStatusCode.SEND_FAILED, WebSocketStatusCode.SUBSCRIBE_FAILED,
+                WebSocketStatusCode.UNSUBSCRIBE_FAILED, WebSocketStatusCode.NULL_SESSION)
+            .contains(summary.result)) {
+            nextStep = null;
+            isRequestSuccess = false;
+        }
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: haazz
body: It seems clearer if the above logic is placed inside the else block of the condition below.
created_at: 2025-05-16T01:20:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
+
+        Request request = step.getRequest();
+        boolean isRequestSuccess = true;
+
+        ExecutionTimer.DurationResult<WebSocketStatusCode> summary = ExecutionTimer.execute(
+            () -> switch (request.getMethod()) {
+                case CONNECT -> connect(request.getUrl(), timeoutMs);
+                case SEND -> send(request.getUrl(), request.getBody().getJson());
+                case SUBSCRIBE -> subscribe(request);
+                case UNSUBSCRIBE -> unSubscribe(request);
+                case CLOSE -> close(timeoutMs);
+                default -> throw new UnsupportedOperationException(
+                    "Unknown method: " + request.getMethod());
+            });
+
+        String nextStep =
+            step.getRoute().stream().findFirst().map(route -> route.getThen().getStep()).orElse("");
+
+        if (EnumSet.of(WebSocketStatusCode.CONNECT_FAILED, WebSocketStatusCode.CLOSE_FAILED,
+                WebSocketStatusCode.SEND_FAILED, WebSocketStatusCode.SUBSCRIBE_FAILED,
+                WebSocketStatusCode.UNSUBSCRIBE_FAILED, WebSocketStatusCode.NULL_SESSION)
+            .contains(summary.result)) {
+            nextStep = null;
+            isRequestSuccess = false;
+        }
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: haazz
body: It might be better to use null consistently inside orElse().
created_at: 2025-05-16T01:22:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,156 @@
+package com.apighost.scenario.executor;
+
+import java.net.http.WebSocket;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.concurrent.*;
+
+/**
+ * Manages WebSocket connections, subscriptions, and connection acknowledgments per thread.
+ * <p>
+ * This class provides thread-local storage and utilities for:
+ * <ul>
+ *   <li>Tracking individual WebSocket sessions</li>
+ *   <li>Managing topic subscriptions per thread</li>
+ *   <li>Handling connection acknowledgment using {@link CompletableFuture}</li>
+ * </ul>
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketManager {
+
+    /** Stores WebSocket sessions per thread */
+    private static final ConcurrentHashMap<Long, WebSocket> webSocketStore =
+        new ConcurrentHashMap<>();
+
+    /** Stores topic-to-subscription ID mappings per thread */
+    private static final ConcurrentHashMap<Long, ConcurrentHashMap<String, Set<String>>>
+        subscribeStore = new ConcurrentHashMap<>();
+
+    /** Stores connection acknowledgment futures per thread */
+    private static final ConcurrentHashMap<Long, CompletableFuture<Boolean>> connectAckStore =
path: core/src/main/java/com/apighost/scenario/executor/WebSocketManager.java
user: login: haerim-kweon
body: This is the first time I've actually seen ConcurrentHashMap being used. Interesting.
created_at: 2025-05-16T02:38:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,156 @@
+package com.apighost.scenario.executor;
+
+import java.net.http.WebSocket;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.concurrent.*;
+
+/**
+ * Manages WebSocket connections, subscriptions, and connection acknowledgments per thread.
+ * <p>
+ * This class provides thread-local storage and utilities for:
+ * <ul>
+ *   <li>Tracking individual WebSocket sessions</li>
+ *   <li>Managing topic subscriptions per thread</li>
+ *   <li>Handling connection acknowledgment using {@link CompletableFuture}</li>
+ * </ul>
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketManager {
+
+    /** Stores WebSocket sessions per thread */
+    private static final ConcurrentHashMap<Long, WebSocket> webSocketStore =
+        new ConcurrentHashMap<>();
+
+    /** Stores topic-to-subscription ID mappings per thread */
+    private static final ConcurrentHashMap<Long, ConcurrentHashMap<String, Set<String>>>
+        subscribeStore = new ConcurrentHashMap<>();
+
+    /** Stores connection acknowledgment futures per thread */
+    private static final ConcurrentHashMap<Long, CompletableFuture<Boolean>> connectAckStore =
+        new ConcurrentHashMap<>();
+
+    /**
+     * Associates the current thread's ID with the given WebSocket instance.
+     *
+     * @param webSocket the WebSocket instance to store
+     */
+    public static void putWebSocket(WebSocket webSocket) {
+        webSocketStore.put(getCallerThreadId(), webSocket);
+    }
+
+    /**
+     * Retrieves the WebSocket associated with the current thread.
+     *
+     * @return the WebSocket for the current thread, or null if not set
+     */
+    public static WebSocket getWebSocket() {
+        return webSocketStore.get(getCallerThreadId());
+    }
+
+    /**
+     * Removes the WebSocket associated with the current thread.
+     */
+    public static void removeWebSocket() {
+        webSocketStore.remove(getCallerThreadId());
+    }
+
+    /**
+     * Retrieves and removes a single subscription ID for the given topic.
+     *
+     * @param topic the topic to unsubscribe from
+     * @return a subscription ID if available; otherwise, null
+     */
+    public static String getSubscribeAndRemove(String topic){
+        Set<String> subscriptions = subscribeStore
+            .getOrDefault(getCallerThreadId(), new ConcurrentHashMap<>())
+            .get(topic);
+
+        if (subscriptions != null && !subscriptions.isEmpty()) {
+            Iterator<String> iterator = subscriptions.iterator();
+            if (iterator.hasNext()) {
+                String removed = iterator.next();
+                iterator.remove();
+                return removed;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Saves a subscription ID for the specified topic under the current thread.
+     *
+     * @param topic the topic being subscribed to
+     * @param subscribeId the subscription ID to store
+     */
+    public static void saveSubScribeId(String topic, String subscribeId){
+        subscribeStore.computeIfAbsent(getCallerThreadId(), K -> new ConcurrentHashMap<>())
+            .computeIfAbsent(topic, t -> Collections.newSetFromMap(new ConcurrentHashMap<>()))
+            .add(subscribeId);
+    }
+
+    /**
+     * Retrieves the connection acknowledgment future for the specified thread.
+     *
+     * @param threadId the thread ID
+     * @return the corresponding CompletableFuture, or null if not present
+     */
+    public static CompletableFuture<Boolean> getConnectAck(long threadId){
+        return connectAckStore.get(threadId);
+    }
+
+    /**
+     * Stores a new connection acknowledgment future for the current thread.
+     *
+     * @param connectAck the CompletableFuture to store
+     */
+    public static void putConnectAck(CompletableFuture<Boolean> connectAck){
+        connectAckStore.put(getCallerThreadId(), connectAck);
+    }
+
+    /**
+     * Removes the connection acknowledgment future for the current thread.
+     */
+    public static void removeConnectAck(){
+        connectAckStore.remove(getCallerThreadId());
+    }
+
+    /**
+     * Waits for a connection acknowledgment response within the specified timeout.
+     *
+     * @param timeoutMs the timeout duration
+     * @param timeUnit the unit of time
+     * @return true if acknowledgment was received; false otherwise
+     */
+    public static boolean connectedAckResponse(long timeoutMs, TimeUnit timeUnit){
+        try{
+            return connectAckStore.get(getCallerThreadId()).get(timeoutMs, timeUnit);
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /**
+     * Clears all stored WebSocket sessions, subscriptions, and acknowledgment futures.
+     * <p>
+     * Sends a close frame to all active WebSocket sessions before clearing.
+     */
+    public static void removeAll() {
path: core/src/main/java/com/apighost/scenario/executor/WebSocketManager.java
user: login: haerim-kweon
body: I am curious about how exceptions are handled if one of the clears in the middle fails.
created_at: 2025-05-16T02:44:36Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,156 @@
+package com.apighost.scenario.executor;
+
+import java.net.http.WebSocket;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.concurrent.*;
+
+/**
+ * Manages WebSocket connections, subscriptions, and connection acknowledgments per thread.
+ * <p>
+ * This class provides thread-local storage and utilities for:
+ * <ul>
+ *   <li>Tracking individual WebSocket sessions</li>
+ *   <li>Managing topic subscriptions per thread</li>
+ *   <li>Handling connection acknowledgment using {@link CompletableFuture}</li>
+ * </ul>
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketManager {
+
+    /** Stores WebSocket sessions per thread */
+    private static final ConcurrentHashMap<Long, WebSocket> webSocketStore =
+        new ConcurrentHashMap<>();
+
+    /** Stores topic-to-subscription ID mappings per thread */
+    private static final ConcurrentHashMap<Long, ConcurrentHashMap<String, Set<String>>>
+        subscribeStore = new ConcurrentHashMap<>();
path: core/src/main/java/com/apighost/scenario/executor/WebSocketManager.java
user: login: kobenlys
body: Ok I'll register the issue
created_at: 2025-05-16T02:49:25Z
author_association: COLLABORATOR
path: core/src/main/java/com/apighost/util/ExecutionTimer.java
user: login: kobenlys
body: Im So Happy~
created_at: 2025-05-16T02:49:49Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: kobenlys
body: I think that's a good direction!
I'll try to implement it later!
created_at: 2025-05-16T02:53:08Z
author_association: COLLABORATOR
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: kobenlys
body: Oh, okay. I’ll add it in the next PR.
created_at: 2025-05-16T02:54:40Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
+
+        Request request = step.getRequest();
+        boolean isRequestSuccess = true;
+
+        ExecutionTimer.DurationResult<WebSocketStatusCode> summary = ExecutionTimer.execute(
+            () -> switch (request.getMethod()) {
+                case CONNECT -> connect(request.getUrl(), timeoutMs);
+                case SEND -> send(request.getUrl(), request.getBody().getJson());
+                case SUBSCRIBE -> subscribe(request);
+                case UNSUBSCRIBE -> unSubscribe(request);
+                case CLOSE -> close(timeoutMs);
+                default -> throw new UnsupportedOperationException(
+                    "Unknown method: " + request.getMethod());
+            });
+
+        String nextStep =
+            step.getRoute().stream().findFirst().map(route -> route.getThen().getStep()).orElse("");
+
+        if (EnumSet.of(WebSocketStatusCode.CONNECT_FAILED, WebSocketStatusCode.CLOSE_FAILED,
+                WebSocketStatusCode.SEND_FAILED, WebSocketStatusCode.SUBSCRIBE_FAILED,
+                WebSocketStatusCode.UNSUBSCRIBE_FAILED, WebSocketStatusCode.NULL_SESSION)
+            .contains(summary.result)) {
+            nextStep = null;
+            isRequestSuccess = false;
+        }
+
+        return new ResultStep.Builder().stepName(stepKey).type(step.getType())
+            .method(request.getMethod()).url(request.getUrl()).requestHeader(request.getHeader())
+            .requestBody(request.getBody()).responseHeaders(Map.of()).responseBody("{}")
+            .status(summary.result.getStatusCode()).startTime(String.valueOf(summary.startTime))
+            .endTime(TimeUtils.convertFormat(summary.startTime)).durationMs(summary.durationTime)
+            .isRequestSuccess(isRequestSuccess).nextStep(nextStep).route(step.getRoute()).build();
+    }
+
+    /**
+     * Initiates a WebSocket connection and sends a STOMP CONNECT frame. Awaits connection
+     * acknowledgment within the specified timeout.
+     *
+     * @param url       the WebSocket server URL
+     * @param timeoutMs the timeout in milliseconds to wait for connection acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode connect(String url, long timeoutMs) {
+
+        try {
+
+            CompletableFuture<Boolean> connectAck = new CompletableFuture<>();
+            putConnectAck(connectAck);
+            WebSocket connectWebSocket = HttpClient.newHttpClient().newWebSocketBuilder()
+                .buildAsync(URI.create(url), new BasicListener(Thread.currentThread().getId()))
+                .join();
+
+            putWebSocket(connectWebSocket);
+            connectWebSocket.sendText(buildStompConnectFrame(), true);
+
+            boolean connected = connectedAckResponse(timeoutMs, TimeUnit.MILLISECONDS);
+
+            return connected ?
+                WebSocketStatusCode.CONNECT_SUCCESS :
+                WebSocketStatusCode.CONNECT_FAILED;
+        } catch (NullPointerException e) {
+
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+
+            return WebSocketStatusCode.CONNECT_FAILED;
+        } finally {
+            removeConnectAck();
+        }
+    }
+
+    /**
+     * Sends a STOMP SUBSCRIBE frame to the given destination.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode subscribe(Request request) {
+
+        try {
+
+            String subscribeId = UUID.randomUUID().toString();
+            String subscribeFrame = buildStompSubscribeFrame(request.getUrl(), subscribeId);
+            getWebSocket().sendText(subscribeFrame, true);
+            saveSubScribeId(request.getUrl(), subscribeId);
+
+            return WebSocketStatusCode.SUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP UNSUBSCRIBE frame to cancel a previous subscription.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode unSubscribe(Request request) {
+
+        try {
+
+            String subscribeId = getSubscribeAndRemove(request.getUrl());
+            if (subscribeId == null || subscribeId.isEmpty()) {
+                return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+            }
+
+            String frame = buildStompUnsubscribeFrame(subscribeId);
+            getWebSocket().sendText(frame, true);
+
+            return WebSocketStatusCode.UNSUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP SEND frame to a specific topic with the given payload.
+     *
+     * @param topic   the STOMP destination topic
+     * @param payload the data to be serialized and sent
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode send(String topic, Object payload) {
+
+        try {
+
+            String sendFrame = buildStompSendFrame(topic, payload);
+            getWebSocket().sendText(sendFrame, true);
+
+            return WebSocketStatusCode.SEND_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SEND_FAILED;
+        }
+    }
+
+    /**
+     * Closes the WebSocket session by sending a CLOSE frame.
+     *
+     * @param timeoutMs the timeout for waiting on the close acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode close(long timeoutMs) {
+
+        try {
+
+            getWebSocket().sendClose(WebSocket.NORMAL_CLOSURE, "Close from ApiGhost")
+                .orTimeout(timeoutMs, TimeUnit.MILLISECONDS).join();
+            removeWebSocket();
+
+            return WebSocketStatusCode.CLOSE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.CLOSE_FAILED;
+        }
+    }
+
+    /**
+     * Clears all WebSocket-related resources from the current thread context.
+     */
+    private static void clearAll() {
+        WebSocketManager.removeAll();
+    }
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: kobenlys
body: Since clearAll is already used internally in the library, I believe it makes sense to include it.

Thanks, I’ll consider that suggestion.
created_at: 2025-05-16T03:01:04Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
+
+        Request request = step.getRequest();
+        boolean isRequestSuccess = true;
+
+        ExecutionTimer.DurationResult<WebSocketStatusCode> summary = ExecutionTimer.execute(
+            () -> switch (request.getMethod()) {
+                case CONNECT -> connect(request.getUrl(), timeoutMs);
+                case SEND -> send(request.getUrl(), request.getBody().getJson());
+                case SUBSCRIBE -> subscribe(request);
+                case UNSUBSCRIBE -> unSubscribe(request);
+                case CLOSE -> close(timeoutMs);
+                default -> throw new UnsupportedOperationException(
+                    "Unknown method: " + request.getMethod());
+            });
+
+        String nextStep =
+            step.getRoute().stream().findFirst().map(route -> route.getThen().getStep()).orElse("");
+
+        if (EnumSet.of(WebSocketStatusCode.CONNECT_FAILED, WebSocketStatusCode.CLOSE_FAILED,
+                WebSocketStatusCode.SEND_FAILED, WebSocketStatusCode.SUBSCRIBE_FAILED,
+                WebSocketStatusCode.UNSUBSCRIBE_FAILED, WebSocketStatusCode.NULL_SESSION)
+            .contains(summary.result)) {
+            nextStep = null;
+            isRequestSuccess = false;
+        }
+
+        return new ResultStep.Builder().stepName(stepKey).type(step.getType())
+            .method(request.getMethod()).url(request.getUrl()).requestHeader(request.getHeader())
+            .requestBody(request.getBody()).responseHeaders(Map.of()).responseBody("{}")
+            .status(summary.result.getStatusCode()).startTime(String.valueOf(summary.startTime))
+            .endTime(TimeUtils.convertFormat(summary.startTime)).durationMs(summary.durationTime)
+            .isRequestSuccess(isRequestSuccess).nextStep(nextStep).route(step.getRoute()).build();
+    }
+
+    /**
+     * Initiates a WebSocket connection and sends a STOMP CONNECT frame. Awaits connection
+     * acknowledgment within the specified timeout.
+     *
+     * @param url       the WebSocket server URL
+     * @param timeoutMs the timeout in milliseconds to wait for connection acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode connect(String url, long timeoutMs) {
+
+        try {
+
+            CompletableFuture<Boolean> connectAck = new CompletableFuture<>();
+            putConnectAck(connectAck);
+            WebSocket connectWebSocket = HttpClient.newHttpClient().newWebSocketBuilder()
+                .buildAsync(URI.create(url), new BasicListener(Thread.currentThread().getId()))
+                .join();
+
+            putWebSocket(connectWebSocket);
+            connectWebSocket.sendText(buildStompConnectFrame(), true);
+
+            boolean connected = connectedAckResponse(timeoutMs, TimeUnit.MILLISECONDS);
+
+            return connected ?
+                WebSocketStatusCode.CONNECT_SUCCESS :
+                WebSocketStatusCode.CONNECT_FAILED;
+        } catch (NullPointerException e) {
+
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+
+            return WebSocketStatusCode.CONNECT_FAILED;
+        } finally {
+            removeConnectAck();
+        }
+    }
+
+    /**
+     * Sends a STOMP SUBSCRIBE frame to the given destination.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode subscribe(Request request) {
+
+        try {
+
+            String subscribeId = UUID.randomUUID().toString();
+            String subscribeFrame = buildStompSubscribeFrame(request.getUrl(), subscribeId);
+            getWebSocket().sendText(subscribeFrame, true);
+            saveSubScribeId(request.getUrl(), subscribeId);
+
+            return WebSocketStatusCode.SUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP UNSUBSCRIBE frame to cancel a previous subscription.
+     *
+     * @param request the request containing the subscription destination
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode unSubscribe(Request request) {
+
+        try {
+
+            String subscribeId = getSubscribeAndRemove(request.getUrl());
+            if (subscribeId == null || subscribeId.isEmpty()) {
+                return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+            }
+
+            String frame = buildStompUnsubscribeFrame(subscribeId);
+            getWebSocket().sendText(frame, true);
+
+            return WebSocketStatusCode.UNSUBSCRIBE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.UNSUBSCRIBE_FAILED;
+        }
+    }
+
+    /**
+     * Sends a STOMP SEND frame to a specific topic with the given payload.
+     *
+     * @param topic   the STOMP destination topic
+     * @param payload the data to be serialized and sent
+     * @return {@link WebSocketStatusCode} indicating success or failure
+     */
+    private WebSocketStatusCode send(String topic, Object payload) {
+
+        try {
+
+            String sendFrame = buildStompSendFrame(topic, payload);
+            getWebSocket().sendText(sendFrame, true);
+
+            return WebSocketStatusCode.SEND_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.SEND_FAILED;
+        }
+    }
+
+    /**
+     * Closes the WebSocket session by sending a CLOSE frame.
+     *
+     * @param timeoutMs the timeout for waiting on the close acknowledgment
+     * @return {@link WebSocketStatusCode} indicating the result of the operation
+     */
+    private WebSocketStatusCode close(long timeoutMs) {
+
+        try {
+
+            getWebSocket().sendClose(WebSocket.NORMAL_CLOSURE, "Close from ApiGhost")
+                .orTimeout(timeoutMs, TimeUnit.MILLISECONDS).join();
+            removeWebSocket();
+
+            return WebSocketStatusCode.CLOSE_SUCCESS;
+        } catch (NullPointerException e) {
+            return WebSocketStatusCode.NULL_SESSION;
+        } catch (Exception e) {
+            return WebSocketStatusCode.CLOSE_FAILED;
+        }
+    }
+
+    /**
+     * Clears all WebSocket-related resources from the current thread context.
+     */
+    private static void clearAll() {
+        WebSocketManager.removeAll();
+    }
+
+    /**
+     * Basic WebSocket listener implementation that handles CONNECTED frame acknowledgment.
+     */
+    static class BasicListener implements WebSocket.Listener {
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: kobenlys
body: Ok i'll fix that
created_at: 2025-05-16T03:01:30Z
author_association: COLLABORATOR
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: kobenlys
body: Thanks, I’ll consider that suggestion.
created_at: 2025-05-16T03:03:29Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,286 @@
+package com.apighost.scenario.executor;
+
+import com.apighost.model.scenario.WebSocketStatusCode;
+import com.apighost.model.scenario.request.Request;
+import com.apighost.model.scenario.result.ResultStep;
+import com.apighost.model.scenario.step.Step;
+import com.apighost.util.ExecutionTimer;
+import com.apighost.util.TimeUtils;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+
+import static com.apighost.scenario.executor.WebSocketManager.*;
+
+/**
+ * Executes a scenario step that uses the WebSocket protocol. Supports STOMP over WebSocket commands
+ * including CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, and CLOSE.
+ *
+ * <p>Each thread maintains its own WebSocket session and subscription ID mappings,
+ * which are managed in {@link WebSocketManager}. The listener responds to connection
+ * acknowledgments and completes handshake logic.
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketStepExecutor implements StepExecutor {
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Executes a WebSocket-related step using the provided {@link Step} configuration.
+     *
+     * @param stepKey   the unique key of the current step
+     * @param step      the step to be executed
+     * @param store     shared memory across steps for context transfer
+     * @param timeoutMs the timeout duration in milliseconds for blocking operations
+     * @return a {@link ResultStep} containing execution results
+     * @throws IOException              if an I/O error occurs
+     * @throws InterruptedException     if the operation is interrupted
+     * @throws IllegalArgumentException if required step fields are null or invalid
+     */
+    @Override
+    public ResultStep execute(String stepKey, Step step, Map<String, Object> store, long timeoutMs)
+        throws IOException, InterruptedException {
+
+        if (step == null || (stepKey == null || stepKey.isEmpty())) {
+            throw new IllegalArgumentException("Step Information must not be null");
+        }
+        if (step.getRequest() == null) {
+            throw new IllegalArgumentException("Request must not be null. : " + stepKey);
+        }
+
+        Request request = step.getRequest();
+        boolean isRequestSuccess = true;
+
+        ExecutionTimer.DurationResult<WebSocketStatusCode> summary = ExecutionTimer.execute(
+            () -> switch (request.getMethod()) {
+                case CONNECT -> connect(request.getUrl(), timeoutMs);
+                case SEND -> send(request.getUrl(), request.getBody().getJson());
+                case SUBSCRIBE -> subscribe(request);
+                case UNSUBSCRIBE -> unSubscribe(request);
+                case CLOSE -> close(timeoutMs);
+                default -> throw new UnsupportedOperationException(
+                    "Unknown method: " + request.getMethod());
+            });
+
+        String nextStep =
+            step.getRoute().stream().findFirst().map(route -> route.getThen().getStep()).orElse("");
+
+        if (EnumSet.of(WebSocketStatusCode.CONNECT_FAILED, WebSocketStatusCode.CLOSE_FAILED,
+                WebSocketStatusCode.SEND_FAILED, WebSocketStatusCode.SUBSCRIBE_FAILED,
+                WebSocketStatusCode.UNSUBSCRIBE_FAILED, WebSocketStatusCode.NULL_SESSION)
+            .contains(summary.result)) {
+            nextStep = null;
+            isRequestSuccess = false;
+        }
path: core/src/main/java/com/apighost/scenario/executor/WebSocketStepExecutor.java
user: login: kobenlys
body: Ok! i will consider that!
created_at: 2025-05-16T03:06:08Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,156 @@
+package com.apighost.scenario.executor;
+
+import java.net.http.WebSocket;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.concurrent.*;
+
+/**
+ * Manages WebSocket connections, subscriptions, and connection acknowledgments per thread.
+ * <p>
+ * This class provides thread-local storage and utilities for:
+ * <ul>
+ *   <li>Tracking individual WebSocket sessions</li>
+ *   <li>Managing topic subscriptions per thread</li>
+ *   <li>Handling connection acknowledgment using {@link CompletableFuture}</li>
+ * </ul>
+ *
+ * @author kobenlys
+ * @version BETA-0.0.1
+ */
+public class WebSocketManager {
+
+    /** Stores WebSocket sessions per thread */
+    private static final ConcurrentHashMap<Long, WebSocket> webSocketStore =
+        new ConcurrentHashMap<>();
+
+    /** Stores topic-to-subscription ID mappings per thread */
+    private static final ConcurrentHashMap<Long, ConcurrentHashMap<String, Set<String>>>
+        subscribeStore = new ConcurrentHashMap<>();
+
+    /** Stores connection acknowledgment futures per thread */
+    private static final ConcurrentHashMap<Long, CompletableFuture<Boolean>> connectAckStore =
+        new ConcurrentHashMap<>();
+
+    /**
+     * Associates the current thread's ID with the given WebSocket instance.
+     *
+     * @param webSocket the WebSocket instance to store
+     */
+    public static void putWebSocket(WebSocket webSocket) {
+        webSocketStore.put(getCallerThreadId(), webSocket);
+    }
+
+    /**
+     * Retrieves the WebSocket associated with the current thread.
+     *
+     * @return the WebSocket for the current thread, or null if not set
+     */
+    public static WebSocket getWebSocket() {
+        return webSocketStore.get(getCallerThreadId());
+    }
+
+    /**
+     * Removes the WebSocket associated with the current thread.
+     */
+    public static void removeWebSocket() {
+        webSocketStore.remove(getCallerThreadId());
+    }
+
+    /**
+     * Retrieves and removes a single subscription ID for the given topic.
+     *
+     * @param topic the topic to unsubscribe from
+     * @return a subscription ID if available; otherwise, null
+     */
+    public static String getSubscribeAndRemove(String topic){
+        Set<String> subscriptions = subscribeStore
+            .getOrDefault(getCallerThreadId(), new ConcurrentHashMap<>())
+            .get(topic);
+
+        if (subscriptions != null && !subscriptions.isEmpty()) {
+            Iterator<String> iterator = subscriptions.iterator();
+            if (iterator.hasNext()) {
+                String removed = iterator.next();
+                iterator.remove();
+                return removed;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Saves a subscription ID for the specified topic under the current thread.
+     *
+     * @param topic the topic being subscribed to
+     * @param subscribeId the subscription ID to store
+     */
+    public static void saveSubScribeId(String topic, String subscribeId){
+        subscribeStore.computeIfAbsent(getCallerThreadId(), K -> new ConcurrentHashMap<>())
+            .computeIfAbsent(topic, t -> Collections.newSetFromMap(new ConcurrentHashMap<>()))
+            .add(subscribeId);
+    }
+
+    /**
+     * Retrieves the connection acknowledgment future for the specified thread.
+     *
+     * @param threadId the thread ID
+     * @return the corresponding CompletableFuture, or null if not present
+     */
+    public static CompletableFuture<Boolean> getConnectAck(long threadId){
+        return connectAckStore.get(threadId);
+    }
+
+    /**
+     * Stores a new connection acknowledgment future for the current thread.
+     *
+     * @param connectAck the CompletableFuture to store
+     */
+    public static void putConnectAck(CompletableFuture<Boolean> connectAck){
+        connectAckStore.put(getCallerThreadId(), connectAck);
+    }
+
+    /**
+     * Removes the connection acknowledgment future for the current thread.
+     */
+    public static void removeConnectAck(){
+        connectAckStore.remove(getCallerThreadId());
+    }
+
+    /**
+     * Waits for a connection acknowledgment response within the specified timeout.
+     *
+     * @param timeoutMs the timeout duration
+     * @param timeUnit the unit of time
+     * @return true if acknowledgment was received; false otherwise
+     */
+    public static boolean connectedAckResponse(long timeoutMs, TimeUnit timeUnit){
+        try{
+            return connectAckStore.get(getCallerThreadId()).get(timeoutMs, timeUnit);
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /**
+     * Clears all stored WebSocket sessions, subscriptions, and acknowledgment futures.
+     * <p>
+     * Sends a close frame to all active WebSocket sessions before clearing.
+     */
+    public static void removeAll() {
path: core/src/main/java/com/apighost/scenario/executor/WebSocketManager.java
user: login: kobenlys
body: Thank you for the great review!
The methods `connectAckStore.clear(); subscribeStore.clear(); webSocketStore.clear(); `operate in-memory, so the likelihood of failure is very low.

However, if the part `webSocketStore.values().forEach(ws -> ws.sendClose(WebSocket.NORMAL_CLOSURE, "Clear")); `fails, the rest of the code won’t execute, so it definitely needs to be fixed.

Thank you!
created_at: 2025-05-16T03:18:51Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,156 @@
+package com.apighost.scenario.executor;
+
+import java.net.http.WebSocket;
path: core/src/main/java/com/apighost/scenario/executor/WebSocketManager.java
user: login: cod0216
body: "Oh… for the first time in forever, I haven't seen this import class before."


created_at: 2025-05-16T03:35:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,25 @@
+package com.apighost.model.scenario;
+
+public enum WebSocketStatusCode {
path: model/src/main/java/com/apighost/model/scenario/WebSocketStatusCode.java
user: login: cod0216
body: what is zero mean?? can you expain?
created_at: 2025-05-16T03:42:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,25 @@
+package com.apighost.model.scenario;
+
+public enum WebSocketStatusCode {
path: model/src/main/java/com/apighost/model/scenario/WebSocketStatusCode.java
user: login: kobenlys
body: Since WebSocket doesn't have status codes, I just put a placeholder value.

created_at: 2025-05-16T05:29:52Z
author_association: COLLABORATOR

PR comments:
user: login: cod0216
created_at: 2025-05-16T03:43:41Z
body: To. @kobenlys 
I read through everything carefully—great job, and thank you for your hard work!👍👍👍
author_association: MEMBER
user: login: kobenlys
created_at: 2025-05-16T05:30:12Z
body: > To. @kobenlys I read through everything carefully—great job, and thank you for your hard work!👍👍👍
Thank you so much!!! 
author_association: COLLABORATOR