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 ); + } + +}