feat(websocket): add WebSocket Helper with RFC 6455 compliance
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
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
// test_websocket_json_roundtrip.v - Property test for WebSocket JSON round-trip
|
||||
// Feature: websocket-helper, Property 13: JSON Round-Trip
|
||||
// Validates: Requirements 8.1, 8.2, 8.3
|
||||
//
|
||||
// This test validates that JSON data sent through WebSocket frames
|
||||
// is preserved correctly through the encode/decode cycle.
|
||||
module main
|
||||
|
||||
import rand
|
||||
import time
|
||||
|
||||
const test_iterations = 100
|
||||
|
||||
// WebSocket opcodes (RFC 6455)
|
||||
const ws_opcode_text = u8(0x1)
|
||||
const ws_opcode_binary = u8(0x2)
|
||||
|
||||
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 JSON Round-Trip Property 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')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Frame Encoding/Decoding (copied from websocket.v for testing)
|
||||
// ============================================================================
|
||||
|
||||
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}
|
||||
|
||||
// First byte: FIN bit (1) + RSV (000) + opcode (4 bits)
|
||||
frame << u8(0x80 | (opcode & 0x0F))
|
||||
|
||||
// Second byte: MASK bit + payload length
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Add masking key and masked payload if masked
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn generate_random_string_value() string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
len := rand.int_in_range(1, 30) or { 10 }
|
||||
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_int() int {
|
||||
return rand.int_in_range(-10000, 10000) or { 0 }
|
||||
}
|
||||
|
||||
fn generate_random_bool() bool {
|
||||
return rand.int_in_range(0, 2) or { 0 } == 1
|
||||
}
|
||||
|
||||
fn generate_random_json() string {
|
||||
key := generate_random_string_value()
|
||||
value_type := rand.int_in_range(0, 4) or { 0 }
|
||||
|
||||
match value_type {
|
||||
0 {
|
||||
str_val := generate_random_string_value()
|
||||
return '{"${key}":"${str_val}"}'
|
||||
}
|
||||
1 {
|
||||
int_val := generate_random_int()
|
||||
return '{"${key}":${int_val}}'
|
||||
}
|
||||
2 {
|
||||
bool_val := generate_random_bool()
|
||||
return '{"${key}":${bool_val}}'
|
||||
}
|
||||
3 {
|
||||
return '{"${key}":null}'
|
||||
}
|
||||
else {
|
||||
return '{"test":"value"}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_nested_json() string {
|
||||
key1 := generate_random_string_value()
|
||||
key2 := generate_random_string_value()
|
||||
str_val := generate_random_string_value()
|
||||
int_val := generate_random_int()
|
||||
bool_val := generate_random_bool()
|
||||
|
||||
return '{"${key1}":{"${key2}":"${str_val}","num":${int_val},"flag":${bool_val}}}'
|
||||
}
|
||||
|
||||
fn generate_json_array() string {
|
||||
mut items := []string{}
|
||||
count := rand.int_in_range(1, 5) or { 2 }
|
||||
|
||||
for _ in 0 .. count {
|
||||
item_type := rand.int_in_range(0, 3) or { 0 }
|
||||
match item_type {
|
||||
0 {
|
||||
items << '"${generate_random_string_value()}"'
|
||||
}
|
||||
1 {
|
||||
items << '${generate_random_int()}'
|
||||
}
|
||||
2 {
|
||||
items << '${generate_random_bool()}'
|
||||
}
|
||||
else {
|
||||
items << 'null'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '[${items.join(",")}]'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 13: JSON Round-Trip
|
||||
// Feature: websocket-helper, Property 13: JSON Round-Trip
|
||||
// Validates: Requirements 8.1, 8.2, 8.3
|
||||
//
|
||||
// *For any* valid JSON string, calling ws.send_json(data) SHALL send the data
|
||||
// as a text frame, and parsing the received frame content as JSON SHALL
|
||||
// produce an equivalent value.
|
||||
// ============================================================================
|
||||
fn test_property_13_json_roundtrip() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate random JSON data
|
||||
json_type := rand.int_in_range(0, 3) or { 0 }
|
||||
json_data := match json_type {
|
||||
0 { generate_random_json() }
|
||||
1 { generate_nested_json() }
|
||||
2 { generate_json_array() }
|
||||
else { '{"test":"value"}' }
|
||||
}
|
||||
|
||||
// Encode the JSON as a WebSocket text frame (simulating send_json)
|
||||
frame := encode_ws_frame(ws_opcode_text, json_data.bytes(), false)
|
||||
|
||||
// Decode the frame
|
||||
decoded := decode_ws_frame(frame) or {
|
||||
println(' Iteration ${i}: Failed to decode frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify it's a text frame
|
||||
if decoded.opcode != ws_opcode_text {
|
||||
println(' Iteration ${i}: Expected text opcode (0x1), got 0x${decoded.opcode:02x}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract the payload as string
|
||||
received_json := decoded.payload.bytestr()
|
||||
|
||||
// Verify the JSON content is preserved exactly
|
||||
if received_json != json_data {
|
||||
println(' Iteration ${i}: JSON mismatch')
|
||||
println(' Expected: ${json_data}')
|
||||
println(' Got: ${received_json}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional test: JSON with special characters
|
||||
// ============================================================================
|
||||
fn test_json_special_chars() bool {
|
||||
test_cases := [
|
||||
'{"msg":"hello\\nworld"}',
|
||||
'{"msg":"tab\\there"}',
|
||||
'{"msg":"quote\\"test\\""}',
|
||||
'{"empty":""}',
|
||||
'{"unicode":"test"}',
|
||||
'[]',
|
||||
'{}',
|
||||
'{"a":1,"b":2,"c":3}',
|
||||
]
|
||||
|
||||
for i, json_data in test_cases {
|
||||
frame := encode_ws_frame(ws_opcode_text, json_data.bytes(), false)
|
||||
|
||||
decoded := decode_ws_frame(frame) or {
|
||||
println(' Test case ${i}: Failed to decode frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
received := decoded.payload.bytestr()
|
||||
if received != json_data {
|
||||
println(' Test case ${i}: Mismatch')
|
||||
println(' Expected: ${json_data}')
|
||||
println(' Got: ${received}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional test: Verify JSON uses text opcode
|
||||
// ============================================================================
|
||||
fn test_json_uses_text_opcode() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(54321)])
|
||||
|
||||
for _ in 0 .. test_iterations {
|
||||
json_data := generate_random_json()
|
||||
|
||||
// Encode as text frame (what send_json should do)
|
||||
text_frame := encode_ws_frame(ws_opcode_text, json_data.bytes(), false)
|
||||
|
||||
// Decode and verify opcode
|
||||
decoded := decode_ws_frame(text_frame) or {
|
||||
println(' Failed to decode frame')
|
||||
return false
|
||||
}
|
||||
|
||||
if decoded.opcode != ws_opcode_text {
|
||||
println(' JSON should be sent as text frame (opcode 0x1), got 0x${decoded.opcode:02x}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket JSON Round-Trip Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 13: JSON Round-Trip
|
||||
// Validates: Requirements 8.1, 8.2, 8.3
|
||||
stats.run_property_test('Property 13: JSON Round-Trip', test_property_13_json_roundtrip)
|
||||
stats.run_property_test('JSON Special Characters', test_json_special_chars)
|
||||
stats.run_property_test('JSON Uses Text Opcode', test_json_uses_text_opcode)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
Reference in New Issue
Block a user