Skip to content

Commit cd33cf1

Browse files
authored
tests: cover documented recipe claims that had no test (#19)
1 parent ffdb80f commit cd33cf1

10 files changed

Lines changed: 596 additions & 9 deletions

.github/workflows/main.yml

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,9 @@ jobs:
257257
echo "Running integration tests on Lucee ${{ matrix.lucee }}..."
258258
FAILED=false
259259
RESULTS=""
260-
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
260+
# test-session-access parked pending LDEV-6277 (wsClient.getSession() not exposed) —
261+
# re-add once the Java fix lands
262+
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
261263
echo ""
262264
echo "=============================================="
263265
echo "Running ${test}"
@@ -292,15 +294,15 @@ jobs:
292294
# visible debug info in the CI log.
293295
echo "=== WebSocket Test Event Logs ==="
294296
found=false
295-
for f in /tmp/ws-*-events.log; do
297+
for f in /tmp/ws-*.log; do
296298
[ -f "$f" ] || continue
297299
found=true
298300
echo ""
299301
echo "--- $f ---"
300302
cat "$f"
301303
done
302304
if [ "$found" = "false" ]; then
303-
echo "(no event logs found — no test wrote to /tmp/ws-*-events.log)"
305+
echo "(no event logs found — no test wrote to /tmp/ws-*.log)"
304306
fi
305307
306308
- name: Test idleTimeout (LDEV-6219)
@@ -350,7 +352,7 @@ jobs:
350352
echo '```' >> $GITHUB_STEP_SUMMARY
351353
cat /tmp/integration-results.txt >> $GITHUB_STEP_SUMMARY || true
352354
echo '```' >> $GITHUB_STEP_SUMMARY
353-
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
355+
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
354356
if [ -f /tmp/${test}.txt ] && grep -q "FAILED" /tmp/${test}.txt; then
355357
echo "" >> $GITHUB_STEP_SUMMARY
356358
echo "#### ${test}" >> $GITHUB_STEP_SUMMARY
@@ -395,14 +397,25 @@ jobs:
395397
lucee-express/lucee-server/context/logs/
396398
397399
test-config-override:
398-
name: Test - Config override + reflection hot-reinstall
400+
name: Test - Config override (${{ matrix.override-mode }})
399401
runs-on: ubuntu-latest
400402
needs: [build-extension]
403+
strategy:
404+
fail-fast: false
405+
matrix:
406+
include:
407+
- override-mode: env-var
408+
env-var-value: /tmp/ws-alt-config.json
409+
java-opts: ""
410+
- override-mode: jvm-arg
411+
env-var-value: ""
412+
java-opts: "-Dlucee.websocket.config=/tmp/ws-alt-config.json"
401413
env:
402414
LUCEE_LOGGING_FORCE_APPENDER: console
403415
LUCEE_LOGGING_FORCE_LEVEL: info
404-
LUCEE_WEBSOCKET_CONFIG: /tmp/ws-alt-config.json
416+
LUCEE_WEBSOCKET_CONFIG: ${{ matrix.env-var-value }}
405417
LUCEE_ADMIN_PASSWORD: testadmin
418+
JAVA_OPTS: ${{ matrix.java-opts }}
406419
steps:
407420
- name: Checkout
408421
uses: actions/checkout@v6
@@ -466,7 +479,7 @@ jobs:
466479
echo "WS_EXT_LEX_PATH=$LEX_FILE" >> $GITHUB_ENV
467480
echo "Resolved .lex to: $LEX_FILE"
468481
469-
- name: Start Lucee Express (with LUCEE_WEBSOCKET_CONFIG + LUCEE_ADMIN_PASSWORD in env)
482+
- name: Start Lucee Express (override mode = ${{ matrix.override-mode }})
470483
run: cd lucee-express && ./bin/catalina.sh start
471484

472485
- name: Wait for server
@@ -556,7 +569,7 @@ jobs:
556569
- name: Dump WebSocket event logs
557570
if: always()
558571
run: |
559-
for f in /tmp/ws-*-events.log; do
572+
for f in /tmp/ws-*.log; do
560573
[ -f "$f" ] || continue
561574
echo "--- $f ---"; cat "$f"
562575
done || echo "(no event logs found)"
@@ -573,7 +586,7 @@ jobs:
573586
if: always()
574587
uses: actions/upload-artifact@v7
575588
with:
576-
name: lucee-logs-config-override-${{ steps.lucee-jar.outputs.LUCEE_VERSION }}
589+
name: lucee-logs-config-override-${{ matrix.override-mode }}-${{ steps.lucee-jar.outputs.LUCEE_VERSION }}
577590
path: |
578591
lucee-express/logs/
579592
lucee-express/lucee-server/context/logs/

tests/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# WebSocket Extension — Tests
2+
3+
## Two test tiers
4+
5+
### Extension tests (this folder)
6+
7+
Exercise the extension's own contract — WebSocket server not required beyond what Tomcat already provides:
8+
9+
- [`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`).
10+
- [`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.
11+
- [`WebSocketInfo.cfc`](WebSocketInfo.cfc) / [`WebSocketListener.cfc`](WebSocketListener.cfc) / [`TestPlaceholder.cfc`](TestPlaceholder.cfc) — support CFCs.
12+
- [`websockets/`](websockets/) — listener CFCs (`TestListener`, `TimeoutListener`) dropped into the configured websockets directory.
13+
14+
Driven by CI via `curl` against a Lucee Express instance with the built `.lex` installed — see `.github/workflows/main.yml`.
15+
16+
### Integration tests ([`tests/integration/`](integration/))
17+
18+
End-to-end client⇄server coverage — `sendText`, `sendBinary`, `onMessage`, `onClose`, `disconnect`, `isOpen`, lifecycle ordering, broadcast semantics, reflection-after-restart, config-path overrides.
19+
20+
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.
21+
22+
## Why integration tests live here, not in the client repo
23+
24+
Every integration test needs both extensions running. Duplicating the suite across two repos guarantees drift. Instead:
25+
26+
- One copy lives here (server repo) — the server is the thing being driven.
27+
- 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.
28+
- 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).
29+
30+
## What fails where
31+
32+
| Bug source | Caught by |
33+
| --- | --- |
34+
| Extension load, `websocketInfo()` shape, per-listener config | Extension tests in this repo (`tests/*.cfm`) |
35+
| Server callback firing, broadcast, lifecycle, `wsClient` / `wsClients` APIs | Integration tests in this repo (`tests/integration/`) |
36+
| Reflection fallback after Lucee restart (LDEV-6221) | `test-reflection-restart.cfm` (integration, Linux-only in CI) |
37+
| Config overrides (env var, custom directory) | `test-config-override.cfm` (separate CI job, needs altered env) |
38+
| 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) |
39+
| Client send/receive, lifecycle, reconnect | Integration tests here — fail in both repos' CI |
40+
41+
## Adding a test
42+
43+
**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.
44+
45+
**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).
46+
47+
**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.
48+
49+
## Running locally
50+
51+
Build the extension:
52+
53+
```bash
54+
mvn clean install
55+
```
56+
57+
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:
58+
59+
```bash
60+
curl http://localhost:8888/tests/test-websocket-info.cfm
61+
curl http://localhost:8888/tests/integration/test-lifecycle-callbacks.cfm
62+
```
63+
64+
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.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<cfscript>
2+
// Doc claim: "onFirstOpen can fire more than once per listener lifetime — each time
3+
// the channel goes from zero clients back to one, it runs again."
4+
//
5+
// Sequence: connect → disconnect (channel goes to zero) → wait → connect again.
6+
// Expect: onFirstOpen count == 2, onLastClose count >= 1.
7+
8+
writeOutput( "=== onFirstOpen re-arm after onLastClose ===" & chr( 10 ) );
9+
10+
logFile = getTempDirectory() & "ws-rearm.log";
11+
if ( fileExists( logFile ) )
12+
fileDelete( logFile );
13+
14+
try {
15+
if ( !structKeyExists( getFunctionList(), "CreateWebSocketClient" ) )
16+
throw( message="CreateWebSocketClient not available", type="TestSetupError" );
17+
18+
wsUrl = "ws://localhost:8888/ws/RearmListener";
19+
20+
// First connection — expect onFirstOpen
21+
clientA = new tests.integration.ClientListener();
22+
wsA = CreateWebSocketClient( wsUrl, clientA );
23+
sleep( 500 );
24+
25+
wsA.disconnect();
26+
sleep( 1500 ); // give onLastClose's async thread time to fire
27+
28+
// Second connection after channel drained — expect another onFirstOpen
29+
clientB = new tests.integration.ClientListener();
30+
wsB = CreateWebSocketClient( wsUrl, clientB );
31+
sleep( 500 );
32+
33+
wsB.disconnect();
34+
sleep( 1500 );
35+
36+
events = fileExists( logFile ) ? fileRead( logFile ).listToArray( chr( 10 ) ) : [];
37+
38+
writeOutput( chr( 10 ) & "=== Events logged ===" & chr( 10 ) );
39+
for ( e in events )
40+
writeOutput( " - #e#" & chr( 10 ) );
41+
42+
firstOpenCount = 0;
43+
for ( e in events ) {
44+
if ( e == "onFirstOpen" ) firstOpenCount++;
45+
}
46+
47+
errors = [];
48+
49+
// onFirstOpen firing twice is the real claim — the channel went to zero, then back to one.
50+
// NOT asserting onLastClose because it runs on an async thread and races with the
51+
// window we sample in; test-lifecycle-callbacks covers its existence.
52+
if ( firstOpenCount != 2 )
53+
arrayAppend( errors, "expected onFirstOpen to fire 2 times (once per cold start), got #firstOpenCount#" );
54+
55+
if ( arrayLen( errors ) ) {
56+
writeOutput( chr( 10 ) & "FAILED:" & chr( 10 ) );
57+
for ( err in errors )
58+
writeOutput( " - #err#" & chr( 10 ) );
59+
cfheader( statuscode=500, statustext="Test Failed" );
60+
}
61+
else {
62+
writeOutput( chr( 10 ) & "SUCCESS: onFirstOpen re-fired after channel drained" & chr( 10 ) );
63+
}
64+
}
65+
catch ( any e ) {
66+
writeOutput( "FAILED with exception:" & chr( 10 ) );
67+
writeOutput( e.stacktrace );
68+
cfheader( statuscode=500, statustext="Test Failed" );
69+
}
70+
</cfscript>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<cfscript>
2+
// Doc claim: "onOpenAsync — fires at the same time as onOpen, in parallel. Long
3+
// init work that shouldn't block the connection ack."
4+
//
5+
// Test: connect, assert both onOpen and onOpenAsync logged, on DIFFERENT threads.
6+
7+
writeOutput( "=== onOpenAsync parallel execution ===" & chr( 10 ) );
8+
9+
logFile = getTempDirectory() & "ws-onopen-async.log";
10+
if ( fileExists( logFile ) )
11+
fileDelete( logFile );
12+
13+
try {
14+
if ( !structKeyExists( getFunctionList(), "CreateWebSocketClient" ) )
15+
throw( message="CreateWebSocketClient not available", type="TestSetupError" );
16+
17+
listener = new tests.integration.ClientListener();
18+
ws = CreateWebSocketClient( "ws://localhost:8888/ws/OnOpenAsyncListener", listener );
19+
sleep( 1000 ); // generous window for async callback to complete
20+
21+
ws.disconnect();
22+
sleep( 300 );
23+
24+
events = fileExists( logFile ) ? fileRead( logFile ).listToArray( chr( 10 ) ) : [];
25+
26+
writeOutput( chr( 10 ) & "=== Events logged ===" & chr( 10 ) );
27+
for ( e in events )
28+
writeOutput( " - #e#" & chr( 10 ) );
29+
30+
onOpenThread = "";
31+
onAsyncThread = "";
32+
for ( e in events ) {
33+
if ( left( e, 14 ) == "onOpen:thread=" )
34+
onOpenThread = mid( e, 15, len( e ) );
35+
if ( left( e, 19 ) == "onOpenAsync:thread=" )
36+
onAsyncThread = mid( e, 20, len( e ) );
37+
}
38+
39+
errors = [];
40+
41+
if ( onOpenThread == "" )
42+
arrayAppend( errors, "onOpen did not fire (no log entry)" );
43+
if ( onAsyncThread == "" )
44+
arrayAppend( errors, "onOpenAsync did not fire (no log entry)" );
45+
if ( onOpenThread != "" && onOpenThread == onAsyncThread )
46+
arrayAppend( errors, "onOpenAsync ran on the SAME thread as onOpen (#onOpenThread#) — not parallel" );
47+
48+
if ( arrayLen( errors ) ) {
49+
writeOutput( chr( 10 ) & "FAILED:" & chr( 10 ) );
50+
for ( err in errors )
51+
writeOutput( " - #err#" & chr( 10 ) );
52+
cfheader( statuscode=500, statustext="Test Failed" );
53+
}
54+
else {
55+
writeOutput( chr( 10 ) & "SUCCESS: onOpen (t=#onOpenThread#) and onOpenAsync (t=#onAsyncThread#) ran on different threads" & chr( 10 ) );
56+
}
57+
}
58+
catch ( any e ) {
59+
writeOutput( "FAILED with exception:" & chr( 10 ) );
60+
writeOutput( e.stacktrace );
61+
cfheader( statuscode=500, statustext="Test Failed" );
62+
}
63+
</cfscript>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<cfscript>
2+
// Doc claim: "requestTimeout also bounds onFirstOpen and any thread spawned inside
3+
// it. A while loop in onFirstOpen will be killed once requestTimeout elapses."
4+
//
5+
// The listener runs a 15-second loop with requestTimeout=3. After ~3s the loop
6+
// should be killed; the file log should show "before-loop" and some "tick:*"
7+
// entries but NO "after-loop:" entry.
8+
9+
writeOutput( "=== requestTimeout kills onFirstOpen work ===" & chr( 10 ) );
10+
11+
logFile = getTempDirectory() & "ws-request-timeout.log";
12+
if ( fileExists( logFile ) )
13+
fileDelete( logFile );
14+
15+
try {
16+
if ( !structKeyExists( getFunctionList(), "CreateWebSocketClient" ) )
17+
throw( message="CreateWebSocketClient not available", type="TestSetupError" );
18+
19+
listener = new tests.integration.ClientListener();
20+
ws = CreateWebSocketClient( "ws://localhost:8888/ws/LongOnFirstOpenListener", listener );
21+
22+
// Wait long enough for requestTimeout (3s) to fire but less than the 15s hard cap
23+
sleep( 8000 );
24+
25+
ws.disconnect();
26+
sleep( 500 );
27+
28+
events = fileExists( logFile ) ? fileRead( logFile ).listToArray( chr( 10 ) ) : [];
29+
30+
writeOutput( chr( 10 ) & "=== Events logged (#arrayLen( events )# total) ===" & chr( 10 ) );
31+
// Dump first + last 5 so CI output stays readable
32+
for ( i = 1; i <= min( 5, arrayLen( events ) ); i++ )
33+
writeOutput( " - #events[ i ]#" & chr( 10 ) );
34+
if ( arrayLen( events ) > 10 )
35+
writeOutput( " ... (#arrayLen( events ) - 10# more)" & chr( 10 ) );
36+
for ( i = max( 6, arrayLen( events ) - 4 ); i <= arrayLen( events ); i++ )
37+
writeOutput( " - #events[ i ]#" & chr( 10 ) );
38+
39+
reachedAfter = false;
40+
sawBefore = false;
41+
for ( e in events ) {
42+
if ( left( e, 11 ) == "before-loop" ) sawBefore = true;
43+
if ( left( e, 10 ) == "after-loop" ) reachedAfter = true;
44+
}
45+
46+
errors = [];
47+
48+
if ( !sawBefore )
49+
arrayAppend( errors, "onFirstOpen never started — 'before-loop' not logged" );
50+
51+
if ( reachedAfter )
52+
arrayAppend( errors, "loop ran to completion — requestTimeout did NOT kill the long-running onFirstOpen work ('after-loop' was logged)" );
53+
54+
if ( arrayLen( errors ) ) {
55+
writeOutput( chr( 10 ) & "FAILED:" & chr( 10 ) );
56+
for ( err in errors )
57+
writeOutput( " - #err#" & chr( 10 ) );
58+
cfheader( statuscode=500, statustext="Test Failed" );
59+
}
60+
else {
61+
writeOutput( chr( 10 ) & "SUCCESS: requestTimeout killed onFirstOpen before the loop completed" & chr( 10 ) );
62+
}
63+
}
64+
catch ( any e ) {
65+
writeOutput( "FAILED with exception:" & chr( 10 ) );
66+
writeOutput( e.stacktrace );
67+
cfheader( statuscode=500, statustext="Test Failed" );
68+
}
69+
</cfscript>

0 commit comments

Comments
 (0)