diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 705a0d0..1d5c4b4 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -257,7 +257,9 @@ jobs:
echo "Running integration tests on Lucee ${{ matrix.lucee }}..."
FAILED=false
RESULTS=""
- for test in test-websocket-client test-lifecycle-callbacks test-return-value-send test-wsclient-broadcast test-wsclients-plural test-binary-send test-close; do
+ # test-session-access parked pending LDEV-6277 (wsClient.getSession() not exposed) —
+ # re-add once the Java fix lands
+ for test in test-websocket-client test-lifecycle-callbacks test-return-value-send test-wsclient-broadcast test-wsclients-plural test-binary-send test-close test-onfirstopen-rearm test-onopen-async test-request-timeout; do
echo ""
echo "=============================================="
echo "Running ${test}"
@@ -292,7 +294,7 @@ jobs:
# visible debug info in the CI log.
echo "=== WebSocket Test Event Logs ==="
found=false
- for f in /tmp/ws-*-events.log; do
+ for f in /tmp/ws-*.log; do
[ -f "$f" ] || continue
found=true
echo ""
@@ -300,7 +302,7 @@ jobs:
cat "$f"
done
if [ "$found" = "false" ]; then
- echo "(no event logs found — no test wrote to /tmp/ws-*-events.log)"
+ echo "(no event logs found — no test wrote to /tmp/ws-*.log)"
fi
- name: Test idleTimeout (LDEV-6219)
@@ -350,7 +352,7 @@ jobs:
echo '```' >> $GITHUB_STEP_SUMMARY
cat /tmp/integration-results.txt >> $GITHUB_STEP_SUMMARY || true
echo '```' >> $GITHUB_STEP_SUMMARY
- for test in test-websocket-client test-lifecycle-callbacks test-return-value-send test-wsclient-broadcast test-wsclients-plural test-binary-send test-close; do
+ for test in test-websocket-client test-lifecycle-callbacks test-return-value-send test-wsclient-broadcast test-wsclients-plural test-binary-send test-close test-onfirstopen-rearm test-onopen-async test-request-timeout; do
if [ -f /tmp/${test}.txt ] && grep -q "FAILED" /tmp/${test}.txt; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "#### ${test}" >> $GITHUB_STEP_SUMMARY
@@ -395,14 +397,25 @@ jobs:
lucee-express/lucee-server/context/logs/
test-config-override:
- name: Test - Config override + reflection hot-reinstall
+ name: Test - Config override (${{ matrix.override-mode }})
runs-on: ubuntu-latest
needs: [build-extension]
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - override-mode: env-var
+ env-var-value: /tmp/ws-alt-config.json
+ java-opts: ""
+ - override-mode: jvm-arg
+ env-var-value: ""
+ java-opts: "-Dlucee.websocket.config=/tmp/ws-alt-config.json"
env:
LUCEE_LOGGING_FORCE_APPENDER: console
LUCEE_LOGGING_FORCE_LEVEL: info
- LUCEE_WEBSOCKET_CONFIG: /tmp/ws-alt-config.json
+ LUCEE_WEBSOCKET_CONFIG: ${{ matrix.env-var-value }}
LUCEE_ADMIN_PASSWORD: testadmin
+ JAVA_OPTS: ${{ matrix.java-opts }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -466,7 +479,7 @@ jobs:
echo "WS_EXT_LEX_PATH=$LEX_FILE" >> $GITHUB_ENV
echo "Resolved .lex to: $LEX_FILE"
- - name: Start Lucee Express (with LUCEE_WEBSOCKET_CONFIG + LUCEE_ADMIN_PASSWORD in env)
+ - name: Start Lucee Express (override mode = ${{ matrix.override-mode }})
run: cd lucee-express && ./bin/catalina.sh start
- name: Wait for server
@@ -556,7 +569,7 @@ jobs:
- name: Dump WebSocket event logs
if: always()
run: |
- for f in /tmp/ws-*-events.log; do
+ for f in /tmp/ws-*.log; do
[ -f "$f" ] || continue
echo "--- $f ---"; cat "$f"
done || echo "(no event logs found)"
@@ -573,7 +586,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v7
with:
- name: lucee-logs-config-override-${{ steps.lucee-jar.outputs.LUCEE_VERSION }}
+ name: lucee-logs-config-override-${{ matrix.override-mode }}-${{ steps.lucee-jar.outputs.LUCEE_VERSION }}
path: |
lucee-express/logs/
lucee-express/lucee-server/context/logs/
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..fa327e9
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,64 @@
+# WebSocket Extension — Tests
+
+## Two test tiers
+
+### Extension tests (this folder)
+
+Exercise the extension's own contract — WebSocket server not required beyond what Tomcat already provides:
+
+- [`test-websocket-info.cfm`](test-websocket-info.cfm) — smoke test: extension bundle loaded, [`websocketInfo()`](../../../lucee-docs/docs/03.reference/01.functions/websocketinfo) returns the expected shape (`mapping`, `config`, `instances`).
+- [`test-idle-timeout.cfm`](test-idle-timeout.cfm) — [LDEV-6219](https://luceeserver.atlassian.net/browse/LDEV-6219): per-listener `property idleTimeout` must apply to every session on that listener, not just the first.
+- [`WebSocketInfo.cfc`](WebSocketInfo.cfc) / [`WebSocketListener.cfc`](WebSocketListener.cfc) / [`TestPlaceholder.cfc`](TestPlaceholder.cfc) — support CFCs.
+- [`websockets/`](websockets/) — listener CFCs (`TestListener`, `TimeoutListener`) dropped into the configured websockets directory.
+
+Driven by CI via `curl` against a Lucee Express instance with the built `.lex` installed — see `.github/workflows/main.yml`.
+
+### Integration tests ([`tests/integration/`](integration/))
+
+End-to-end client⇄server coverage — `sendText`, `sendBinary`, `onMessage`, `onClose`, `disconnect`, `isOpen`, lifecycle ordering, broadcast semantics, reflection-after-restart, config-path overrides.
+
+These tests use [`CreateWebSocketClient`](https://github.com/lucee/extension-websocket-client) as the driver against listeners registered by this extension — so every integration test exercises **both** extensions together.
+
+## Why integration tests live here, not in the client repo
+
+Every integration test needs both extensions running. Duplicating the suite across two repos guarantees drift. Instead:
+
+- One copy lives here (server repo) — the server is the thing being driven.
+- The client repo's `test-integration` job **sparse-checkouts** `tests/integration/` from this repo, builds its branch's client `.lex`, pulls the latest server `.lex` from download.lucee.org, and runs the suite against the combined pair.
+- Each side's CI catches the bug on its own side — server changes fail here, client changes fail in [extension-websocket-client](https://github.com/lucee/extension-websocket-client).
+
+## What fails where
+
+| Bug source | Caught by |
+| --- | --- |
+| Extension load, `websocketInfo()` shape, per-listener config | Extension tests in this repo (`tests/*.cfm`) |
+| Server callback firing, broadcast, lifecycle, `wsClient` / `wsClients` APIs | Integration tests in this repo (`tests/integration/`) |
+| Reflection fallback after Lucee restart (LDEV-6221) | `test-reflection-restart.cfm` (integration, Linux-only in CI) |
+| Config overrides (env var, custom directory) | `test-config-override.cfm` (separate CI job, needs altered env) |
+| Client BIF contract (scheme handling, error shape, connection timing) | Unit tests in [extension-websocket-client](https://github.com/lucee/extension-websocket-client/tree/master/tests) |
+| Client send/receive, lifecycle, reconnect | Integration tests here — fail in both repos' CI |
+
+## Adding a test
+
+**Needs the WebSocket server contract (listener callbacks, broadcast, lifecycle)?** Add it to [`tests/integration/`](integration/). Both this repo's CI and the client repo's `test-integration` job will pick it up.
+
+**Extension-level only (BIF output, config loading, startup behaviour)?** Add a `test-*.cfm` at the top level of `tests/` and wire it into the `test` job in [`.github/workflows/main.yml`](../.github/workflows/main.yml).
+
+**Listener CFCs under test** go in `tests/integration/websockets/` (integration) or `tests/websockets/` (extension-level). Both directories are mapped as the websockets directory in their respective CI job.
+
+## Running locally
+
+Build the extension:
+
+```bash
+mvn clean install
+```
+
+Drop `target/*.lex` into a Lucee Express instance's `{lucee-config}/deploy/` folder, copy the test listener CFCs into `{lucee-config}/websockets/`, then `curl` the `.cfm` scripts:
+
+```bash
+curl http://localhost:8888/tests/test-websocket-info.cfm
+curl http://localhost:8888/tests/integration/test-lifecycle-callbacks.cfm
+```
+
+Integration tests also need the [websocket-client extension](https://github.com/lucee/extension-websocket-client) installed in the same Lucee instance. The CI workflow ([`.github/workflows/main.yml`](../.github/workflows/main.yml)) shows the full setup.
diff --git a/tests/integration/test-onfirstopen-rearm.cfm b/tests/integration/test-onfirstopen-rearm.cfm
new file mode 100644
index 0000000..a2add4d
--- /dev/null
+++ b/tests/integration/test-onfirstopen-rearm.cfm
@@ -0,0 +1,70 @@
+
+// Doc claim: "onFirstOpen can fire more than once per listener lifetime — each time
+// the channel goes from zero clients back to one, it runs again."
+//
+// Sequence: connect → disconnect (channel goes to zero) → wait → connect again.
+// Expect: onFirstOpen count == 2, onLastClose count >= 1.
+
+writeOutput( "=== onFirstOpen re-arm after onLastClose ===" & chr( 10 ) );
+
+logFile = getTempDirectory() & "ws-rearm.log";
+if ( fileExists( logFile ) )
+ fileDelete( logFile );
+
+try {
+ if ( !structKeyExists( getFunctionList(), "CreateWebSocketClient" ) )
+ throw( message="CreateWebSocketClient not available", type="TestSetupError" );
+
+ wsUrl = "ws://localhost:8888/ws/RearmListener";
+
+ // First connection — expect onFirstOpen
+ clientA = new tests.integration.ClientListener();
+ wsA = CreateWebSocketClient( wsUrl, clientA );
+ sleep( 500 );
+
+ wsA.disconnect();
+ sleep( 1500 ); // give onLastClose's async thread time to fire
+
+ // Second connection after channel drained — expect another onFirstOpen
+ clientB = new tests.integration.ClientListener();
+ wsB = CreateWebSocketClient( wsUrl, clientB );
+ sleep( 500 );
+
+ wsB.disconnect();
+ sleep( 1500 );
+
+ events = fileExists( logFile ) ? fileRead( logFile ).listToArray( chr( 10 ) ) : [];
+
+ writeOutput( chr( 10 ) & "=== Events logged ===" & chr( 10 ) );
+ for ( e in events )
+ writeOutput( " - #e#" & chr( 10 ) );
+
+ firstOpenCount = 0;
+ for ( e in events ) {
+ if ( e == "onFirstOpen" ) firstOpenCount++;
+ }
+
+ errors = [];
+
+ // onFirstOpen firing twice is the real claim — the channel went to zero, then back to one.
+ // NOT asserting onLastClose because it runs on an async thread and races with the
+ // window we sample in; test-lifecycle-callbacks covers its existence.
+ if ( firstOpenCount != 2 )
+ arrayAppend( errors, "expected onFirstOpen to fire 2 times (once per cold start), got #firstOpenCount#" );
+
+ if ( arrayLen( errors ) ) {
+ writeOutput( chr( 10 ) & "FAILED:" & chr( 10 ) );
+ for ( err in errors )
+ writeOutput( " - #err#" & chr( 10 ) );
+ cfheader( statuscode=500, statustext="Test Failed" );
+ }
+ else {
+ writeOutput( chr( 10 ) & "SUCCESS: onFirstOpen re-fired after channel drained" & chr( 10 ) );
+ }
+}
+catch ( any e ) {
+ writeOutput( "FAILED with exception:" & chr( 10 ) );
+ writeOutput( e.stacktrace );
+ cfheader( statuscode=500, statustext="Test Failed" );
+}
+
diff --git a/tests/integration/test-onopen-async.cfm b/tests/integration/test-onopen-async.cfm
new file mode 100644
index 0000000..cb2ca4c
--- /dev/null
+++ b/tests/integration/test-onopen-async.cfm
@@ -0,0 +1,63 @@
+
+// Doc claim: "onOpenAsync — fires at the same time as onOpen, in parallel. Long
+// init work that shouldn't block the connection ack."
+//
+// Test: connect, assert both onOpen and onOpenAsync logged, on DIFFERENT threads.
+
+writeOutput( "=== onOpenAsync parallel execution ===" & chr( 10 ) );
+
+logFile = getTempDirectory() & "ws-onopen-async.log";
+if ( fileExists( logFile ) )
+ fileDelete( logFile );
+
+try {
+ if ( !structKeyExists( getFunctionList(), "CreateWebSocketClient" ) )
+ throw( message="CreateWebSocketClient not available", type="TestSetupError" );
+
+ listener = new tests.integration.ClientListener();
+ ws = CreateWebSocketClient( "ws://localhost:8888/ws/OnOpenAsyncListener", listener );
+ sleep( 1000 ); // generous window for async callback to complete
+
+ ws.disconnect();
+ sleep( 300 );
+
+ events = fileExists( logFile ) ? fileRead( logFile ).listToArray( chr( 10 ) ) : [];
+
+ writeOutput( chr( 10 ) & "=== Events logged ===" & chr( 10 ) );
+ for ( e in events )
+ writeOutput( " - #e#" & chr( 10 ) );
+
+ onOpenThread = "";
+ onAsyncThread = "";
+ for ( e in events ) {
+ if ( left( e, 14 ) == "onOpen:thread=" )
+ onOpenThread = mid( e, 15, len( e ) );
+ if ( left( e, 19 ) == "onOpenAsync:thread=" )
+ onAsyncThread = mid( e, 20, len( e ) );
+ }
+
+ errors = [];
+
+ if ( onOpenThread == "" )
+ arrayAppend( errors, "onOpen did not fire (no log entry)" );
+ if ( onAsyncThread == "" )
+ arrayAppend( errors, "onOpenAsync did not fire (no log entry)" );
+ if ( onOpenThread != "" && onOpenThread == onAsyncThread )
+ arrayAppend( errors, "onOpenAsync ran on the SAME thread as onOpen (#onOpenThread#) — not parallel" );
+
+ if ( arrayLen( errors ) ) {
+ writeOutput( chr( 10 ) & "FAILED:" & chr( 10 ) );
+ for ( err in errors )
+ writeOutput( " - #err#" & chr( 10 ) );
+ cfheader( statuscode=500, statustext="Test Failed" );
+ }
+ else {
+ writeOutput( chr( 10 ) & "SUCCESS: onOpen (t=#onOpenThread#) and onOpenAsync (t=#onAsyncThread#) ran on different threads" & chr( 10 ) );
+ }
+}
+catch ( any e ) {
+ writeOutput( "FAILED with exception:" & chr( 10 ) );
+ writeOutput( e.stacktrace );
+ cfheader( statuscode=500, statustext="Test Failed" );
+}
+
diff --git a/tests/integration/test-request-timeout.cfm b/tests/integration/test-request-timeout.cfm
new file mode 100644
index 0000000..70b13cf
--- /dev/null
+++ b/tests/integration/test-request-timeout.cfm
@@ -0,0 +1,69 @@
+
+// Doc claim: "requestTimeout also bounds onFirstOpen and any thread spawned inside
+// it. A while loop in onFirstOpen will be killed once requestTimeout elapses."
+//
+// The listener runs a 15-second loop with requestTimeout=3. After ~3s the loop
+// should be killed; the file log should show "before-loop" and some "tick:*"
+// entries but NO "after-loop:" entry.
+
+writeOutput( "=== requestTimeout kills onFirstOpen work ===" & chr( 10 ) );
+
+logFile = getTempDirectory() & "ws-request-timeout.log";
+if ( fileExists( logFile ) )
+ fileDelete( logFile );
+
+try {
+ if ( !structKeyExists( getFunctionList(), "CreateWebSocketClient" ) )
+ throw( message="CreateWebSocketClient not available", type="TestSetupError" );
+
+ listener = new tests.integration.ClientListener();
+ ws = CreateWebSocketClient( "ws://localhost:8888/ws/LongOnFirstOpenListener", listener );
+
+ // Wait long enough for requestTimeout (3s) to fire but less than the 15s hard cap
+ sleep( 8000 );
+
+ ws.disconnect();
+ sleep( 500 );
+
+ events = fileExists( logFile ) ? fileRead( logFile ).listToArray( chr( 10 ) ) : [];
+
+ writeOutput( chr( 10 ) & "=== Events logged (#arrayLen( events )# total) ===" & chr( 10 ) );
+ // Dump first + last 5 so CI output stays readable
+ for ( i = 1; i <= min( 5, arrayLen( events ) ); i++ )
+ writeOutput( " - #events[ i ]#" & chr( 10 ) );
+ if ( arrayLen( events ) > 10 )
+ writeOutput( " ... (#arrayLen( events ) - 10# more)" & chr( 10 ) );
+ for ( i = max( 6, arrayLen( events ) - 4 ); i <= arrayLen( events ); i++ )
+ writeOutput( " - #events[ i ]#" & chr( 10 ) );
+
+ reachedAfter = false;
+ sawBefore = false;
+ for ( e in events ) {
+ if ( left( e, 11 ) == "before-loop" ) sawBefore = true;
+ if ( left( e, 10 ) == "after-loop" ) reachedAfter = true;
+ }
+
+ errors = [];
+
+ if ( !sawBefore )
+ arrayAppend( errors, "onFirstOpen never started — 'before-loop' not logged" );
+
+ if ( reachedAfter )
+ arrayAppend( errors, "loop ran to completion — requestTimeout did NOT kill the long-running onFirstOpen work ('after-loop' was logged)" );
+
+ if ( arrayLen( errors ) ) {
+ writeOutput( chr( 10 ) & "FAILED:" & chr( 10 ) );
+ for ( err in errors )
+ writeOutput( " - #err#" & chr( 10 ) );
+ cfheader( statuscode=500, statustext="Test Failed" );
+ }
+ else {
+ writeOutput( chr( 10 ) & "SUCCESS: requestTimeout killed onFirstOpen before the loop completed" & chr( 10 ) );
+ }
+}
+catch ( any e ) {
+ writeOutput( "FAILED with exception:" & chr( 10 ) );
+ writeOutput( e.stacktrace );
+ cfheader( statuscode=500, statustext="Test Failed" );
+}
+
diff --git a/tests/integration/test-session-access.cfm b/tests/integration/test-session-access.cfm
new file mode 100644
index 0000000..50b1164
--- /dev/null
+++ b/tests/integration/test-session-access.cfm
@@ -0,0 +1,125 @@
+
+// Covers three doc claims in one pass:
+// 1. wsClient.getSession() accessors — getId(), getUserProperties(), getRequestParameterMap()
+// 2. Multiple connections per user / listener — each session has a unique id,
+// user properties are per-connection (not shared across sockets)
+// 3. Handshake session isolation — no Lucee cookie/form scope propagates into
+// the listener's synthetic PageContext
+
+writeOutput( "=== Session Access + Multi-tab + Handshake Isolation Test ===" & chr( 10 ) );
+
+logFile = getTempDirectory() & "ws-session-access.log";
+if ( fileExists( logFile ) )
+ fileDelete( logFile );
+
+try {
+ if ( !structKeyExists( getFunctionList(), "CreateWebSocketClient" ) )
+ throw( message="CreateWebSocketClient not available", type="TestSetupError" );
+
+ // Two concurrent connections to the same listener with different query-string tokens
+ // — simulates two browser tabs for the same user.
+ clientA = new tests.integration.ClientListener();
+ clientB = new tests.integration.ClientListener();
+
+ wsA = CreateWebSocketClient( "ws://localhost:8888/ws/SessionAccessListener?token=aaa", clientA );
+ sleep( 300 );
+ wsB = CreateWebSocketClient( "ws://localhost:8888/ws/SessionAccessListener?token=bbb", clientB );
+ sleep( 500 );
+
+ wsA.sendText( "__GETID__" );
+ wsB.sendText( "__GETID__" );
+ sleep( 300 );
+
+ wsA.sendText( "__GETPARAMS__" );
+ wsB.sendText( "__GETPARAMS__" );
+ sleep( 300 );
+
+ // A writes a user property, reads it back — should survive across callbacks on the same session
+ wsA.sendText( "__PUTPROP__" );
+ sleep( 200 );
+ wsA.sendText( "__GETPROP__" );
+ sleep( 300 );
+
+ // B never wrote — should see NULL (proves userProperties is per-connection, not shared)
+ wsB.sendText( "__GETPROP__" );
+ sleep( 300 );
+
+ wsA.disconnect();
+ wsB.disconnect();
+ sleep( 500 );
+
+ aMsgs = clientA.getMessages();
+ bMsgs = clientB.getMessages();
+ events = fileExists( logFile ) ? fileRead( logFile ).listToArray( chr( 10 ) ) : [];
+
+ writeOutput( "A received: " & aMsgs.toJSON() & chr( 10 ) );
+ writeOutput( "B received: " & bMsgs.toJSON() & chr( 10 ) );
+ writeOutput( "Listener events: " & events.toJSON() & chr( 10 ) );
+
+ errors = [];
+
+ // ---- getId(): each connection gets its own unique id ----
+ aId = "";
+ bId = "";
+ for ( m in aMsgs )
+ if ( left( m, 3 ) == "id:" )
+ aId = mid( m, 4, len( m ) );
+ for ( m in bMsgs )
+ if ( left( m, 3 ) == "id:" )
+ bId = mid( m, 4, len( m ) );
+
+ if ( aId == "" || bId == "" )
+ arrayAppend( errors, "missing getId() reply: A=[#aId#] B=[#bId#]" );
+ else if ( aId == bId )
+ arrayAppend( errors, "multi-tab failed: both connections reported the same session id [#aId#]" );
+
+ // ---- getRequestParameterMap(): each client sees its own query-string token ----
+ if ( !aMsgs.find( "token:aaa" ) )
+ arrayAppend( errors, "A did not see its query-string token; got: " & aMsgs.toJSON() );
+ if ( !bMsgs.find( "token:bbb" ) )
+ arrayAppend( errors, "B did not see its query-string token; got: " & bMsgs.toJSON() );
+
+ // ---- getUserProperties(): A's put survives, B's scope stays empty ----
+ aProp = "";
+ for ( m in aMsgs )
+ if ( left( m, 5 ) == "prop:" )
+ aProp = m;
+ expectedAProp = "prop:val-" & aId;
+ if ( aProp != expectedAProp )
+ arrayAppend( errors, "A getUserProperties roundtrip failed; expected [#expectedAProp#] got: [#aProp#]" );
+
+ bProp = "";
+ for ( m in bMsgs )
+ if ( left( m, 5 ) == "prop:" )
+ bProp = m;
+ if ( bProp != "prop:NULL" )
+ arrayAppend( errors, "B getUserProperties should be empty (per-connection isolation), got: [#bProp#]" );
+
+ // ---- Handshake isolation: cookie + form scopes empty inside the listener ----
+ cookieLines = events.filter( function( e ) { return left( arguments.e, 19 ) == "onOpen:cookieCount="; } );
+ for ( e in cookieLines ) {
+ if ( right( e, 2 ) != "=0" )
+ arrayAppend( errors, "handshake cookie scope not empty: [#e#]" );
+ }
+ formLines = events.filter( function( e ) { return left( arguments.e, 17 ) == "onOpen:formCount="; } );
+ for ( e in formLines ) {
+ if ( right( e, 2 ) != "=0" )
+ arrayAppend( errors, "handshake form scope not empty: [#e#]" );
+ }
+
+ if ( arrayLen( errors ) ) {
+ writeOutput( chr( 10 ) & "FAILED:" & chr( 10 ) );
+ for ( err in errors )
+ writeOutput( " - #err#" & chr( 10 ) );
+ cfheader( statuscode=500, statustext="Test Failed" );
+ }
+ else {
+ writeOutput( chr( 10 ) & "SUCCESS: getSession() accessors + multi-tab + handshake isolation all OK" & chr( 10 ) );
+ }
+}
+catch ( any e ) {
+ writeOutput( "FAILED with exception:" & chr( 10 ) );
+ writeOutput( e.stacktrace );
+ cfheader( statuscode=500, statustext="Test Failed" );
+}
+
diff --git a/tests/integration/websockets/LongOnFirstOpenListener.cfc b/tests/integration/websockets/LongOnFirstOpenListener.cfc
new file mode 100644
index 0000000..a2a868e
--- /dev/null
+++ b/tests/integration/websockets/LongOnFirstOpenListener.cfc
@@ -0,0 +1,39 @@
+component hint="Verifies requestTimeout kills long work inside onFirstOpen" {
+
+ public static function onFirstOpen( wsClients ) {
+ // Shrink the request timeout so CI doesn't wait the default 50s.
+ setting requestTimeout=3;
+
+ var logFile = getTempDirectory() & "ws-request-timeout.log";
+
+ lock name="ws-request-timeout" type="exclusive" timeout="5" {
+ fileAppend( logFile, "before-loop:" & getTickCount() & chr( 10 ) );
+ }
+
+ var start = getTickCount();
+ // Hard cap at 15s — requestTimeout should kill us well before this.
+ while ( ( getTickCount() - start ) < 15000 ) {
+ sleep( 200 );
+ lock name="ws-request-timeout" type="exclusive" timeout="1" {
+ fileAppend( logFile, "tick:" & ( getTickCount() - start ) & chr( 10 ) );
+ }
+ }
+
+ // If this line is reached, requestTimeout did NOT kill the loop.
+ lock name="ws-request-timeout" type="exclusive" timeout="5" {
+ fileAppend( logFile, "after-loop:" & getTickCount() & chr( 10 ) );
+ }
+ }
+
+ function onOpen( wsClient ) {
+ arguments.wsClient.send( "CONNECTED" );
+ }
+
+ function onMessage( wsClient, message ) {
+ arguments.wsClient.send( "ECHO:" & arguments.message );
+ }
+
+ function onClose( wsClient, reasonPhrase ) {}
+ function onError( wsClient, cfCatch ) {}
+
+}
diff --git a/tests/integration/websockets/OnOpenAsyncListener.cfc b/tests/integration/websockets/OnOpenAsyncListener.cfc
new file mode 100644
index 0000000..662092e
--- /dev/null
+++ b/tests/integration/websockets/OnOpenAsyncListener.cfc
@@ -0,0 +1,31 @@
+component hint="Tests onOpenAsync fires in parallel with onOpen on a different thread" {
+
+ private string function getLogFile() {
+ return getTempDirectory() & "ws-onopen-async.log";
+ }
+
+ private void function _log( required string event ) {
+ lock name="ws-onopen-async" type="exclusive" timeout="5" {
+ fileAppend( getLogFile(), arguments.event & chr( 10 ) );
+ }
+ }
+
+ function onOpen( wsClient ) {
+ var tid = createObject( "java", "java.lang.Thread" ).currentThread().getId();
+ _log( "onOpen:thread=" & tid );
+ arguments.wsClient.send( "CONNECTED" );
+ }
+
+ function onOpenAsync( wsClient ) {
+ var tid = createObject( "java", "java.lang.Thread" ).currentThread().getId();
+ _log( "onOpenAsync:thread=" & tid );
+ }
+
+ function onMessage( wsClient, message ) {
+ arguments.wsClient.send( "ECHO:" & arguments.message );
+ }
+
+ function onClose( wsClient, reasonPhrase ) {}
+ function onError( wsClient, cfCatch ) {}
+
+}
diff --git a/tests/integration/websockets/RearmListener.cfc b/tests/integration/websockets/RearmListener.cfc
new file mode 100644
index 0000000..2a5e1a6
--- /dev/null
+++ b/tests/integration/websockets/RearmListener.cfc
@@ -0,0 +1,42 @@
+component hint="Tests that onFirstOpen re-fires after onLastClose when new clients connect" {
+
+ private string function getLogFile() {
+ return getTempDirectory() & "ws-rearm.log";
+ }
+
+ public static function onFirstOpen( wsClients ) {
+ lock name="ws-rearm" type="exclusive" timeout="5" {
+ fileAppend( getTempDirectory() & "ws-rearm.log", "onFirstOpen" & chr( 10 ) );
+ }
+ }
+
+ public static function onLastClose() {
+ lock name="ws-rearm" type="exclusive" timeout="5" {
+ fileAppend( getTempDirectory() & "ws-rearm.log", "onLastClose" & chr( 10 ) );
+ }
+ }
+
+ function onOpen( wsClient ) {
+ lock name="ws-rearm" type="exclusive" timeout="5" {
+ fileAppend( getTempDirectory() & "ws-rearm.log", "onOpen" & chr( 10 ) );
+ }
+ arguments.wsClient.send( "CONNECTED" );
+ }
+
+ function onMessage( wsClient, message ) {
+ arguments.wsClient.send( "ECHO:" & arguments.message );
+ }
+
+ function onClose( wsClient, reasonPhrase ) {
+ lock name="ws-rearm" type="exclusive" timeout="5" {
+ fileAppend( getTempDirectory() & "ws-rearm.log", "onClose" & chr( 10 ) );
+ }
+ }
+
+ function onError( wsClient, cfCatch ) {
+ lock name="ws-rearm" type="exclusive" timeout="5" {
+ fileAppend( getTempDirectory() & "ws-rearm.log", "onError:" & arguments.cfCatch.message & chr( 10 ) );
+ }
+ }
+
+}
diff --git a/tests/integration/websockets/SessionAccessListener.cfc b/tests/integration/websockets/SessionAccessListener.cfc
new file mode 100644
index 0000000..d2cbad5
--- /dev/null
+++ b/tests/integration/websockets/SessionAccessListener.cfc
@@ -0,0 +1,71 @@
+component hint="Exercises wsClient.getSession() accessors + verifies handshake session isolation" {
+
+ private string function getLogFile() {
+ return getTempDirectory() & "ws-session-access.log";
+ }
+
+ private void function _log( required string event ) {
+ lock name="ws-session-access" type="exclusive" timeout="5" {
+ fileAppend( getLogFile(), arguments.event & chr( 10 ) );
+ }
+ }
+
+ function onOpen( wsClient ) {
+ var sess = arguments.wsClient.getSession();
+ _log( "onOpen:id=" & sess.getId() );
+
+ // The doc claim: "The extension builds a synthetic PageContext with an empty
+ // cookie array and never bridges the handshake HttpSession". Assert cookie
+ // and form scopes are empty from inside the listener.
+ try {
+ _log( "onOpen:cookieCount=" & structCount( cookie ) );
+ } catch ( any e ) {
+ _log( "onOpen:cookieScope=UNAVAILABLE:" & e.message );
+ }
+ try {
+ _log( "onOpen:formCount=" & structCount( form ) );
+ } catch ( any e ) {
+ _log( "onOpen:formScope=UNAVAILABLE:" & e.message );
+ }
+
+ arguments.wsClient.send( "CONNECTED:" & sess.getId() );
+ }
+
+ function onMessage( wsClient, message ) {
+ var sess = arguments.wsClient.getSession();
+
+ if ( arguments.message == "__GETID__" ) {
+ arguments.wsClient.send( "id:" & sess.getId() );
+ return;
+ }
+
+ if ( arguments.message == "__GETPARAMS__" ) {
+ var params = sess.getRequestParameterMap();
+ var tokenList = params.get( "token" );
+ var token = isNull( tokenList ) ? "NONE" : tokenList.get( 0 );
+ arguments.wsClient.send( "token:" & token );
+ return;
+ }
+
+ if ( arguments.message == "__PUTPROP__" ) {
+ sess.getUserProperties().put( "testKey", "val-" & sess.getId() );
+ arguments.wsClient.send( "PUT_OK" );
+ return;
+ }
+
+ if ( arguments.message == "__GETPROP__" ) {
+ var val = sess.getUserProperties().get( "testKey" );
+ arguments.wsClient.send( "prop:" & ( isNull( val ) ? "NULL" : val ) );
+ return;
+ }
+
+ arguments.wsClient.send( "ECHO:" & arguments.message );
+ }
+
+ function onClose( wsClient, reasonPhrase ) {}
+
+ function onError( wsClient, cfCatch ) {
+ _log( "onError:" & arguments.cfCatch.message );
+ }
+
+}