Skip to content

Commit 0507d0c

Browse files
authored
Add unit tests + cross-repo integration CI job (#4)
* add behavioural unit tests and cross-repo integration CI job * convert unit tests to BDD style and drop compile-time arity tests * fix RecordingListener onError signature to match 2-arg invocation * improve onError test failure message to diagnose why events are empty or wrong * drop onError-on-sync-connect test — nv-websocket-client throws without invoking listener in that path * warm up extension before integration tests to avoid 404 race on first connect
1 parent 2ce5554 commit 0507d0c

3 files changed

Lines changed: 332 additions & 12 deletions

File tree

.github/workflows/main.yml

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,189 @@ jobs:
135135
testLabels: websocketclient
136136
testAdditional: ${{ github.workspace }}/tests
137137

138+
test-integration:
139+
# Runs the integration test suite that lives in the server repo's tests/integration/
140+
# folder against this branch's freshly-built client .lex. Sparse-checkouts only the
141+
# integration subtree so we don't pull the whole server repo.
142+
runs-on: ubuntu-latest
143+
needs: [build]
144+
strategy:
145+
fail-fast: false
146+
matrix:
147+
lucee: [ "6.2/stable/jar", "7.0/stable/jar" ]
148+
steps:
149+
- uses: actions/checkout@v6
150+
151+
- name: Determine Tomcat and Java version
152+
id: config
153+
run: |
154+
MAJOR_MINOR=$(echo "${{ matrix.lucee }}" | grep -oE '^[0-9]+\.[0-9]+')
155+
case "$MAJOR_MINOR" in
156+
6.2)
157+
echo "tomcat=tomcat-9" >> $GITHUB_OUTPUT
158+
echo "java=11" >> $GITHUB_OUTPUT
159+
;;
160+
7.0|7.1)
161+
echo "tomcat=tomcat-11" >> $GITHUB_OUTPUT
162+
echo "java=21" >> $GITHUB_OUTPUT
163+
;;
164+
*)
165+
echo "ERROR: Unknown Lucee major version: $MAJOR_MINOR"; exit 1 ;;
166+
esac
167+
168+
- name: Set up JDK ${{ steps.config.outputs.java }}
169+
uses: actions/setup-java@v5
170+
with:
171+
java-version: '${{ steps.config.outputs.java }}'
172+
distribution: 'temurin'
173+
174+
- name: Download this branch's client .lex
175+
uses: actions/download-artifact@v8
176+
with:
177+
name: websocketclient-lex
178+
path: client-lex
179+
180+
- name: Sparse-checkout server tests
181+
uses: actions/checkout@v6
182+
with:
183+
repository: lucee/extension-websocket
184+
path: server-repo
185+
sparse-checkout: |
186+
tests
187+
sparse-checkout-cone-mode: false
188+
189+
- name: Download latest server extension
190+
run: |
191+
# Pull the latest snapshot — follows the same naming convention the server repo uses
192+
curl -L -o server.lex "https://ext.lucee.org/websocket-extension-3.0.0.20-SNAPSHOT.lex"
193+
ls -la server.lex
194+
195+
- name: Download Lucee Express template
196+
run: |
197+
EXPRESS_URL=$(curl -s https://update.lucee.org/rest/update/provider/expressTemplates | jq -r '.["${{ steps.config.outputs.tomcat }}"]')
198+
curl -L -o express-template.zip "$EXPRESS_URL"
199+
unzip -q express-template.zip -d lucee-express
200+
201+
- name: Download Lucee JAR
202+
id: lucee-jar
203+
run: |
204+
LUCEE_URL=$(curl -s "https://update.lucee.org/rest/update/provider/latest/${{ matrix.lucee }}/url" | tr -d '"')
205+
LUCEE_VERSION=$(basename "$LUCEE_URL" | sed 's/lucee-//;s/\.jar//')
206+
echo "LUCEE_VERSION=$LUCEE_VERSION" >> $GITHUB_OUTPUT
207+
curl -L -f -o lucee.jar "$LUCEE_URL"
208+
rm -f lucee-express/lib/lucee-*.jar
209+
cp lucee.jar lucee-express/lib/
210+
211+
- name: Install both extensions into Express
212+
run: |
213+
mkdir -p lucee-express/lucee-server/deploy
214+
cp client-lex/*.lex lucee-express/lucee-server/deploy/
215+
cp server.lex lucee-express/lucee-server/deploy/
216+
ls -la lucee-express/lucee-server/deploy/
217+
218+
- name: Copy listeners and tests from server repo
219+
run: |
220+
mkdir -p lucee-express/lucee-server/context/websockets
221+
find server-repo/tests -path '*/websockets/*.cfc' -exec cp {} lucee-express/lucee-server/context/websockets/ \;
222+
cp -r server-repo/tests lucee-express/webapps/ROOT/
223+
ls -la lucee-express/lucee-server/context/websockets/
224+
225+
- name: Configure Tomcat port
226+
run: sed -i 's/port="8080"/port="8888"/g' lucee-express/conf/server.xml
227+
228+
- name: Start Lucee Express
229+
run: |
230+
cd lucee-express && ./bin/catalina.sh start
231+
232+
- name: Wait for server
233+
run: |
234+
for i in {1..60}; do
235+
if curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/ | grep -q "200\|302\|404"; then
236+
echo "HTTP ready after $i seconds"; break
237+
fi
238+
sleep 1
239+
done
240+
241+
- name: Warm up — wait for websocket extension to finish registering endpoints
242+
run: |
243+
# The extension's Registrar runs on a background thread after Lucee starts —
244+
# if we fire integration tests before it's registered /ws/* endpoints, we get
245+
# 404 during the WebSocket upgrade. Poll test-websocket-info.cfm which returns
246+
# SUCCESS once the extension is loaded and endpoints are registered.
247+
for i in {1..30}; do
248+
RESPONSE=$(curl -s http://localhost:8888/tests/test-websocket-info.cfm)
249+
if echo "$RESPONSE" | grep -q "SUCCESS"; then
250+
echo "Extension ready after $i attempts"
251+
break
252+
fi
253+
sleep 1
254+
done
255+
256+
- name: Run integration tests
257+
id: integration-tests
258+
continue-on-error: true
259+
run: |
260+
FAILED=false
261+
for test in test-websocket-client test-lifecycle-callbacks test-return-value-send test-wsclient-broadcast test-wsclients-plural test-binary-send; do
262+
echo ""
263+
echo "=== Running ${test} ==="
264+
RESPONSE=$(curl -s -w "\n%{http_code}" http://localhost:8888/tests/integration/${test}.cfm)
265+
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
266+
BODY=$(echo "$RESPONSE" | sed '$d')
267+
echo "$BODY"
268+
echo "$BODY" > /tmp/${test}.txt
269+
if [ "$HTTP_CODE" != "200" ] || echo "$BODY" | grep -q "FAILED"; then
270+
echo "${test}: ❌ FAILED"
271+
FAILED=true
272+
else
273+
echo "${test}: ✅ PASSED"
274+
fi
275+
done
276+
if [ "$FAILED" = "true" ]; then
277+
echo "INTEGRATION_FAILED=true" >> $GITHUB_OUTPUT
278+
fi
279+
280+
- name: Dump WebSocket event logs
281+
if: always()
282+
run: |
283+
echo "=== WebSocket Test Event Logs ==="
284+
found=false
285+
for f in /tmp/ws-*-events.log; do
286+
[ -f "$f" ] || continue
287+
found=true
288+
echo ""
289+
echo "--- $f ---"
290+
cat "$f"
291+
done
292+
if [ "$found" = "false" ]; then
293+
echo "(no event logs found)"
294+
fi
295+
296+
- name: Stop Lucee Express
297+
if: always()
298+
run: cd lucee-express && ./bin/shutdown.sh || true
299+
300+
- name: Show catalina.out
301+
if: always()
302+
run: cat lucee-express/logs/catalina.out || echo "No catalina.out found"
303+
304+
- name: Upload logs
305+
if: always()
306+
uses: actions/upload-artifact@v7
307+
with:
308+
name: lucee-logs-${{ steps.lucee-jar.outputs.LUCEE_VERSION }}
309+
path: |
310+
lucee-express/logs/
311+
lucee-express/lucee-server/context/logs/
312+
313+
- name: Fail if integration tests failed
314+
if: steps.integration-tests.outputs.INTEGRATION_FAILED == 'true'
315+
run: exit 1
316+
138317
deploy:
139318
runs-on: ubuntu-latest
140-
needs: [setup, test]
141-
if: always() && needs.test.result == 'success' && github.ref == 'refs/heads/master' && !inputs.dry-run
319+
needs: [setup, test, test-integration]
320+
if: always() && needs.test.result == 'success' && needs.test-integration.result == 'success' && github.event_name == 'workflow_dispatch' && !inputs.dry-run
142321
steps:
143322
- name: Checkout repository
144323
uses: actions/checkout@v6

tests/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# WebSocket Client Extension — Tests
2+
3+
## Two test tiers
4+
5+
### Unit tests (this folder)
6+
7+
`WebSocketClientTest.cfc` extends `org.lucee.cfml.test.LuceeTestCase` and exercises the BIF's own contract — no WebSocket server required:
8+
9+
- `CreateWebSocketClient()` argument validation (arg counts, non-component listener)
10+
- Scheme handling (`ws://`, `wss://`, rejection of `http://`)
11+
- Connection error timing (refused-connection fails fast, unroutable IP hits the library's 5s timeout)
12+
- `onError( type="connect", ... )` listener invocation on connect failure
13+
- OSGi loader health
14+
15+
Runs in the `test` job via `lucee/script-runner@main` with `testLabels: websocketclient`. Fast, no network dependencies beyond loopback.
16+
17+
### Integration tests (server repo)
18+
19+
The real client⇄server coverage — `sendText`, `sendBinary`, `onMessage`, `onClose`, `disconnect`, `isOpen`, lifecycle event ordering — lives in the server extension repo:
20+
21+
[lucee/extension-websocket — `tests/integration/`](https://github.com/lucee/extension-websocket/tree/master/tests/integration)
22+
23+
Those tests use `CreateWebSocketClient` as the driver against a Lucee WebSocket server listener, so they cover both extensions end to end.
24+
25+
## Why not duplicate the integration tests here?
26+
27+
Every integration test needs both extensions running. Duplicating the suite across two repos guarantees drift. Instead:
28+
29+
- One copy lives in the server repo.
30+
- The `test-integration` job in this repo's workflow **sparse-checkouts** `tests/integration/` from the server repo, builds this branch's client `.lex`, pulls the latest server `.lex` from download.lucee.org, and runs the suite against the combined pair.
31+
- Each side's CI catches the bug on its own side — server changes fail in the server repo, client changes fail here.
32+
33+
## What fails where
34+
35+
| Bug source | Caught by |
36+
| --- | --- |
37+
| Client BIF contract (scheme handling, error shape, connection timing) | Unit tests in this repo |
38+
| Client send/receive, lifecycle, reconnect | Integration tests (cross-repo) |
39+
| Server callback firing, broadcast, state | Integration tests (server repo CI) |
40+
41+
## Adding a test
42+
43+
Needs a server to drive it? **Add it to the server repo's [`tests/integration/`](https://github.com/lucee/extension-websocket/tree/master/tests/integration).** Both CIs will pick it up.
44+
45+
No server needed (argument validation, error shape, scheme parsing)? **Add a `testXxx()` method to [`WebSocketClientTest.cfc`](WebSocketClientTest.cfc) here.**
46+
47+
## Running locally
48+
49+
Unit tests — whatever CFML test runner you use that picks up `labels="websocketclient"`. Or invoke `WebSocketClientTest.cfc` methods directly from a test script.
50+
51+
Integration tests — spin up Lucee with both extensions, drop the server listeners into `{lucee-config}/websockets/`, and `curl` the `.cfm` scripts from the server repo's `tests/integration/` folder. See [that repo's CI workflow](https://github.com/lucee/extension-websocket/blob/master/.github/workflows/main.yml) for the exact setup — it's the same one this repo's `test-integration` job uses.

tests/WebSocketClientTest.cfc

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,105 @@
11
component extends="org.lucee.cfml.test.LuceeTestCase" labels="websocketclient" {
22

3-
function testCreateWebSocketClientLoads() {
4-
try {
5-
ws = CreateWebSocketClient( "ws://localhost:9999/nope", new WebSocketListener() );
6-
fail( "Expected connection error" );
7-
}
8-
catch ( any e ) {
9-
// connection refused is expected - means the class loaded and OSGi resolved OK
10-
expect( e.message ).notToInclude( "osgi.wiring.package" );
11-
expect( e.message ).notToInclude( "Unable to resolve" );
12-
}
3+
function run() {
4+
5+
describe( "CreateWebSocketClient()", function() {
6+
7+
it( "loads without OSGi resolution errors", function() {
8+
try {
9+
CreateWebSocketClient( "ws://localhost:9999/nope", new WebSocketListener() );
10+
fail( "Expected connection error" );
11+
}
12+
catch ( any e ) {
13+
// connection refused is expected — means the class loaded and OSGi resolved OK
14+
expect( e.message ).notToInclude( "osgi.wiring.package" );
15+
expect( e.message ).notToInclude( "Unable to resolve" );
16+
}
17+
});
18+
19+
describe( "argument validation", function() {
20+
// Note: wrong arg counts are rejected by the Lucee parser at compile time —
21+
// no runtime behaviour to test.
22+
23+
it( "rejects a non-component listener", function() {
24+
try {
25+
CreateWebSocketClient( "ws://localhost:9999/nope", "not a component" );
26+
fail( "Expected cast error when listener is not a component" );
27+
}
28+
catch ( any e ) {
29+
expect( e.message & e.type ).toInclude( "omponent" );
30+
}
31+
});
32+
});
33+
34+
describe( "scheme handling", function() {
35+
36+
it( "accepts wss:// and surfaces a connection error, not a scheme error", function() {
37+
try {
38+
CreateWebSocketClient( "wss://127.0.0.1:9999/nope", new WebSocketListener() );
39+
fail( "Expected connection error for unreachable wss endpoint" );
40+
}
41+
catch ( any e ) {
42+
expect( e.message ).notToInclude( "osgi.wiring.package" );
43+
expect( e.message ).notToInclude( "Unable to resolve" );
44+
expect( e.message ).notToInclude( "MalformedURLException" );
45+
expect( e.message ).notToInclude( "unknown protocol" );
46+
}
47+
});
48+
49+
it( "rejects non-websocket schemes like http://", function() {
50+
try {
51+
CreateWebSocketClient( "http://127.0.0.1:9999/nope", new WebSocketListener() );
52+
fail( "Expected error for non-websocket scheme" );
53+
}
54+
catch ( any e ) {
55+
// Any error is acceptable — the point is it doesn't silently succeed
56+
expect( true ).toBe( true );
57+
}
58+
});
59+
});
60+
61+
describe( "connection error behaviour", function() {
62+
63+
it( "fails quickly on connection refused (RST under 5s)", function() {
64+
var start = getTickCount();
65+
try {
66+
CreateWebSocketClient( "ws://127.0.0.1:1/none", new WebSocketListener() );
67+
fail( "Expected connection refused" );
68+
}
69+
catch ( any e ) {
70+
var elapsed = getTickCount() - start;
71+
expect( elapsed ).toBeLT( 5000 );
72+
}
73+
});
74+
75+
it( "enforces the library's 5s connection timeout on unroutable IPs", function() {
76+
// 10.255.255.1 is typically non-routable; should hit the hardcoded
77+
// 5000ms library timeout rather than the OS-level TCP SYN timeout
78+
var start = getTickCount();
79+
try {
80+
CreateWebSocketClient( "ws://10.255.255.1:9999/nope", new WebSocketListener() );
81+
fail( "Expected connection timeout" );
82+
}
83+
catch ( any e ) {
84+
var elapsed = getTickCount() - start;
85+
// Generous window for CI jitter: 3-10s (5s ± slop)
86+
expect( elapsed ).toBeGT( 3000 );
87+
expect( elapsed ).toBeLT( 10000 );
88+
}
89+
});
90+
91+
// NOTE: we don't assert the listener's onError fires on sync TCP-refused
92+
// failures. nv-websocket-client throws WebSocketException directly from
93+
// the blocking connect() call without invoking onConnectError on the
94+
// adapter in that path. The adapter's onConnectError override at
95+
// CreateWebSocketClient.java fires for async/later failures (e.g.
96+
// mid-session frame errors), which aren't cheaply triggerable here.
97+
});
98+
});
99+
100+
// Round-trip tests (sendText/sendBinary/onMessage/onClose/disconnect/isOpen)
101+
// live in extension-websocket's tests/integration/ because they need a running
102+
// server. See tests/README.md for the cross-repo split.
13103
}
14104

15105
}

0 commit comments

Comments
 (0)