Add comprehensive WebSocket support for v-hono framework, inspired by Hono.js WebSocket Helper. Features: - upgrade_websocket() function for WebSocket route handlers - Event-based callbacks: on_open, on_message, on_close, on_error - WSContext with send(), send_bytes(), send_json(), close() methods - Connection state tracking (connecting, open, closing, closed) - Configurable options: ping_interval, max_message_size, timeout, protocols - Subprotocol negotiation support - Ping/pong keep-alive handling - Message size validation with 1009 close code - Integration with picoev and uSockets backends Protocol Compliance: - RFC 6455 compliant handshake with Sec-WebSocket-Accept computation - Frame encoding/decoding with proper masking - Close handshake with status codes and reasons - Text and binary message frame support Testing: - 13 property-based test files with 82 tests total - Validates handshake, frame encoding, masking, JSON round-trip - Tests context preservation, route patterns, connection state - All existing tests pass (no regressions) Files added: - websocket.v: Core WebSocket implementation - examples/websocket_demo.v: Chat room example - examples/websocket_test.html: Browser test client - tests/test_websocket_*.v: Property-based tests Files modified: - app.v: Add ws() method for WebSocket routes - middleware.v: Export WebSocket types and functions - picoev_server.v: WebSocket upgrade detection - usockets_server.v: WebSocket upgrade detection - README.md: WebSocket documentation
881 lines
22 KiB
V
881 lines
22 KiB
V
// test_websocket_lifecycle_integration.v - Integration test for WebSocket full connection lifecycle
|
|
// Feature: websocket-helper, Property 4: Event Callback Invocation
|
|
// Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5
|
|
//
|
|
// *For any* WebSocket connection lifecycle, the event callbacks SHALL be invoked in the
|
|
// correct order: `on_open` (once on connection), `on_message` (for each message), and
|
|
// `on_close` (once on disconnection), with each callback receiving a valid WSContext.
|
|
module main
|
|
|
|
import rand
|
|
import time
|
|
import net.http
|
|
|
|
const test_iterations = 100
|
|
|
|
// WebSocket magic GUID for handshake (RFC 6455)
|
|
const ws_magic_guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
|
|
// WebSocket opcodes
|
|
const ws_opcode_text = u8(0x1)
|
|
const ws_opcode_binary = u8(0x2)
|
|
const ws_opcode_close = u8(0x8)
|
|
const ws_opcode_ping = u8(0x9)
|
|
const ws_opcode_pong = u8(0xA)
|
|
|
|
// WebSocket close codes
|
|
const ws_close_normal = 1000
|
|
|
|
struct PropertyTestStats {
|
|
mut:
|
|
total_tests int
|
|
passed_tests int
|
|
failed_tests int
|
|
}
|
|
|
|
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
|
|
stats.total_tests++
|
|
print('🔬 ${test_name}... ')
|
|
|
|
if test_func() {
|
|
stats.passed_tests++
|
|
println('✅')
|
|
} else {
|
|
stats.failed_tests++
|
|
println('❌')
|
|
}
|
|
}
|
|
|
|
fn (stats PropertyTestStats) print_summary() {
|
|
println('\n=== WebSocket Lifecycle Integration Test Summary ===')
|
|
println('Total tests: ${stats.total_tests}')
|
|
println('Passed: ${stats.passed_tests}')
|
|
println('Failed: ${stats.failed_tests}')
|
|
|
|
if stats.failed_tests == 0 {
|
|
println('🎉 All property tests passed!')
|
|
} else {
|
|
println('⚠️ ${stats.failed_tests} property test(s) failed')
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Minimal type definitions for testing
|
|
// ============================================================================
|
|
|
|
enum WSReadyState {
|
|
connecting = 0
|
|
open = 1
|
|
closing = 2
|
|
closed = 3
|
|
}
|
|
|
|
struct WSMessageEvent {
|
|
pub:
|
|
data string
|
|
data_bytes []u8
|
|
is_binary bool
|
|
}
|
|
|
|
struct WSCloseEvent {
|
|
pub:
|
|
code int
|
|
reason string
|
|
was_clean bool
|
|
}
|
|
|
|
@[heap]
|
|
struct Context {
|
|
pub:
|
|
req http.Request
|
|
params map[string]string
|
|
query map[string]string
|
|
url string
|
|
path string
|
|
pub mut:
|
|
status_code int = 200
|
|
headers map[string]string
|
|
body string
|
|
store map[string]string
|
|
}
|
|
|
|
struct WSContext {
|
|
pub:
|
|
http_ctx &Context
|
|
params map[string]string
|
|
query map[string]string
|
|
store map[string]string
|
|
pub mut:
|
|
ready_state WSReadyState
|
|
protocol string
|
|
socket voidptr
|
|
send_fn fn ([]u8) ! = unsafe { nil }
|
|
close_fn fn (int, string) ! = unsafe { nil }
|
|
}
|
|
|
|
fn (ws WSContext) is_open() bool {
|
|
return ws.ready_state == .open
|
|
}
|
|
|
|
fn (ws WSContext) can_send() bool {
|
|
return ws.ready_state == .open
|
|
}
|
|
|
|
// ============================================================================
|
|
// Event Tracking for Testing
|
|
// ============================================================================
|
|
|
|
enum EventType {
|
|
on_open
|
|
on_message
|
|
on_close
|
|
on_error
|
|
}
|
|
|
|
struct EventRecord {
|
|
event_type EventType
|
|
timestamp i64
|
|
message_data string
|
|
is_binary bool
|
|
close_code int
|
|
close_reason string
|
|
error_message string
|
|
ws_valid bool // Whether WSContext was valid when callback was invoked
|
|
}
|
|
|
|
// EventTracker - Tracks all events during a WebSocket lifecycle
|
|
struct EventTracker {
|
|
mut:
|
|
events []EventRecord
|
|
}
|
|
|
|
fn (mut tracker EventTracker) record_open(ws WSContext) {
|
|
tracker.events << EventRecord{
|
|
event_type: .on_open
|
|
timestamp: time.now().unix_milli()
|
|
ws_valid: ws.http_ctx != unsafe { nil }
|
|
}
|
|
}
|
|
|
|
fn (mut tracker EventTracker) record_message(event WSMessageEvent, ws WSContext) {
|
|
tracker.events << EventRecord{
|
|
event_type: .on_message
|
|
timestamp: time.now().unix_milli()
|
|
message_data: event.data
|
|
is_binary: event.is_binary
|
|
ws_valid: ws.http_ctx != unsafe { nil }
|
|
}
|
|
}
|
|
|
|
fn (mut tracker EventTracker) record_close(event WSCloseEvent, ws WSContext) {
|
|
tracker.events << EventRecord{
|
|
event_type: .on_close
|
|
timestamp: time.now().unix_milli()
|
|
close_code: event.code
|
|
close_reason: event.reason
|
|
ws_valid: ws.http_ctx != unsafe { nil }
|
|
}
|
|
}
|
|
|
|
fn (mut tracker EventTracker) record_error(error_msg string, ws WSContext) {
|
|
tracker.events << EventRecord{
|
|
event_type: .on_error
|
|
timestamp: time.now().unix_milli()
|
|
error_message: error_msg
|
|
ws_valid: ws.http_ctx != unsafe { nil }
|
|
}
|
|
}
|
|
|
|
fn (tracker EventTracker) get_event_sequence() []EventType {
|
|
mut sequence := []EventType{}
|
|
for event in tracker.events {
|
|
sequence << event.event_type
|
|
}
|
|
return sequence
|
|
}
|
|
|
|
fn (tracker EventTracker) count_events(event_type EventType) int {
|
|
mut count := 0
|
|
for event in tracker.events {
|
|
if event.event_type == event_type {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
fn (tracker EventTracker) all_ws_contexts_valid() bool {
|
|
for event in tracker.events {
|
|
if !event.ws_valid {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ============================================================================
|
|
// WebSocket Frame Encoding/Decoding (for simulation)
|
|
// ============================================================================
|
|
|
|
struct WSFrame {
|
|
pub:
|
|
fin bool
|
|
opcode u8
|
|
masked bool
|
|
mask_key [4]u8
|
|
payload_len u64
|
|
payload []u8
|
|
}
|
|
|
|
fn encode_ws_frame(opcode u8, payload []u8, masked bool) []u8 {
|
|
payload_len := payload.len
|
|
mut frame := []u8{cap: 14 + payload_len}
|
|
|
|
frame << u8(0x80 | (opcode & 0x0F))
|
|
|
|
mut mask_bit := u8(0)
|
|
if masked {
|
|
mask_bit = 0x80
|
|
}
|
|
|
|
if payload_len <= 125 {
|
|
frame << mask_bit | u8(payload_len)
|
|
} else if payload_len <= 65535 {
|
|
frame << mask_bit | u8(126)
|
|
frame << u8(payload_len >> 8)
|
|
frame << u8(payload_len & 0xFF)
|
|
} else {
|
|
frame << mask_bit | u8(127)
|
|
for i := 7; i >= 0; i-- {
|
|
frame << u8((payload_len >> (i * 8)) & 0xFF)
|
|
}
|
|
}
|
|
|
|
if masked {
|
|
mask_key := [u8(0x12), 0x34, 0x56, 0x78]
|
|
frame << mask_key[0]
|
|
frame << mask_key[1]
|
|
frame << mask_key[2]
|
|
frame << mask_key[3]
|
|
|
|
for i, b in payload {
|
|
frame << b ^ mask_key[i % 4]
|
|
}
|
|
} else {
|
|
frame << payload
|
|
}
|
|
|
|
return frame
|
|
}
|
|
|
|
fn decode_ws_frame(data []u8) !WSFrame {
|
|
if data.len < 2 {
|
|
return error('Frame too short')
|
|
}
|
|
|
|
fin := (data[0] & 0x80) != 0
|
|
opcode := data[0] & 0x0F
|
|
masked := (data[1] & 0x80) != 0
|
|
mut payload_len := u64(data[1] & 0x7F)
|
|
mut offset := 2
|
|
|
|
if payload_len == 126 {
|
|
if data.len < 4 {
|
|
return error('Frame too short for extended length')
|
|
}
|
|
payload_len = u64(data[2]) << 8 | u64(data[3])
|
|
offset = 4
|
|
} else if payload_len == 127 {
|
|
if data.len < 10 {
|
|
return error('Frame too short for 64-bit length')
|
|
}
|
|
payload_len = 0
|
|
for i := 0; i < 8; i++ {
|
|
payload_len = (payload_len << 8) | u64(data[2 + i])
|
|
}
|
|
offset = 10
|
|
}
|
|
|
|
mut mask_key := [4]u8{}
|
|
if masked {
|
|
if data.len < offset + 4 {
|
|
return error('Frame too short for mask key')
|
|
}
|
|
mask_key[0] = data[offset]
|
|
mask_key[1] = data[offset + 1]
|
|
mask_key[2] = data[offset + 2]
|
|
mask_key[3] = data[offset + 3]
|
|
offset += 4
|
|
}
|
|
|
|
if data.len < offset + int(payload_len) {
|
|
return error('Frame too short for payload')
|
|
}
|
|
|
|
mut payload := data[offset..offset + int(payload_len)].clone()
|
|
if masked {
|
|
for i := 0; i < payload.len; i++ {
|
|
payload[i] = payload[i] ^ mask_key[i % 4]
|
|
}
|
|
}
|
|
|
|
return WSFrame{
|
|
fin: fin
|
|
opcode: opcode
|
|
masked: masked
|
|
mask_key: mask_key
|
|
payload_len: payload_len
|
|
payload: payload
|
|
}
|
|
}
|
|
|
|
fn build_close_frame(code int, reason string) []u8 {
|
|
mut payload := []u8{cap: 2 + reason.len}
|
|
payload << u8(code >> 8)
|
|
payload << u8(code & 0xFF)
|
|
if reason.len > 0 {
|
|
payload << reason.bytes()
|
|
}
|
|
return encode_ws_frame(ws_opcode_close, payload, false)
|
|
}
|
|
|
|
fn parse_close_frame(payload []u8) (int, string) {
|
|
mut code := ws_close_normal
|
|
mut reason := ''
|
|
|
|
if payload.len >= 2 {
|
|
code = int(payload[0]) << 8 | int(payload[1])
|
|
if payload.len > 2 {
|
|
reason = payload[2..].bytestr()
|
|
}
|
|
}
|
|
|
|
return code, reason
|
|
}
|
|
|
|
// ============================================================================
|
|
// Connection Lifecycle Simulator
|
|
// ============================================================================
|
|
|
|
// Simulates a complete WebSocket connection lifecycle
|
|
struct LifecycleSimulator {
|
|
mut:
|
|
tracker EventTracker
|
|
ws_ctx WSContext
|
|
http_ctx Context
|
|
is_open bool
|
|
messages []string
|
|
close_code int
|
|
close_reason string
|
|
}
|
|
|
|
fn create_lifecycle_simulator() LifecycleSimulator {
|
|
http_ctx := Context{
|
|
req: http.Request{
|
|
method: .get
|
|
url: '/ws/test'
|
|
}
|
|
params: {'room': 'test-room', 'id': '123'}
|
|
query: {'token': 'abc123'}
|
|
url: '/ws/test?token=abc123'
|
|
path: '/ws/test'
|
|
store: {'user_id': 'user-456'}
|
|
}
|
|
|
|
ws_ctx := WSContext{
|
|
http_ctx: &http_ctx
|
|
params: http_ctx.params.clone()
|
|
query: http_ctx.query.clone()
|
|
store: http_ctx.store.clone()
|
|
ready_state: .connecting
|
|
protocol: ''
|
|
socket: unsafe { nil }
|
|
}
|
|
|
|
return LifecycleSimulator{
|
|
tracker: EventTracker{}
|
|
ws_ctx: ws_ctx
|
|
http_ctx: http_ctx
|
|
is_open: false
|
|
messages: []string{}
|
|
close_code: 0
|
|
close_reason: ''
|
|
}
|
|
}
|
|
|
|
fn (mut sim LifecycleSimulator) simulate_open() {
|
|
sim.ws_ctx.ready_state = .open
|
|
sim.is_open = true
|
|
sim.tracker.record_open(sim.ws_ctx)
|
|
}
|
|
|
|
fn (mut sim LifecycleSimulator) simulate_message(data string, is_binary bool) {
|
|
if !sim.is_open {
|
|
return
|
|
}
|
|
|
|
event := WSMessageEvent{
|
|
data: data
|
|
data_bytes: data.bytes()
|
|
is_binary: is_binary
|
|
}
|
|
sim.messages << data
|
|
sim.tracker.record_message(event, sim.ws_ctx)
|
|
}
|
|
|
|
fn (mut sim LifecycleSimulator) simulate_close(code int, reason string) {
|
|
if !sim.is_open {
|
|
return
|
|
}
|
|
|
|
sim.ws_ctx.ready_state = .closing
|
|
sim.close_code = code
|
|
sim.close_reason = reason
|
|
|
|
event := WSCloseEvent{
|
|
code: code
|
|
reason: reason
|
|
was_clean: code == ws_close_normal
|
|
}
|
|
sim.tracker.record_close(event, sim.ws_ctx)
|
|
|
|
sim.ws_ctx.ready_state = .closed
|
|
sim.is_open = false
|
|
}
|
|
|
|
fn (mut sim LifecycleSimulator) simulate_error(error_msg string) {
|
|
sim.tracker.record_error(error_msg, sim.ws_ctx)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Random Data Generators
|
|
// ============================================================================
|
|
|
|
fn generate_random_string(min_len int, max_len int) string {
|
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
|
|
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
|
mut result := ''
|
|
for _ in 0 .. len {
|
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
|
result += chars[idx].ascii_str()
|
|
}
|
|
return result
|
|
}
|
|
|
|
fn generate_random_message_count() int {
|
|
return rand.int_in_range(0, 11) or { 0 } // 0 to 10 messages
|
|
}
|
|
|
|
fn generate_random_close_code() int {
|
|
codes := [1000, 1001, 1002, 1003, 1007, 1008, 1009, 1011]
|
|
idx := rand.int_in_range(0, codes.len) or { 0 }
|
|
return codes[idx]
|
|
}
|
|
|
|
// ============================================================================
|
|
// Property 4: Event Callback Invocation
|
|
// Feature: websocket-helper, Property 4: Event Callback Invocation
|
|
// Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5
|
|
// ============================================================================
|
|
|
|
// Test that on_open is called exactly once at connection start
|
|
fn test_property_4_on_open_called_once() bool {
|
|
rand.seed([u32(time.now().unix()), u32(12345)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
mut sim := create_lifecycle_simulator()
|
|
|
|
// Simulate connection open
|
|
sim.simulate_open()
|
|
|
|
// Send random number of messages
|
|
msg_count := generate_random_message_count()
|
|
for _ in 0 .. msg_count {
|
|
sim.simulate_message(generate_random_string(5, 50), false)
|
|
}
|
|
|
|
// Close connection
|
|
sim.simulate_close(generate_random_close_code(), 'test close')
|
|
|
|
// Verify on_open was called exactly once
|
|
open_count := sim.tracker.count_events(.on_open)
|
|
if open_count != 1 {
|
|
println(' Iteration ${i}: on_open should be called exactly once')
|
|
println(' Expected: 1, Got: ${open_count}')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test that on_message is called for each message
|
|
fn test_property_4_on_message_called_for_each() bool {
|
|
rand.seed([u32(time.now().unix()), u32(23456)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
mut sim := create_lifecycle_simulator()
|
|
|
|
sim.simulate_open()
|
|
|
|
// Send random number of messages
|
|
msg_count := generate_random_message_count()
|
|
for _ in 0 .. msg_count {
|
|
sim.simulate_message(generate_random_string(5, 50), false)
|
|
}
|
|
|
|
sim.simulate_close(ws_close_normal, '')
|
|
|
|
// Verify on_message was called for each message
|
|
message_count := sim.tracker.count_events(.on_message)
|
|
if message_count != msg_count {
|
|
println(' Iteration ${i}: on_message count mismatch')
|
|
println(' Expected: ${msg_count}, Got: ${message_count}')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test that on_close is called exactly once at disconnection
|
|
fn test_property_4_on_close_called_once() bool {
|
|
rand.seed([u32(time.now().unix()), u32(34567)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
mut sim := create_lifecycle_simulator()
|
|
|
|
sim.simulate_open()
|
|
|
|
msg_count := generate_random_message_count()
|
|
for _ in 0 .. msg_count {
|
|
sim.simulate_message(generate_random_string(5, 50), false)
|
|
}
|
|
|
|
sim.simulate_close(generate_random_close_code(), 'test close')
|
|
|
|
// Verify on_close was called exactly once
|
|
close_count := sim.tracker.count_events(.on_close)
|
|
if close_count != 1 {
|
|
println(' Iteration ${i}: on_close should be called exactly once')
|
|
println(' Expected: 1, Got: ${close_count}')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test that events are called in correct order: open -> messages -> close
|
|
fn test_property_4_event_order() bool {
|
|
rand.seed([u32(time.now().unix()), u32(45678)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
mut sim := create_lifecycle_simulator()
|
|
|
|
sim.simulate_open()
|
|
|
|
msg_count := generate_random_message_count()
|
|
for _ in 0 .. msg_count {
|
|
sim.simulate_message(generate_random_string(5, 50), false)
|
|
}
|
|
|
|
sim.simulate_close(ws_close_normal, '')
|
|
|
|
// Get event sequence
|
|
sequence := sim.tracker.get_event_sequence()
|
|
|
|
// Verify sequence starts with on_open
|
|
if sequence.len == 0 || sequence[0] != .on_open {
|
|
println(' Iteration ${i}: First event should be on_open')
|
|
return false
|
|
}
|
|
|
|
// Verify sequence ends with on_close
|
|
if sequence[sequence.len - 1] != .on_close {
|
|
println(' Iteration ${i}: Last event should be on_close')
|
|
return false
|
|
}
|
|
|
|
// Verify all messages are between open and close
|
|
mut found_close := false
|
|
for j := 1; j < sequence.len - 1; j++ {
|
|
if sequence[j] == .on_close {
|
|
found_close = true
|
|
}
|
|
if found_close && sequence[j] == .on_message {
|
|
println(' Iteration ${i}: on_message should not occur after on_close')
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test that all callbacks receive valid WSContext
|
|
fn test_property_4_valid_ws_context() bool {
|
|
rand.seed([u32(time.now().unix()), u32(56789)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
mut sim := create_lifecycle_simulator()
|
|
|
|
sim.simulate_open()
|
|
|
|
msg_count := generate_random_message_count()
|
|
for _ in 0 .. msg_count {
|
|
sim.simulate_message(generate_random_string(5, 50), false)
|
|
}
|
|
|
|
sim.simulate_close(ws_close_normal, '')
|
|
|
|
// Verify all callbacks received valid WSContext
|
|
if !sim.tracker.all_ws_contexts_valid() {
|
|
println(' Iteration ${i}: Some callbacks received invalid WSContext')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test message data is correctly passed to on_message callback
|
|
fn test_property_4_message_data_preserved() bool {
|
|
rand.seed([u32(time.now().unix()), u32(67890)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
mut sim := create_lifecycle_simulator()
|
|
|
|
sim.simulate_open()
|
|
|
|
// Generate and send messages
|
|
mut expected_messages := []string{}
|
|
msg_count := rand.int_in_range(1, 6) or { 1 }
|
|
for _ in 0 .. msg_count {
|
|
msg := generate_random_string(5, 50)
|
|
expected_messages << msg
|
|
sim.simulate_message(msg, false)
|
|
}
|
|
|
|
sim.simulate_close(ws_close_normal, '')
|
|
|
|
// Verify message data was preserved
|
|
mut msg_idx := 0
|
|
for event in sim.tracker.events {
|
|
if event.event_type == .on_message {
|
|
if msg_idx >= expected_messages.len {
|
|
println(' Iteration ${i}: More messages recorded than sent')
|
|
return false
|
|
}
|
|
if event.message_data != expected_messages[msg_idx] {
|
|
println(' Iteration ${i}: Message data mismatch at index ${msg_idx}')
|
|
println(' Expected: ${expected_messages[msg_idx]}')
|
|
println(' Got: ${event.message_data}')
|
|
return false
|
|
}
|
|
msg_idx++
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test close code and reason are correctly passed to on_close callback
|
|
fn test_property_4_close_data_preserved() bool {
|
|
rand.seed([u32(time.now().unix()), u32(78901)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
mut sim := create_lifecycle_simulator()
|
|
|
|
sim.simulate_open()
|
|
|
|
// Generate random close code and reason
|
|
close_code := generate_random_close_code()
|
|
close_reason := generate_random_string(5, 30)
|
|
|
|
sim.simulate_close(close_code, close_reason)
|
|
|
|
// Find close event and verify data
|
|
mut found_close := false
|
|
for event in sim.tracker.events {
|
|
if event.event_type == .on_close {
|
|
found_close = true
|
|
if event.close_code != close_code {
|
|
println(' Iteration ${i}: Close code mismatch')
|
|
println(' Expected: ${close_code}, Got: ${event.close_code}')
|
|
return false
|
|
}
|
|
if event.close_reason != close_reason {
|
|
println(' Iteration ${i}: Close reason mismatch')
|
|
println(' Expected: ${close_reason}, Got: ${event.close_reason}')
|
|
return false
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found_close {
|
|
println(' Iteration ${i}: No close event found')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test binary message handling
|
|
fn test_property_4_binary_message_handling() bool {
|
|
rand.seed([u32(time.now().unix()), u32(89012)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
mut sim := create_lifecycle_simulator()
|
|
|
|
sim.simulate_open()
|
|
|
|
// Send mix of text and binary messages
|
|
mut expected_binary_flags := []bool{}
|
|
msg_count := rand.int_in_range(1, 6) or { 1 }
|
|
for _ in 0 .. msg_count {
|
|
is_binary := rand.int_in_range(0, 2) or { 0 } == 1
|
|
expected_binary_flags << is_binary
|
|
sim.simulate_message(generate_random_string(5, 50), is_binary)
|
|
}
|
|
|
|
sim.simulate_close(ws_close_normal, '')
|
|
|
|
// Verify binary flags were preserved
|
|
mut msg_idx := 0
|
|
for event in sim.tracker.events {
|
|
if event.event_type == .on_message {
|
|
if msg_idx >= expected_binary_flags.len {
|
|
println(' Iteration ${i}: More messages recorded than sent')
|
|
return false
|
|
}
|
|
if event.is_binary != expected_binary_flags[msg_idx] {
|
|
println(' Iteration ${i}: Binary flag mismatch at index ${msg_idx}')
|
|
println(' Expected: ${expected_binary_flags[msg_idx]}, Got: ${event.is_binary}')
|
|
return false
|
|
}
|
|
msg_idx++
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test complete lifecycle with all event types
|
|
fn test_property_4_complete_lifecycle() bool {
|
|
rand.seed([u32(time.now().unix()), u32(90123)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
mut sim := create_lifecycle_simulator()
|
|
|
|
// Full lifecycle: open -> messages -> close
|
|
sim.simulate_open()
|
|
|
|
msg_count := generate_random_message_count()
|
|
for _ in 0 .. msg_count {
|
|
sim.simulate_message(generate_random_string(5, 50), false)
|
|
}
|
|
|
|
sim.simulate_close(ws_close_normal, 'Normal closure')
|
|
|
|
// Verify complete lifecycle
|
|
sequence := sim.tracker.get_event_sequence()
|
|
|
|
// Should have: 1 open + N messages + 1 close
|
|
expected_len := 1 + msg_count + 1
|
|
if sequence.len != expected_len {
|
|
println(' Iteration ${i}: Event count mismatch')
|
|
println(' Expected: ${expected_len}, Got: ${sequence.len}')
|
|
return false
|
|
}
|
|
|
|
// Verify structure
|
|
if sequence[0] != .on_open {
|
|
println(' Iteration ${i}: First event should be on_open')
|
|
return false
|
|
}
|
|
|
|
for j := 1; j < sequence.len - 1; j++ {
|
|
if sequence[j] != .on_message {
|
|
println(' Iteration ${i}: Middle events should be on_message')
|
|
return false
|
|
}
|
|
}
|
|
|
|
if sequence[sequence.len - 1] != .on_close {
|
|
println(' Iteration ${i}: Last event should be on_close')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test error callback invocation
|
|
fn test_property_4_error_callback() bool {
|
|
rand.seed([u32(time.now().unix()), u32(11111)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
mut sim := create_lifecycle_simulator()
|
|
|
|
sim.simulate_open()
|
|
|
|
// Simulate an error
|
|
error_msg := 'Test error: ${generate_random_string(10, 30)}'
|
|
sim.simulate_error(error_msg)
|
|
|
|
sim.simulate_close(1011, 'Internal error')
|
|
|
|
// Verify error was recorded
|
|
error_count := sim.tracker.count_events(.on_error)
|
|
if error_count != 1 {
|
|
println(' Iteration ${i}: on_error should be called once')
|
|
println(' Expected: 1, Got: ${error_count}')
|
|
return false
|
|
}
|
|
|
|
// Verify error message was preserved
|
|
for event in sim.tracker.events {
|
|
if event.event_type == .on_error {
|
|
if event.error_message != error_msg {
|
|
println(' Iteration ${i}: Error message mismatch')
|
|
println(' Expected: ${error_msg}')
|
|
println(' Got: ${event.error_message}')
|
|
return false
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
fn main() {
|
|
println('🚀 Starting WebSocket Lifecycle Integration Tests...')
|
|
println('Feature: websocket-helper, Property 4: Event Callback Invocation')
|
|
println('Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5')
|
|
println('Each property test runs ${test_iterations} iterations\n')
|
|
|
|
mut stats := PropertyTestStats{}
|
|
|
|
// Run property tests
|
|
stats.run_property_test('Property 4.1: on_open Called Once', test_property_4_on_open_called_once)
|
|
stats.run_property_test('Property 4.2: on_message Called For Each', test_property_4_on_message_called_for_each)
|
|
stats.run_property_test('Property 4.3: on_close Called Once', test_property_4_on_close_called_once)
|
|
stats.run_property_test('Property 4.4: Event Order (open -> messages -> close)', test_property_4_event_order)
|
|
stats.run_property_test('Property 4.5: Valid WSContext in All Callbacks', test_property_4_valid_ws_context)
|
|
stats.run_property_test('Property 4.6: Message Data Preserved', test_property_4_message_data_preserved)
|
|
stats.run_property_test('Property 4.7: Close Data Preserved', test_property_4_close_data_preserved)
|
|
stats.run_property_test('Property 4.8: Binary Message Handling', test_property_4_binary_message_handling)
|
|
stats.run_property_test('Property 4.9: Complete Lifecycle', test_property_4_complete_lifecycle)
|
|
stats.run_property_test('Property 4.10: Error Callback', test_property_4_error_callback)
|
|
|
|
// Print test summary
|
|
stats.print_summary()
|
|
}
|