Files
v-hono/tests/test_websocket_frame_encoding.v
meiseayoung 6ab6d3b99e 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
2026-01-04 13:38:12 +08:00

388 lines
11 KiB
V

// test_websocket_frame_encoding.v - Property test for WebSocket frame encoding
// Feature: websocket-helper, Property 5: Frame Encoding
// Validates: Requirements 3.1, 3.2, 7.3
//
// *For any* string data sent via ws.send(data), the resulting WebSocket frame
// SHALL have opcode 0x1 (text) and contain the exact string bytes.
// *For any* binary data sent via ws.send_bytes(data), the resulting frame
// SHALL have opcode 0x2 (binary) and contain the exact byte sequence.
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 Frame Encoding 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() string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !@#$%^&*()_+-=[]{}|;:,.<>?'
len := rand.int_in_range(0, 200) or { 50 }
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_bytes() []u8 {
len := rand.int_in_range(0, 200) or { 50 }
mut result := []u8{cap: len}
for _ in 0 .. len {
result << u8(rand.int_in_range(0, 256) or { 0 })
}
return result
}
fn generate_large_payload() []u8 {
// Generate payload between 126 and 1000 bytes to test extended length encoding
len := rand.int_in_range(126, 1000) or { 200 }
mut result := []u8{cap: len}
for _ in 0 .. len {
result << u8(rand.int_in_range(0, 256) or { 0 })
}
return result
}
// ============================================================================
// Property 5: Frame Encoding
// Feature: websocket-helper, Property 5: Frame Encoding
// Validates: Requirements 3.1, 3.2, 7.3
//
// *For any* string data sent via ws.send(data), the resulting WebSocket frame
// SHALL have opcode 0x1 (text) and contain the exact string bytes.
// ============================================================================
fn test_property_5_text_frame_encoding() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random string data
text_data := generate_random_string()
// Encode as text frame (simulating ws.send(data))
frame := encode_ws_frame(ws_opcode_text, text_data.bytes(), false)
// Decode the frame
decoded := decode_ws_frame(frame) or {
println(' Iteration ${i}: Failed to decode frame: ${err}')
return false
}
// Verify opcode is text (0x1)
if decoded.opcode != ws_opcode_text {
println(' Iteration ${i}: Expected text opcode (0x1), got 0x${decoded.opcode:02x}')
return false
}
// Verify payload contains exact string bytes
received := decoded.payload.bytestr()
if received != text_data {
println(' Iteration ${i}: Payload mismatch')
println(' Expected: ${text_data}')
println(' Got: ${received}')
return false
}
// Verify FIN bit is set (single frame message)
if !decoded.fin {
println(' Iteration ${i}: FIN bit should be set')
return false
}
}
return true
}
// ============================================================================
// Property 5: Frame Encoding (Binary)
// *For any* binary data sent via ws.send_bytes(data), the resulting frame
// SHALL have opcode 0x2 (binary) and contain the exact byte sequence.
// ============================================================================
fn test_property_5_binary_frame_encoding() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate random binary data
binary_data := generate_random_bytes()
// Encode as binary frame (simulating ws.send_bytes(data))
frame := encode_ws_frame(ws_opcode_binary, binary_data, false)
// Decode the frame
decoded := decode_ws_frame(frame) or {
println(' Iteration ${i}: Failed to decode frame: ${err}')
return false
}
// Verify opcode is binary (0x2)
if decoded.opcode != ws_opcode_binary {
println(' Iteration ${i}: Expected binary opcode (0x2), got 0x${decoded.opcode:02x}')
return false
}
// Verify payload contains exact byte sequence
if decoded.payload != binary_data {
println(' Iteration ${i}: Binary payload mismatch')
println(' Expected length: ${binary_data.len}')
println(' Got length: ${decoded.payload.len}')
return false
}
// Verify FIN bit is set
if !decoded.fin {
println(' Iteration ${i}: FIN bit should be set')
return false
}
}
return true
}
// ============================================================================
// Additional test: Extended payload length encoding (126-65535 bytes)
// ============================================================================
fn test_extended_payload_length() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. 20 {
// Generate payload that requires extended length encoding
payload := generate_large_payload()
// Encode as text frame
frame := encode_ws_frame(ws_opcode_text, payload, false)
// Decode the frame
decoded := decode_ws_frame(frame) or {
println(' Iteration ${i}: Failed to decode frame: ${err}')
return false
}
// Verify payload is preserved
if decoded.payload != payload {
println(' Iteration ${i}: Payload mismatch for extended length')
println(' Expected length: ${payload.len}')
println(' Got length: ${decoded.payload.len}')
return false
}
// Verify payload length is correctly decoded
if decoded.payload_len != u64(payload.len) {
println(' Iteration ${i}: Payload length mismatch')
println(' Expected: ${payload.len}')
println(' Got: ${decoded.payload_len}')
return false
}
}
return true
}
// ============================================================================
// Additional test: Empty payload handling
// ============================================================================
fn test_empty_payload() bool {
// Test empty text frame
empty_text := ''
text_frame := encode_ws_frame(ws_opcode_text, empty_text.bytes(), false)
decoded_text := decode_ws_frame(text_frame) or {
println(' Failed to decode empty text frame: ${err}')
return false
}
if decoded_text.payload.len != 0 {
println(' Empty text frame should have empty payload')
return false
}
// Test empty binary frame
empty_binary := []u8{}
binary_frame := encode_ws_frame(ws_opcode_binary, empty_binary, false)
decoded_binary := decode_ws_frame(binary_frame) or {
println(' Failed to decode empty binary frame: ${err}')
return false
}
if decoded_binary.payload.len != 0 {
println(' Empty binary frame should have empty payload')
return false
}
return true
}
fn main() {
println('🚀 Starting WebSocket Frame Encoding Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 5: Frame Encoding
// Validates: Requirements 3.1, 3.2, 7.3
stats.run_property_test('Property 5: Text Frame Encoding', test_property_5_text_frame_encoding)
stats.run_property_test('Property 5: Binary Frame Encoding', test_property_5_binary_frame_encoding)
stats.run_property_test('Extended Payload Length', test_extended_payload_length)
stats.run_property_test('Empty Payload Handling', test_empty_payload)
// Print test summary
stats.print_summary()
}