Compare commits

..
8 Commits
Author SHA1 Message Date
meiseayoung 26ac804b1d chore: update v.mod version 2026-01-05 11:07:41 +08:00
meiseayoung 56299b5485 refactor(usockets): reorganize lib directory structure for multi-platform support
- Organize lib directory by platform: windows/, macos-arm64/, macos-x64/, linux/
- Update build.sh to support unified build process for Windows/macOS/Linux
- Compile libuv + uSockets and merge into complete static library
- Update usockets.v conditional compilation to point to new directory structure
- Optimize benchmark tests with connection pooling for high concurrency testing
- Clean up unused legacy library files

Performance test results (Windows):
- 1000 concurrent, 1M requests: 35,223 req/s
- 10000 concurrent, 1M requests: 26,884 req/s
2026-01-05 11:06:56 +08:00
meiseayoung d31492fd59 chore: update v.mod version 2026-01-04 18:12:21 +08:00
meiseayoung e19f799e46 chore: upload usokets lib 2026-01-04 18:11:19 +08:00
meiseayoung f7e753969d fix: change the usockets/lib path 2026-01-04 15:44:54 +08:00
meiseayoung e9d14a35f6 fix(streaming): use direct socket fd writes for real-time SSE streaming
Previously, PicoevStreamWriter used picohttpparser.Response.raw() to write
streaming data, which only buffered the data until the entire request handler
completed. This caused SSE responses to be delayed until all sleep() calls
finished (e.g., 2.89s delay for /stream-text endpoint).

Changes:
- Modified PicoevStreamWriter to use socket fd directly from picohttpparser.Response
- Implemented write_to_fd() using C.send() for immediate data delivery
- Updated handle_picoev_streaming_response() to write HTTP headers directly to socket
- Added C.send() function declaration for direct socket writes

This fix enables true real-time streaming where each write() or write_sse()
call immediately sends data to the client, rather than buffering until
the callback completes.

All 74 test files pass with no regressions.
2026-01-04 15:43:01 +08:00
meiseayoung 9ae9170bcb feat: add SSE streaming helper support
Add Server-Sent Events (SSE) and streaming response support with three
streaming functions:

- stream(): Basic binary streaming with chunked transfer encoding
- stream_text(): Text streaming with proper Content-Type headers
- stream_sse(): SSE event streaming with full event field support

Features:
- SSEEvent struct with data, event, id, retry fields
- StreamContext for write, writeln, write_sse, sleep, pipe operations
- Multi-line data handling in SSE events
- Custom error handler support
- Client disconnect detection via on_abort callback
- Integrated with both Picoev and uSockets backends

New files:
- streaming.v: Core streaming implementation
- examples/sse_demo.v: Comprehensive SSE demo with test page
- tests/test_stream_*.v: 62 property-based tests

Updated:
- picoev_server.v: PicoevStreamWriter integration
- usockets_server.v: UsocketsStreamWriter integration
- README.md: SSE streaming documentation
2026-01-04 15:13:28 +08:00
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
36 changed files with 14741 additions and 68 deletions
+2 -2
View File
@@ -2,7 +2,6 @@
*.exe
*.out
*.o
*.a
*.so
*.dll
@@ -46,4 +45,5 @@ temp/
# Chunk upload temp files
chunks/
.kiro
.kiro
*.def
+416 -2
View File
@@ -9,15 +9,17 @@ A high-performance V language web framework inspired by [Hono.js](https://hono.d
- 🔧 **Middleware Support** - Flexible middleware system with onion model
- 🌐 **CORS** - Built-in CORS middleware with full configuration
- 🍪 **Cookie Helper** - Easy cookie management with signed cookie support
- � **JWTe Authentication** - JWT middleware with HS256/384/512 support
- 🔐 **JWT Authentication** - JWT middleware with HS256/384/512 support
- 🎫 **Bearer Auth** - Simple Bearer token authentication
- � **Cotmpression** - Gzip and deflate response compression
- 📦 **Compression** - Gzip and deflate response compression
- ⏱️ **Rate Limiting** - Request rate limiting with customizable stores
- ✅ **Validation** - Schema-based request validation
- 📁 **Static File Serving** - Built-in static file server with caching
- 🛡️ **Security** - Path validation and security utilities
- 📤 **File Upload** - Chunked file upload support
- 🗄️ **Database** - SQLite integration for data persistence
- 🔌 **WebSocket** - RFC 6455 compliant WebSocket support with event-based API
- 📡 **SSE Streaming** - Server-Sent Events and streaming response support
## Installation
@@ -620,6 +622,414 @@ fn main() {
}
```
### 8. WebSocket Helper
RFC 6455 compliant WebSocket support for real-time bidirectional communication.
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
// Basic WebSocket endpoint
app.get('/ws', hono.upgrade_websocket(fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
println('Client connected')
ws.send('Welcome to the WebSocket server!') or {}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
println('Received: ${event.data}')
// Echo the message back
ws.send('Echo: ${event.data}') or {}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('Client disconnected: ${event.code} - ${event.reason}')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('WebSocket error: ${error}')
}
}
}))
app.listen(':3000')
}
```
#### WebSocket with Configuration Options
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
// WebSocket with custom options
app.get('/ws/chat', hono.upgrade_websocket(
fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
ws.send('Received: ${event.data}') or {}
}
}
},
hono.WebSocketOptions{
ping_interval: 30000 // Send ping every 30 seconds
max_message_size: 1048576 // Max 1MB message size
timeout: 60000 // 60 second timeout
protocols: ['chat', 'json'] // Supported subprotocols
}
))
app.listen(':3000')
}
```
#### WebSocket with Route Parameters
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
// WebSocket with route parameters
app.get('/ws/room/:room_id', hono.upgrade_websocket(fn (c hono.Context) hono.WSEvents {
// Access route parameters from HTTP context
room_id := c.params['room_id'] or { 'default' }
return hono.WSEvents{
on_open: fn [room_id] (mut ws hono.WSContext) {
ws.send('Joined room: ${room_id}') or {}
}
on_message: fn [room_id] (event hono.WSMessageEvent, mut ws hono.WSContext) {
ws.send('[${room_id}] ${event.data}') or {}
}
}
}))
app.listen(':3000')
}
```
#### Sending Different Message Types
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
app.get('/ws', hono.upgrade_websocket(fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
// Send text message
ws.send('Hello, World!') or {}
// Send binary data
ws.send_bytes([u8(0x01), 0x02, 0x03]) or {}
// Send JSON data
ws.send_json('{"type": "message", "content": "Hello"}') or {}
// Close connection gracefully
// ws.close(1000, 'Normal closure') or {}
}
}
}))
app.listen(':3000')
}
```
#### WebSocket Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `ping_interval` | `int` | `30000` | Ping interval in milliseconds (0 to disable) |
| `max_message_size` | `int` | `1048576` | Maximum message size in bytes (1MB) |
| `timeout` | `int` | `60000` | Connection timeout in milliseconds |
| `protocols` | `[]string` | `[]` | Supported WebSocket subprotocols |
#### WSContext Methods
| Method | Description |
|--------|-------------|
| `send(data string)` | Send text message |
| `send_bytes(data []u8)` | Send binary message |
| `send_json(data string)` | Send JSON data as text frame |
| `close(code int, reason string)` | Initiate graceful close |
| `get_context()` | Get original HTTP context |
#### WSContext Properties
| Property | Type | Description |
|----------|------|-------------|
| `ready_state` | `WSReadyState` | Connection state (connecting, open, closing, closed) |
| `protocol` | `string` | Negotiated subprotocol |
| `params` | `map[string]string` | Route parameters |
| `query` | `map[string]string` | Query parameters |
| `store` | `map[string]string` | Middleware store values |
#### WebSocket Close Codes
| Code | Constant | Description |
|------|----------|-------------|
| 1000 | `ws_close_normal` | Normal closure |
| 1001 | `ws_close_going_away` | Server shutting down |
| 1002 | `ws_close_protocol_error` | Protocol error |
| 1003 | `ws_close_unsupported_data` | Unsupported data type |
| 1007 | `ws_close_invalid_payload` | Invalid payload data |
| 1008 | `ws_close_policy_violation` | Policy violation |
| 1009 | `ws_close_message_too_big` | Message too big |
| 1011 | `ws_close_internal_error` | Internal server error |
### 9. SSE Streaming Helper
Server-Sent Events (SSE) and streaming response support for real-time data push.
#### Basic Binary Streaming
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
// Basic stream - binary data streaming
// Sets Transfer-Encoding: chunked header
app.get('/stream', fn (mut c hono.Context) http.Response {
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback for client disconnection
stream.on_abort(fn () {
println('Client disconnected')
})
// Stream binary data in chunks
for i in 0 .. 5 {
data := 'Chunk ${i + 1}: Hello from stream!\n'
stream.write(data.bytes())!
stream.sleep(500) // 500ms delay between chunks
}
})
})
app.listen(':3000')
}
```
#### Text Streaming
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
// Text stream - for streaming text content
// Sets Content-Type: text/plain; charset=utf-8
// Sets Transfer-Encoding: chunked
// Sets X-Content-Type-Options: nosniff
app.get('/stream-text', fn (mut c hono.Context) http.Response {
return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
stream.writeln('=== Text Streaming Demo ===')!
stream.sleep(300)
for i in 0 .. 5 {
stream.write_string('Processing item ${i + 1}... ')!
stream.sleep(200)
stream.writeln('Done!')!
}
})
})
app.listen(':3000')
}
```
#### Server-Sent Events (SSE)
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
// SSE stream - for Server-Sent Events
// Sets Content-Type: text/event-stream
// Sets Cache-Control: no-cache
// Sets Connection: keep-alive
app.get('/sse', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send initial connection event
stream.write_sse(hono.SSEEvent{
data: 'Connected to SSE stream'
event: 'connect'
id: '0'
})!
// Send periodic updates
for i in 1 .. 6 {
stream.sleep(1000) // 1 second delay
stream.write_sse(hono.SSEEvent{
data: 'Update ${i}'
event: 'update'
id: '${i}'
})!
}
// Send completion event
stream.write_sse(hono.SSEEvent{
data: 'Stream completed'
event: 'complete'
id: '999'
})!
})
})
app.listen(':3000')
}
```
#### SSE with Multi-line Data
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
app.get('/sse-json', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send JSON data (multi-line formatted)
json_data := '{\n "name": "v-hono",\n "version": "1.0.0"\n}'
stream.write_sse(hono.SSEEvent{
data: json_data
event: 'json'
id: '1'
})!
})
})
app.listen(':3000')
}
```
#### SSE with Error Handling
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
app.get('/sse-error', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
stream.write_sse(hono.SSEEvent{
data: 'Starting...'
event: 'start'
})!
// Simulate an error
return error('Something went wrong')
}, fn (err IError, mut stream hono.StreamContext) {
// Custom error handler
stream.write_sse(hono.SSEEvent{
data: 'Error: ${err.msg()}'
event: 'error'
}) or {}
})
})
app.listen(':3000')
}
```
#### SSE with Retry Field
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
app.get('/sse-retry', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send event with retry field (client reconnects after 3 seconds)
stream.write_sse(hono.SSEEvent{
data: 'This event includes a retry field'
event: 'message'
id: '1'
retry: 3000 // 3 seconds
})!
})
})
app.listen(':3000')
}
```
#### StreamContext Methods
| Method | Description |
|--------|-------------|
| `write(data []u8)` | Write raw bytes to stream |
| `write_string(data string)` | Write string to stream |
| `writeln(data string)` | Write string with newline |
| `write_sse(event SSEEvent)` | Write SSE event |
| `sleep(ms int)` | Pause execution for milliseconds |
| `pipe(data []u8)` | Pipe data to stream |
| `on_abort(callback fn())` | Register abort callback |
| `close()` | Close the stream |
| `is_open()` | Check if stream is still open |
#### SSEEvent Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `data` | `string` | Yes | Event data (supports multi-line) |
| `event` | `string` | No | Event type name |
| `id` | `string` | No | Event ID for client reconnection |
| `retry` | `int` | No | Reconnection interval in milliseconds |
#### Streaming Functions
| Function | Headers Set | Use Case |
|----------|-------------|----------|
| `c_stream()` | `Transfer-Encoding: chunked` | Binary data streaming |
| `c_stream_text()` | `Content-Type: text/plain`, `Transfer-Encoding: chunked`, `X-Content-Type-Options: nosniff` | Text streaming |
| `c_stream_sse()` | `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` | Server-Sent Events |
#### Client-Side JavaScript Example
```html
<script>
// Connect to SSE endpoint
const eventSource = new EventSource('/sse');
// Listen for specific event types
eventSource.addEventListener('connect', (e) => {
console.log('Connected:', e.data);
});
eventSource.addEventListener('update', (e) => {
console.log('Update:', e.data, 'ID:', e.lastEventId);
});
eventSource.addEventListener('complete', (e) => {
console.log('Complete:', e.data);
eventSource.close();
});
// Handle errors
eventSource.onerror = (e) => {
console.error('SSE Error:', e);
};
</script>
```
## Route Grouping
```v
@@ -724,6 +1134,10 @@ v-hono/
├── compress.v # Compression middleware
├── rate_limit.v # Rate limiting middleware
├── validator.v # Request validation
├── websocket.v # WebSocket helper (RFC 6455)
├── streaming.v # SSE streaming helper
├── picoev_server.v # Picoev backend with WebSocket/SSE support
├── usockets_server.v # uSockets backend with WebSocket/SSE support
├── v.mod # Module definition
├── README.md # Documentation
├── examples/ # Example applications
+29
View File
@@ -175,6 +175,35 @@ pub fn (mut app Hono) options(path string, handler fn (mut Context) http.Respons
app.context_trie_router.add_route('OPTIONS', path, h)
}
// ws() - Register a WebSocket route
// This method registers a WebSocket upgrade handler at the specified path.
// The factory function receives the HTTP Context and returns WSEvents configuration.
//
// Parameters:
// path: The route path (supports path parameters like /ws/:room/:id)
// factory: A function that receives the HTTP Context and returns WSEvents
// options: Optional WebSocket configuration
//
// Example:
// app.ws('/chat/:room', fn (c Context) WSEvents {
// room := c.params['room'] or { 'default' }
// return WSEvents{
// on_open: fn (mut ws WSContext) {
// ws.send('Welcome to room!') or {}
// }
// on_message: fn (event WSMessageEvent, mut ws WSContext) {
// ws.send('Echo: ${event.data}') or {}
// }
// }
// })
pub fn (mut app Hono) ws(path string, factory WSHandlerFactory, options ...WebSocketOptions) {
// Create the WebSocket upgrade handler
ws_handler := upgrade_websocket(factory, ...options)
// Register as a GET route (WebSocket upgrades use GET method)
app.get(path, ws_handler)
}
// all() 方法 - 为所有 HTTP 方法注册同一个处理器
pub fn (mut app Hono) all(path string, handler fn (mut Context) http.Response) {
app.get(path, handler)
+40 -12
View File
@@ -448,21 +448,49 @@ func checkKeepAlive(client *http.Client) bool {
func checkThroughput(client *http.Client) float64 {
start := time.Now()
success := 0
requests := 200
concurrency := 1000
totalRequests := 1000000
requestsPerWorker := totalRequests / concurrency
for i := 0; i < requests; i++ {
resp, err := client.Get(baseURL + "/health")
if err != nil {
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode == 200 {
success++
}
// 创建共享的 Transport,启用连接池复用
transport := &http.Transport{
MaxIdleConns: concurrency,
MaxIdleConnsPerHost: concurrency,
IdleConnTimeout: 30 * time.Second,
DisableKeepAlives: false,
}
results := make(chan int, concurrency)
for i := 0; i < concurrency; i++ {
go func() {
localSuccess := 0
// 每个 goroutine 使用独立 Client,但共享 Transport(连接池)
localClient := &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
}
for j := 0; j < requestsPerWorker; j++ {
resp, err := localClient.Get(baseURL + "/health")
if err != nil {
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode == 200 {
localSuccess++
}
}
results <- localSuccess
}()
}
success := 0
for i := 0; i < concurrency; i++ {
success += <-results
}
elapsed := time.Since(start).Seconds()
fmt.Printf("\n [并发: %d, 总请求: %d, 成功: %d, 耗时: %.2fs]\n ", concurrency, totalRequests, success, elapsed)
return float64(success) / elapsed
}
+554
View File
@@ -0,0 +1,554 @@
// SSE Streaming Demo - Server-Sent Events Example for v-hono
// This example demonstrates all SSE streaming helper functions:
// - stream() - Basic binary streaming
// - stream_text() - Text streaming
// - stream_sse() - Server-Sent Events streaming
//
// Run with: v -enable-globals run examples/sse_demo.v
// Then open a browser and navigate to http://127.0.0.1:3000
// Or use curl to test the endpoints:
// curl http://127.0.0.1:3000/stream
// curl http://127.0.0.1:3000/stream-text
// curl http://127.0.0.1:3000/sse
//
// Features demonstrated:
// - Basic binary streaming with stream()
// - Text streaming with stream_text()
// - SSE event streaming with stream_sse()
// - Error handling with custom error handlers
// - SSE event fields (data, event, id, retry)
// - Multi-line data in SSE events
// - Sleep/delay between stream writes
// - onAbort callback for client disconnection
module main
import net.http
import os
import hono
fn main() {
mut app := hono.Hono.new()
// =========================================================================
// Static HTML page for testing SSE
// =========================================================================
app.get('/', fn (mut c hono.Context) http.Response {
return c.html(get_sse_test_page())
})
// =========================================================================
// Basic Binary Streaming Example
// =========================================================================
// Basic stream - demonstrates binary data streaming
// Sets Transfer-Encoding: chunked header
app.get('/stream', fn (mut c hono.Context) http.Response {
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback for client disconnection
stream.on_abort(fn () {
println('[Stream] Client disconnected')
})
// Stream binary data in chunks
for i in 0 .. 5 {
data := 'Chunk ${i + 1}: Hello from binary stream!\n'
stream.write(data.bytes())!
stream.sleep(500) // 500ms delay between chunks
}
stream.write('Stream complete!\n'.bytes())!
})
})
// =========================================================================
// Text Streaming Example
// =========================================================================
// Text stream - demonstrates text data streaming
// Sets Content-Type: text/plain; charset=utf-8
// Sets Transfer-Encoding: chunked
// Sets X-Content-Type-Options: nosniff
app.get('/stream-text', fn (mut c hono.Context) http.Response {
return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback
stream.on_abort(fn () {
println('[StreamText] Client disconnected')
})
// Stream text data with writeln (adds newline automatically)
stream.writeln('=== Text Streaming Demo ===')!
stream.sleep(300)
for i in 0 .. 5 {
stream.write_string('Processing item ${i + 1}... ')!
stream.sleep(200)
stream.writeln('Done!')!
stream.sleep(300)
}
stream.writeln('')!
stream.writeln('All items processed successfully!')!
})
})
// =========================================================================
// SSE (Server-Sent Events) Streaming Example
// =========================================================================
// SSE stream - demonstrates Server-Sent Events
// Sets Content-Type: text/event-stream
// Sets Cache-Control: no-cache
// Sets Connection: keep-alive
app.get('/sse', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback
stream.on_abort(fn () {
println('[SSE] Client disconnected')
})
// Send initial connection event
stream.write_sse(hono.SSEEvent{
data: 'Connected to SSE stream'
event: 'connect'
id: '0'
})!
// Send periodic updates
for i in 1 .. 6 {
stream.sleep(1000) // 1 second delay
stream.write_sse(hono.SSEEvent{
data: 'Update ${i}: Current time is ${get_timestamp()}'
event: 'update'
id: '${i}'
})!
}
// Send completion event
stream.write_sse(hono.SSEEvent{
data: 'Stream completed'
event: 'complete'
id: '999'
})!
})
})
// =========================================================================
// SSE with Multi-line Data Example
// =========================================================================
// SSE with multi-line data - demonstrates multi-line data handling
app.get('/sse-multiline', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send event with multi-line data
stream.write_sse(hono.SSEEvent{
data: 'Line 1: First line of data\nLine 2: Second line of data\nLine 3: Third line of data'
event: 'multiline'
id: '1'
})!
stream.sleep(1000)
// Send JSON data (multi-line formatted)
json_data := '{\n "name": "v-hono",\n "version": "1.0.0",\n "features": ["SSE", "WebSocket", "Streaming"]\n}'
stream.write_sse(hono.SSEEvent{
data: json_data
event: 'json'
id: '2'
})!
})
})
// =========================================================================
// SSE with Retry Field Example
// =========================================================================
// SSE with retry - demonstrates retry field for reconnection
app.get('/sse-retry', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send event with retry field (tells client to reconnect after 3 seconds)
stream.write_sse(hono.SSEEvent{
data: 'This event includes a retry field'
event: 'message'
id: '1'
retry: 3000 // 3 seconds
})!
stream.sleep(1000)
// Send another event without retry
stream.write_sse(hono.SSEEvent{
data: 'This event does not include retry'
event: 'message'
id: '2'
})!
})
})
// =========================================================================
// SSE with Error Handling Example
// =========================================================================
// SSE with error handling - demonstrates custom error handler
app.get('/sse-error', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send initial event
stream.write_sse(hono.SSEEvent{
data: 'Starting stream with potential error...'
event: 'start'
id: '1'
})!
stream.sleep(1000)
// Simulate an error condition
// In real applications, this could be a database error, API failure, etc.
should_error := true
if should_error {
return error('Simulated error for demonstration')
}
// This won't be reached due to the error above
stream.write_sse(hono.SSEEvent{
data: 'This message will not be sent'
event: 'message'
id: '2'
})!
}, fn (err IError, mut stream hono.StreamContext) {
// Custom error handler - send error event to client
println('[SSE Error Handler] Error occurred: ${err.msg()}')
// Try to send error event to client before closing
stream.write_sse(hono.SSEEvent{
data: 'Error: ${err.msg()}'
event: 'error'
id: 'error'
}) or {
println('[SSE Error Handler] Failed to send error event: ${err}')
}
})
})
// =========================================================================
// Real-time Counter Example
// =========================================================================
// Real-time counter - demonstrates continuous SSE updates
app.get('/sse-counter', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
mut counter := 0
// Send counter updates every 500ms for 20 iterations
for _ in 0 .. 20 {
if !stream.is_open() {
println('[Counter] Stream closed, stopping')
break
}
counter++
stream.write_sse(hono.SSEEvent{
data: '${counter}'
event: 'counter'
id: '${counter}'
})!
stream.sleep(500)
}
// Send completion event
stream.write_sse(hono.SSEEvent{
data: 'Counter finished at ${counter}'
event: 'complete'
id: 'done'
})!
})
})
// =========================================================================
// Pipe Example - Stream file content
// =========================================================================
// Pipe example - demonstrates piping data to stream
app.get('/stream-pipe', fn (mut c hono.Context) http.Response {
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// Create some sample data to pipe
sample_data := 'This is sample data that will be piped to the stream.\n'.repeat(5)
// Pipe the data in chunks
chunk_size := 50
mut offset := 0
for offset < sample_data.len {
end := if offset + chunk_size > sample_data.len { sample_data.len } else { offset + chunk_size }
chunk := sample_data[offset..end]
stream.pipe(chunk.bytes())!
offset = end
stream.sleep(100)
}
})
})
// =========================================================================
// REST API endpoints for demonstration
// =========================================================================
app.get('/api/status', fn (mut c hono.Context) http.Response {
return c.json('{"status":"running","streaming_endpoints":["/stream","/stream-text","/sse","/sse-multiline","/sse-retry","/sse-error","/sse-counter","/stream-pipe"]}')
})
// =========================================================================
// Start the server
// =========================================================================
println('============================================================')
println('SSE Streaming Demo Server')
println('============================================================')
println('')
println('Server starting on http://127.0.0.1:3000')
println('')
println('Streaming Endpoints:')
println(' http://127.0.0.1:3000/stream - Basic binary streaming')
println(' http://127.0.0.1:3000/stream-text - Text streaming')
println(' http://127.0.0.1:3000/sse - SSE event streaming')
println(' http://127.0.0.1:3000/sse-multiline - SSE with multi-line data')
println(' http://127.0.0.1:3000/sse-retry - SSE with retry field')
println(' http://127.0.0.1:3000/sse-error - SSE with error handling')
println(' http://127.0.0.1:3000/sse-counter - Real-time counter')
println(' http://127.0.0.1:3000/stream-pipe - Pipe data to stream')
println('')
println('HTTP Endpoints:')
println(' http://127.0.0.1:3000/ - Test page')
println(' http://127.0.0.1:3000/api/status - Server status')
println('')
println('Test with curl:')
println(' curl http://127.0.0.1:3000/stream')
println(' curl http://127.0.0.1:3000/stream-text')
println(' curl http://127.0.0.1:3000/sse')
println('')
println('============================================================')
app.listen(':3000')
}
// get_timestamp - Get current timestamp string
fn get_timestamp() string {
return '${os.execute("echo %TIME%").output.trim_space()}'
}
// get_sse_test_page - Returns HTML test page for SSE streaming
fn get_sse_test_page() string {
sq := "'"
return '<!DOCTYPE html>
<html>
<head>
<title>v-hono SSE Streaming Demo</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1000px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #667eea; margin-bottom: 10px; }
h2 { color: #333; font-size: 1.2em; margin: 20px 0 10px; }
.subtitle { color: #666; margin-bottom: 30px; }
.card { background: white; padding: 20px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.endpoint { display: flex; align-items: center; gap: 10px; margin: 10px 0; flex-wrap: wrap; }
.endpoint-url { font-family: monospace; background: #f0f0f0; padding: 8px 12px; border-radius: 4px; flex: 1; min-width: 200px; }
button { padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; font-size: 14px; transition: background 0.2s; }
.btn-connect { background: #667eea; color: white; }
.btn-connect:hover { background: #5a6fd6; }
.btn-disconnect { background: #e74c3c; color: white; }
.btn-disconnect:hover { background: #c0392b; }
.btn-clear { background: #95a5a6; color: white; }
.btn-clear:hover { background: #7f8c8d; }
.status { padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; }
.status-connected { background: #27ae60; color: white; }
.status-disconnected { background: #e74c3c; color: white; }
.status-connecting { background: #f39c12; color: white; }
.output { height: 200px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 4px; font-family: monospace; font-size: 13px; line-height: 1.5; }
.output:empty::before { content: "No messages yet..."; color: #666; }
.msg { margin: 2px 0; padding: 2px 0; }
.msg-event { color: #569cd6; }
.msg-data { color: #ce9178; }
.msg-id { color: #b5cea8; }
.msg-retry { color: #dcdcaa; }
.msg-system { color: #6a9955; font-style: italic; }
.msg-error { color: #f44747; }
.msg-text { color: #d4d4d4; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 20px; }
@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } .endpoint { flex-direction: column; align-items: stretch; } }
</style>
</head>
<body>
<h1>v-hono SSE Streaming Demo</h1>
<p class="subtitle">Test Server-Sent Events and streaming responses</p>
<div class="grid">
<div class="card">
<h2>SSE Event Stream</h2>
<div class="endpoint">
<span class="endpoint-url">/sse</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse${sq}, ${sq}sse-output${sq}, ${sq}sse-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}sse${sq})">Disconnect</button>
<span id="sse-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="sse-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}sse-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Multi-line SSE</h2>
<div class="endpoint">
<span class="endpoint-url">/sse-multiline</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse-multiline${sq}, ${sq}multiline-output${sq}, ${sq}multiline-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}multiline${sq})">Disconnect</button>
<span id="multiline-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="multiline-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}multiline-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Real-time Counter</h2>
<div class="endpoint">
<span class="endpoint-url">/sse-counter</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse-counter${sq}, ${sq}counter-output${sq}, ${sq}counter-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}counter${sq})">Disconnect</button>
<span id="counter-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="counter-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}counter-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Error Handling</h2>
<div class="endpoint">
<span class="endpoint-url">/sse-error</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse-error${sq}, ${sq}error-output${sq}, ${sq}error-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}error${sq})">Disconnect</button>
<span id="error-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="error-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}error-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Text Stream</h2>
<div class="endpoint">
<span class="endpoint-url">/stream-text</span>
<button class="btn-connect" onclick="fetchStream(${sq}/stream-text${sq}, ${sq}text-output${sq}, ${sq}text-status${sq})">Fetch</button>
<span id="text-status" class="status status-disconnected">Ready</span>
</div>
<div id="text-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}text-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Binary Stream</h2>
<div class="endpoint">
<span class="endpoint-url">/stream</span>
<button class="btn-connect" onclick="fetchStream(${sq}/stream${sq}, ${sq}binary-output${sq}, ${sq}binary-status${sq})">Fetch</button>
<span id="binary-status" class="status status-disconnected">Ready</span>
</div>
<div id="binary-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}binary-output${sq})">Clear</button>
</div>
</div>
<script>
var connections = {};
function log(outputId, msg, type) {
var output = document.getElementById(outputId);
var div = document.createElement(${sq}div${sq});
div.className = ${sq}msg msg-${sq} + (type || ${sq}text${sq});
div.textContent = msg;
output.appendChild(div);
output.scrollTop = output.scrollHeight;
}
function setStatus(statusId, status) {
var el = document.getElementById(statusId);
el.textContent = status;
el.className = ${sq}status status-${sq} + status.toLowerCase();
}
function clearOutput(outputId) {
document.getElementById(outputId).innerHTML = ${sq}${sq};
}
function connectSSE(url, outputId, statusId) {
var key = outputId.replace(${sq}-output${sq}, ${sq}${sq});
if (connections[key]) {
connections[key].close();
}
setStatus(statusId, ${sq}Connecting${sq});
log(outputId, ${sq}Connecting to ${sq} + url + ${sq}...${sq}, ${sq}system${sq});
var es = new EventSource(url);
connections[key] = es;
es.onopen = function() {
setStatus(statusId, ${sq}Connected${sq});
log(outputId, ${sq}Connected!${sq}, ${sq}system${sq});
};
es.onmessage = function(e) {
log(outputId, ${sq}data: ${sq} + e.data, ${sq}data${sq});
};
es.onerror = function() {
setStatus(statusId, ${sq}Disconnected${sq});
log(outputId, ${sq}Connection closed${sq}, ${sq}system${sq});
connections[key] = null;
};
// Listen for custom event types
[${sq}connect${sq}, ${sq}update${sq}, ${sq}complete${sq}, ${sq}multiline${sq}, ${sq}json${sq}, ${sq}message${sq}, ${sq}counter${sq}, ${sq}start${sq}, ${sq}error${sq}].forEach(function(type) {
es.addEventListener(type, function(e) {
log(outputId, ${sq}event: ${sq} + type, ${sq}event${sq});
log(outputId, ${sq}data: ${sq} + e.data, ${sq}data${sq});
if (e.lastEventId) log(outputId, ${sq}id: ${sq} + e.lastEventId, ${sq}id${sq});
});
});
}
function disconnectSSE(key) {
if (connections[key]) {
connections[key].close();
connections[key] = null;
setStatus(key + ${sq}-status${sq}, ${sq}Disconnected${sq});
log(key + ${sq}-output${sq}, ${sq}Disconnected by user${sq}, ${sq}system${sq});
}
}
async function fetchStream(url, outputId, statusId) {
setStatus(statusId, ${sq}Connecting${sq});
log(outputId, ${sq}Fetching ${sq} + url + ${sq}...${sq}, ${sq}system${sq});
try {
var response = await fetch(url);
var reader = response.body.getReader();
var decoder = new TextDecoder();
setStatus(statusId, ${sq}Connected${sq});
while (true) {
var result = await reader.read();
if (result.done) break;
var text = decoder.decode(result.value, {stream: true});
log(outputId, text, ${sq}text${sq});
}
setStatus(statusId, ${sq}Disconnected${sq});
log(outputId, ${sq}Stream complete${sq}, ${sq}system${sq});
} catch (err) {
setStatus(statusId, ${sq}Disconnected${sq});
log(outputId, ${sq}Error: ${sq} + err.message, ${sq}error${sq});
}
}
</script>
</body>
</html>'
}
+336
View File
@@ -0,0 +1,336 @@
// WebSocket Demo - Chat Room Example for v-hono
// This example demonstrates all WebSocket event handlers and configuration options.
//
// Run with: v run examples/websocket_demo.v
// Then open a browser and navigate to http://127.0.0.1:3000
// Or use a WebSocket client to connect to ws://127.0.0.1:3000/ws/chat/general
//
// Features demonstrated:
// - WebSocket upgrade handling
// - Event handlers (on_open, on_message, on_close, on_error)
// - Route parameters (/ws/chat/:room)
// - Query parameters (?username=xxx)
// - Subprotocol negotiation
// - Custom configuration options (ping interval, max message size, timeout)
// - JSON message handling
// - Binary message handling
// - Middleware integration with WebSocket routes
module main
import net.http
import time
import os
import hono
fn main() {
mut app := hono.Hono.new()
// =========================================================================
// Middleware - Runs before WebSocket upgrade
// =========================================================================
// Logger middleware - logs all requests including WebSocket upgrades
app.use(fn (mut c hono.Context, next fn (mut hono.Context) http.Response) http.Response {
start := time.now()
response := next(mut c)
duration := time.since(start)
println('[${time.now().format_ss()}] ${c.req.method} ${c.path} - ${response.status_code} (${duration})')
return response
})
// =========================================================================
// Static HTML page for testing WebSocket
// =========================================================================
app.get('/', fn (mut c hono.Context) http.Response {
// Try to load the HTML file from the examples directory
html_content := os.read_file('examples/websocket_test.html') or {
// Fallback to a simple HTML page if file not found
return c.html(get_simple_test_page())
}
return c.html(html_content)
})
// =========================================================================
// Basic WebSocket Echo Example
// =========================================================================
// Simple echo WebSocket - echoes back any message received
app.ws('/ws/echo', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
println('[Echo] Client connected')
ws.send('Welcome to the echo server!') or {
println('[Echo] Failed to send welcome: ${err}')
}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
if event.is_binary {
println('[Echo] Received binary message: ${event.data_bytes.len} bytes')
ws.send_bytes(event.data_bytes) or {
println('[Echo] Failed to echo binary: ${err}')
}
} else {
println('[Echo] Received: ${event.data}')
ws.send('Echo: ${event.data}') or {
println('[Echo] Failed to echo: ${err}')
}
}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Echo] Client disconnected - Code: ${event.code}, Reason: ${event.reason}, Clean: ${event.was_clean}')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Echo] Error: ${error}')
}
}
})
// =========================================================================
// Chat Room WebSocket with Route Parameters
// =========================================================================
// Chat room WebSocket - demonstrates route parameters and query parameters
// URL format: /ws/chat/:room?username=xxx
app.ws('/ws/chat/:room', fn (c hono.Context) hono.WSEvents {
// Access route parameters from the HTTP context
room := c.params['room'] or { 'general' }
username := c.query['username'] or { 'anonymous' }
println('[Chat] User "${username}" joining room "${room}"')
return hono.WSEvents{
on_open: fn [room, username] (mut ws hono.WSContext) {
// Access preserved context data
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] ${actual_user} connected')
// Send welcome message as JSON
welcome := '{"type":"system","message":"Welcome to room ${actual_room}, ${actual_user}!"}'
ws.send_json(welcome) or {
println('[Chat] Failed to send welcome: ${err}')
}
// Broadcast join notification
join_msg := '{"type":"join","user":"${actual_user}","room":"${actual_room}"}'
ws.send_json(join_msg) or {}
}
on_message: fn [room, username] (event hono.WSMessageEvent, mut ws hono.WSContext) {
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] ${actual_user}: ${event.data}')
// Echo the message back with metadata
response := '{"type":"message","user":"${actual_user}","room":"${actual_room}","content":"${event.data}"}'
ws.send_json(response) or {
println('[Chat] Failed to send response: ${err}')
}
}
on_close: fn [room, username] (event hono.WSCloseEvent, mut ws hono.WSContext) {
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] ${actual_user} disconnected (code: ${event.code})')
}
on_error: fn [room, username] (error string, mut ws hono.WSContext) {
actual_room := ws.params['room'] or { room }
actual_user := ws.query['username'] or { username }
println('[Chat/${actual_room}] Error for ${actual_user}: ${error}')
}
}
})
// =========================================================================
// WebSocket with Custom Configuration Options
// =========================================================================
// Configured WebSocket - demonstrates custom options
app.ws('/ws/configured', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
println('[Configured] Client connected with custom settings')
ws.send('Connected with custom configuration!') or {}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
println('[Configured] Message: ${event.data}')
ws.send('Received: ${event.data}') or {}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Configured] Disconnected')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Configured] Error: ${error}')
}
}
}, hono.WebSocketOptions{
ping_interval: 15000 // Send ping every 15 seconds
max_message_size: 65536 // Max 64KB messages
timeout: 30000 // 30 second timeout
protocols: ['chat', 'json'] // Supported subprotocols
})
// =========================================================================
// WebSocket with Subprotocol Negotiation
// =========================================================================
// Subprotocol WebSocket - demonstrates protocol negotiation
app.ws('/ws/protocol', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
protocol := ws.protocol
println('[Protocol] Client connected with protocol: ${protocol}')
if protocol == 'json' {
ws.send_json('{"status":"connected","protocol":"json"}') or {}
} else {
ws.send('Connected with protocol: ${protocol}') or {}
}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
protocol := ws.protocol
println('[Protocol] Message (${protocol}): ${event.data}')
if protocol == 'json' {
ws.send_json('{"echo":"${event.data}"}') or {}
} else {
ws.send('Echo: ${event.data}') or {}
}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Protocol] Disconnected')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Protocol] Error: ${error}')
}
}
}, hono.WebSocketOptions{
protocols: ['json', 'text', 'binary']
})
// =========================================================================
// Binary Data WebSocket
// =========================================================================
// Binary WebSocket - demonstrates binary message handling
app.ws('/ws/binary', fn (c hono.Context) hono.WSEvents {
return hono.WSEvents{
on_open: fn (mut ws hono.WSContext) {
println('[Binary] Client connected')
ws.send('Binary WebSocket ready. Send binary data!') or {}
}
on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
if event.is_binary {
println('[Binary] Received ${event.data_bytes.len} bytes')
// Echo back the binary data
ws.send_bytes(event.data_bytes) or {
println('[Binary] Failed to echo: ${err}')
}
// Also send a text confirmation
ws.send('Received ${event.data_bytes.len} bytes of binary data') or {}
} else {
println('[Binary] Received text: ${event.data}')
ws.send('Please send binary data, not text!') or {}
}
}
on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
println('[Binary] Disconnected')
}
on_error: fn (error string, mut ws hono.WSContext) {
println('[Binary] Error: ${error}')
}
}
})
// =========================================================================
// REST API endpoints for demonstration
// =========================================================================
app.get('/api/rooms', fn (mut c hono.Context) http.Response {
return c.json('{"rooms":["general","tech","random"]}')
})
app.get('/api/status', fn (mut c hono.Context) http.Response {
return c.json('{"status":"running","websocket_endpoints":["/ws/echo","/ws/chat/:room","/ws/configured","/ws/protocol","/ws/binary"]}')
})
// =========================================================================
// Start the server
// =========================================================================
println('============================================================')
println('WebSocket Demo Server')
println('============================================================')
println('')
println('Server starting on http://127.0.0.1:3000')
println('')
println('WebSocket Endpoints:')
println(' ws://127.0.0.1:3000/ws/echo - Simple echo server')
println(' ws://127.0.0.1:3000/ws/chat/:room - Chat room with route params')
println(' ws://127.0.0.1:3000/ws/configured - Custom configuration demo')
println(' ws://127.0.0.1:3000/ws/protocol - Subprotocol negotiation')
println(' ws://127.0.0.1:3000/ws/binary - Binary data handling')
println('')
println('HTTP Endpoints:')
println(' http://127.0.0.1:3000/ - Test page')
println(' http://127.0.0.1:3000/api/rooms - List rooms')
println(' http://127.0.0.1:3000/api/status - Server status')
println('')
println('============================================================')
app.listen(':3000')
}
// Simple fallback test page when HTML file is not found
fn get_simple_test_page() string {
return '<!DOCTYPE html>
<html>
<head>
<title>v-hono WebSocket Demo</title>
<style>
body{font-family:sans-serif;max-width:800px;margin:50px auto;padding:20px}
h1{color:#667eea}
.card{background:#f5f5f5;padding:20px;margin:20px 0;border-radius:8px}
input,button{padding:10px;margin:5px;border-radius:4px;border:1px solid #ccc}
button{background:#667eea;color:white;border:none;cursor:pointer}
button:hover{background:#5a6fd6}
#messages{height:200px;overflow-y:auto;background:white;padding:10px;border:1px solid #ccc;border-radius:4px}
.msg{padding:5px;margin:2px 0;border-radius:4px}
.sent{background:#e3f2fd}
.received{background:#e8f5e9}
.system{background:#fff3e0}
</style>
</head>
<body>
<h1>v-hono WebSocket Demo</h1>
<div class="card">
<h3>Connection</h3>
<input type="text" id="url" value="ws://127.0.0.1:3000/ws/echo" style="width:300px">
<button onclick="connect()">Connect</button>
<button onclick="disconnect()">Disconnect</button>
<span id="status">Disconnected</span>
</div>
<div class="card">
<h3>Send Message</h3>
<input type="text" id="msg" placeholder="Type message..." style="width:300px">
<button onclick="send()">Send</button>
</div>
<div class="card">
<h3>Messages</h3>
<div id="messages"></div>
</div>
<script>
var ws=null;
function log(t,c){var m=document.getElementById("messages");m.innerHTML+="<div class=msg "+c+">"+t+"</div>";m.scrollTop=m.scrollHeight}
function connect(){if(ws)return;ws=new WebSocket(document.getElementById("url").value);ws.onopen=function(){document.getElementById("status").textContent="Connected";log("Connected","system")};ws.onmessage=function(e){log("Received: "+e.data,"received")};ws.onclose=function(){document.getElementById("status").textContent="Disconnected";log("Disconnected","system");ws=null};ws.onerror=function(){log("Error","system")}}
function disconnect(){if(ws)ws.close()}
function send(){if(!ws)return;var m=document.getElementById("msg").value;ws.send(m);log("Sent: "+m,"sent");document.getElementById("msg").value=""}
</script>
</body>
</html>'
}
+294
View File
@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>v-hono WebSocket Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 {
color: white;
text-align: center;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
}
.card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.card h2 {
color: #333;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.form-group { margin-bottom: 15px; }
label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #555;
}
input, select {
width: 100%;
padding: 10px 15px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, select:focus {
outline: none;
border-color: #667eea;
}
.btn-group { display: flex; gap: 10px; flex-wrap: wrap; }
button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
button:active { transform: translateY(0); }
.btn-connect { background: #667eea; color: white; }
.btn-disconnect { background: #e74c3c; color: white; }
.btn-send { background: #27ae60; color: white; }
.btn-clear { background: #95a5a6; color: white; }
.status {
display: inline-block;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
margin-left: 10px;
}
.status-disconnected { background: #fee2e2; color: #dc2626; }
.status-connecting { background: #fef3c7; color: #d97706; }
.status-connected { background: #d1fae5; color: #059669; }
#messages {
height: 300px;
overflow-y: auto;
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
background: #f8f9fa;
font-family: "Monaco", "Menlo", monospace;
font-size: 13px;
}
.message {
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 6px;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.message-sent { background: #e3f2fd; border-left: 4px solid #2196f3; }
.message-received { background: #e8f5e9; border-left: 4px solid #4caf50; }
.message-system { background: #fff3e0; border-left: 4px solid #ff9800; }
.message-error { background: #ffebee; border-left: 4px solid #f44336; }
.message-time { color: #999; font-size: 11px; }
.endpoints {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin-top: 15px;
}
.endpoint {
padding: 10px;
background: #f0f4ff;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.endpoint:hover { background: #e0e7ff; }
.endpoint code {
display: block;
font-size: 12px;
color: #667eea;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>🔌 v-hono WebSocket Demo</h1>
<div class="card">
<h2>Connection <span id="status" class="status status-disconnected">Disconnected</span></h2>
<div class="form-group">
<label>WebSocket URL</label>
<input type="text" id="wsUrl" value="ws://127.0.0.1:3000/ws/echo" placeholder="ws://...">
</div>
<div class="form-group">
<label>Quick Connect</label>
<div class="endpoints">
<div class="endpoint" onclick="setUrl('ws://127.0.0.1:3000/ws/echo')">
<strong>Echo Server</strong>
<code>/ws/echo</code>
</div>
<div class="endpoint" onclick="setUrl('ws://127.0.0.1:3000/ws/chat/general?username=guest')">
<strong>Chat Room</strong>
<code>/ws/chat/:room</code>
</div>
<div class="endpoint" onclick="setUrl('ws://127.0.0.1:3000/ws/configured')">
<strong>Configured</strong>
<code>/ws/configured</code>
</div>
<div class="endpoint" onclick="setUrl('ws://127.0.0.1:3000/ws/protocol')">
<strong>Protocol</strong>
<code>/ws/protocol</code>
</div>
</div>
</div>
<div class="btn-group">
<button class="btn-connect" onclick="connect()">Connect</button>
<button class="btn-disconnect" onclick="disconnect()">Disconnect</button>
</div>
</div>
<div class="card">
<h2>Send Message</h2>
<div class="form-group">
<label>Message</label>
<input type="text" id="message" placeholder="Type your message..." onkeypress="if(event.key==='Enter')sendMessage()">
</div>
<div class="btn-group">
<button class="btn-send" onclick="sendMessage()">Send Text</button>
<button class="btn-send" onclick="sendJson()">Send JSON</button>
</div>
</div>
<div class="card">
<h2>Messages <button class="btn-clear" onclick="clearMessages()" style="float:right;padding:6px 12px;">Clear</button></h2>
<div id="messages"></div>
</div>
</div>
<script>
let ws = null;
function setUrl(url) {
document.getElementById('wsUrl').value = url;
}
function updateStatus(status) {
const el = document.getElementById('status');
el.textContent = status;
el.className = 'status status-' + status.toLowerCase();
}
function addMessage(type, text) {
const messages = document.getElementById('messages');
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = 'message message-' + type;
div.innerHTML = '<span class="message-time">[' + time + ']</span> ' + text;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function connect() {
if (ws && ws.readyState === WebSocket.OPEN) {
addMessage('system', 'Already connected!');
return;
}
const url = document.getElementById('wsUrl').value;
updateStatus('Connecting');
addMessage('system', 'Connecting to ' + url + '...');
try {
ws = new WebSocket(url);
ws.onopen = function() {
updateStatus('Connected');
addMessage('system', 'Connected successfully!');
};
ws.onmessage = function(event) {
addMessage('received', event.data);
};
ws.onclose = function(event) {
updateStatus('Disconnected');
addMessage('system', 'Disconnected (code: ' + event.code + ', reason: ' + (event.reason || 'none') + ')');
ws = null;
};
ws.onerror = function(error) {
addMessage('error', 'WebSocket error occurred');
};
} catch (e) {
addMessage('error', 'Failed to connect: ' + e.message);
updateStatus('Disconnected');
}
}
function disconnect() {
if (ws) {
ws.close(1000, 'User requested disconnect');
addMessage('system', 'Disconnecting...');
} else {
addMessage('system', 'Not connected');
}
}
function sendMessage() {
const input = document.getElementById('message');
const text = input.value.trim();
if (!text) {
addMessage('error', 'Please enter a message');
return;
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
addMessage('error', 'Not connected!');
return;
}
ws.send(text);
addMessage('sent', text);
input.value = '';
}
function sendJson() {
const input = document.getElementById('message');
const text = input.value.trim() || 'Hello';
if (!ws || ws.readyState !== WebSocket.OPEN) {
addMessage('error', 'Not connected!');
return;
}
const json = JSON.stringify({ type: 'message', content: text, timestamp: Date.now() });
ws.send(json);
addMessage('sent', json);
input.value = '';
}
function clearMessages() {
document.getElementById('messages').innerHTML = '';
}
</script>
</body>
</html>
+106 -1
View File
@@ -11,7 +11,7 @@ import rand
// 中间件导出说明
// ============================================================================
//
// 本框架提供以下 7 个内置中间件:
// 本框架提供以下 8 个内置中间件:
//
// 1. CORS 中间件 (cors.v)
// - cors(options ...CorsOptions) ContextMiddleware
@@ -59,6 +59,29 @@ import rand
// - get_validated_json(c Context) ?string
// - get_validated_field(c Context, field string) ?string
//
// 8. WebSocket Helper (websocket.v)
// - upgrade_websocket(factory WSHandlerFactory, options ...WebSocketOptions) fn (mut Context) http.Response
// - 用于处理 WebSocket 连接升级和事件处理
//
// Types:
// - WebSocketOptions: 配置选项 (ping_interval, max_message_size, timeout, protocols)
// - WSReadyState: 连接状态枚举 (connecting, open, closing, closed)
// - WSMessageEvent: 消息事件结构
// - WSCloseEvent: 关闭事件结构
// - WSContext: WebSocket 上下文,提供 send/send_bytes/send_json/close 方法
// - WSEvents: 事件处理器配置 (on_open, on_message, on_close, on_error)
// - WSHandlerFactory: 处理器工厂函数类型
//
// Constants:
// - ws_opcode_text, ws_opcode_binary, ws_opcode_close, ws_opcode_ping, ws_opcode_pong
// - ws_close_normal, ws_close_going_away, ws_close_protocol_error, etc.
//
// Helper Functions:
// - is_websocket_upgrade(c Context) bool: 检查是否为 WebSocket 升级请求
// - compute_accept_key(key string) string: 计算 Sec-WebSocket-Accept
// - encode_ws_frame(opcode u8, payload []u8, masked bool) []u8: 编码 WebSocket 帧
// - decode_ws_frame(data []u8) !WSFrame: 解码 WebSocket 帧
//
// ============================================================================
// ============================================================================
@@ -270,3 +293,85 @@ pub fn timing() ContextMiddleware {
}
}
// ============================================================================
// WebSocket Helper 导出
// ============================================================================
//
// WebSocket Helper 提供服务端 WebSocket 支持,实现 RFC 6455 协议。
// 主要功能通过 websocket.v 文件导出,包括:
//
// 核心函数:
// - upgrade_websocket(factory, options...) - 创建 WebSocket 升级处理器
// - is_websocket_upgrade(c) - 检查是否为 WebSocket 升级请求
//
// 类型定义:
// - WebSocketOptions - 配置选项
// - WSReadyState - 连接状态枚举
// - WSMessageEvent - 消息事件
// - WSCloseEvent - 关闭事件
// - WSContext - WebSocket 上下文
// - WSEvents - 事件处理器配置
// - WSHandlerFactory - 处理器工厂类型
//
// 常量:
// - ws_opcode_* - WebSocket 操作码
// - ws_close_* - WebSocket 关闭状态码
//
// 帧处理函数:
// - encode_ws_frame() - 编码 WebSocket 帧
// - decode_ws_frame() - 解码 WebSocket 帧
// - compute_accept_key() - 计算握手响应密钥
//
// 使用示例:
// app.get('/ws', hono.upgrade_websocket(fn (c hono.Context) hono.WSEvents {
// return hono.WSEvents{
// on_open: fn (mut ws hono.WSContext) {
// ws.send('Welcome!') or {}
// }
// on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
// ws.send('Echo: ${event.data}') or {}
// }
// on_close: fn (event hono.WSCloseEvent, mut ws hono.WSContext) {
// println('Closed: ${event.code}')
// }
// }
// }))
//
// 带配置选项:
// app.get('/ws', hono.upgrade_websocket(factory, hono.WebSocketOptions{
// ping_interval: 30000 // 30秒 ping 间隔
// max_message_size: 1048576 // 1MB 最大消息大小
// timeout: 60000 // 60秒超时
// protocols: ['chat', 'json'] // 支持的子协议
// }))
// ============================================================================
// websocket - WebSocket 升级处理器的别名函数
// 使用示例:
// app.get('/ws', hono.websocket(fn (c hono.Context) hono.WSEvents {
// return hono.WSEvents{
// on_message: fn (event hono.WSMessageEvent, mut ws hono.WSContext) {
// ws.send('Echo: ${event.data}') or {}
// }
// }
// }))
pub fn websocket(factory WSHandlerFactory, options ...WebSocketOptions) fn (mut Context) http.Response {
return upgrade_websocket(factory, ...options)
}
// ws - WebSocket 升级处理器的简短别名
// 使用示例:
// app.get('/ws', hono.ws(handler_factory))
pub fn ws(factory WSHandlerFactory, options ...WebSocketOptions) fn (mut Context) http.Response {
return upgrade_websocket(factory, ...options)
}
// is_ws_upgrade - 检查请求是否为 WebSocket 升级请求的别名
// 使用示例:
// if hono.is_ws_upgrade(c) {
// // Handle WebSocket upgrade
// }
pub fn is_ws_upgrade(c Context) bool {
return is_websocket_upgrade(c)
}
+272
View File
@@ -61,6 +61,135 @@ pub fn (mut app Hono) listen_picoev_with_config(config PicoevConfig) {
pico.serve()
}
// ============================================================================
// WebSocket Upgrade Detection and Handling
// ============================================================================
// Check if a picoev request is a WebSocket upgrade request
fn is_picoev_ws_upgrade(req picohttpparser.Request) bool {
mut has_upgrade := false
mut has_connection := false
mut has_ws_key := false
for i in 0 .. req.num_headers {
h := req.headers[i]
name_lower := h.name.to_lower()
if name_lower == 'upgrade' {
if h.value.to_lower() == 'websocket' {
has_upgrade = true
}
} else if name_lower == 'connection' {
if contains_ignore_case(h.value, 'upgrade') {
has_connection = true
}
} else if name_lower == 'sec-websocket-key' {
if h.value.len > 0 {
has_ws_key = true
}
}
}
return has_upgrade && has_connection && has_ws_key
}
// Get WebSocket key from picoev request headers
fn get_picoev_ws_key(req picohttpparser.Request) string {
for i in 0 .. req.num_headers {
h := req.headers[i]
if eq_ignore_case(h.name, 'sec-websocket-key') {
return h.value
}
}
return ''
}
// Get WebSocket protocol from picoev request headers
fn get_picoev_ws_protocol(req picohttpparser.Request) string {
for i in 0 .. req.num_headers {
h := req.headers[i]
if eq_ignore_case(h.name, 'sec-websocket-protocol') {
return h.value
}
}
return ''
}
// Get WebSocket version from picoev request headers
fn get_picoev_ws_version(req picohttpparser.Request) string {
for i in 0 .. req.num_headers {
h := req.headers[i]
if eq_ignore_case(h.name, 'sec-websocket-version') {
return h.value
}
}
return '13'
}
// Handle WebSocket upgrade for picoev
fn handle_picoev_ws_upgrade(mut ctx PicoevRequestContext, req picohttpparser.Request, mut res picohttpparser.Response, route_match ContextRouteMatch, hono_ctx Context) bool {
// Validate WebSocket version
ws_version := get_picoev_ws_version(req)
if ws_version != '13' {
res.raw('HTTP/1.1 426 Upgrade Required\r\n')
res.header('Sec-WebSocket-Version', '13')
res.header('Content-Type', 'text/plain')
res.body('Unsupported WebSocket version')
res.end()
return false
}
// Get WebSocket key
ws_key := get_picoev_ws_key(req)
if ws_key.len == 0 {
res.raw('HTTP/1.1 400 Bad Request\r\n')
res.header('Content-Type', 'text/plain')
res.body('Missing Sec-WebSocket-Key header')
res.end()
return false
}
// Compute accept key
accept_key := compute_accept_key(ws_key)
// Execute the route handler to get WSEvents
// The handler should return a 101 response with WebSocket context stored
mut mutable_ctx := hono_ctx
response := route_match.handler.handle(mut mutable_ctx)
// Check if this is a WebSocket upgrade response
if response.status_code != 101 {
// Not a WebSocket upgrade, send the response as-is
send_picoev_response(mut res, mutable_ctx, response, false, ctx.config)
return false
}
// Negotiate subprotocol
mut selected_protocol := ''
if '_ws_protocol' in mutable_ctx.store {
selected_protocol = mutable_ctx.store['_ws_protocol']
}
// Send WebSocket handshake response
res.raw('HTTP/1.1 101 Switching Protocols\r\n')
res.header('Upgrade', 'websocket')
res.header('Connection', 'Upgrade')
res.header('Sec-WebSocket-Accept', accept_key)
if selected_protocol.len > 0 {
res.header('Sec-WebSocket-Protocol', selected_protocol)
}
res.body('')
res.end()
return true
}
// Send WebSocket frame via picoev response
fn send_ws_frame_picoev(data []u8) ! {
// This is a placeholder - actual implementation depends on picoev's raw socket access
// In practice, we need to write directly to the socket fd
}
// picoev 回调函数
fn picoev_callback(user_data voidptr, req picohttpparser.Request, mut res picohttpparser.Response) {
mut ctx := unsafe { &PicoevRequestContext(user_data) }
@@ -75,11 +204,24 @@ fn picoev_callback(user_data voidptr, req picohttpparser.Request, mut res picoht
keepalive := check_keepalive_request(req)
method_str := req.method
// Check if this is a WebSocket upgrade request
is_ws_upgrade := is_picoev_ws_upgrade(req)
// 优先使用快速路由器
if ctx.app.use_fast_router {
if route_match := ctx.app.fast_router.match_route(method_str, path) {
mut hono_ctx := create_picoev_context(req, route_match.params, query_map)
// Handle WebSocket upgrade if detected
if is_ws_upgrade {
if handle_picoev_ws_upgrade(mut ctx, req, mut res, route_match, hono_ctx) {
// WebSocket upgrade successful, connection is now in WebSocket mode
return
}
// If upgrade failed, response was already sent
return
}
// 优化:零中间件快速路径
if !ctx.app.has_middlewares {
response := route_match.handler.handle(mut hono_ctx)
@@ -98,6 +240,15 @@ fn picoev_callback(user_data voidptr, req picohttpparser.Request, mut res picoht
if route_match := ctx.app.context_hybrid_router.match_route(method_str, path) {
mut hono_ctx := create_picoev_context(req, route_match.params, query_map)
// Handle WebSocket upgrade if detected
if is_ws_upgrade {
if handle_picoev_ws_upgrade(mut ctx, req, mut res, route_match, hono_ctx) {
// WebSocket upgrade successful
return
}
return
}
// 优化:零中间件快速路径
if !ctx.app.has_middlewares {
response := route_match.handler.handle(mut hono_ctx)
@@ -370,6 +521,13 @@ fn parse_http_method_fast(method string) http.Method {
// 发送 picoev 响应 - 优化版
fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response http.Response, keepalive bool, config PicoevConfig) {
// Check if this is a streaming response
if is_streaming_response(ctx) {
// Handle streaming response
handle_picoev_streaming_response(mut res, ctx, config)
return
}
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
if status_code == 200 {
@@ -419,9 +577,122 @@ fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response h
res.end()
}
// ============================================================================
// Streaming Response Handling for Picoev
// ============================================================================
// handle_picoev_streaming_response - Handle a streaming response using PicoevStreamWriter
// This function:
// 1. Sends the HTTP headers for streaming directly to socket fd
// 2. Creates a PicoevStreamWriter with the socket fd
// 3. Executes the streaming callback
// 4. Handles errors and cleanup
fn handle_picoev_streaming_response(mut res picohttpparser.Response, ctx Context, config PicoevConfig) {
// Get stream configuration
stream_config := get_stream_config(ctx) or {
// Fallback to error response if stream config not found
res.raw('HTTP/1.1 500 Internal Server Error\r\n')
res.header('Content-Type', 'text/plain')
res.body('Stream configuration not found')
res.end()
return
}
// Determine status code
status_code := if ctx.status_code != 0 { ctx.status_code } else { 200 }
// Build HTTP headers string for direct socket write
mut headers_str := 'HTTP/1.1 ${status_code} ${get_status_text(status_code)}\r\n'
// Add headers from context
mut has_transfer_encoding := false
mut has_connection := false
for key, value in ctx.headers {
key_len := key.len
// Skip Content-Length for streaming (we use chunked encoding)
if key_len == 14 && eq_ignore_case(key, 'content-length') {
continue
}
headers_str += '${key}: ${value}\r\n'
if key_len == 17 && eq_ignore_case(key, 'transfer-encoding') {
has_transfer_encoding = true
} else if key_len == 10 && eq_ignore_case(key, 'connection') {
has_connection = true
}
}
// Ensure Transfer-Encoding: chunked is set
if !has_transfer_encoding {
headers_str += 'Transfer-Encoding: chunked\r\n'
}
// Ensure Connection header is set for streaming
if !has_connection {
headers_str += 'Connection: keep-alive\r\n'
headers_str += 'Keep-Alive: timeout=${config.timeout_secs}\r\n'
}
// End headers section
headers_str += '\r\n'
// Write headers directly to socket fd for immediate delivery
fd := res.fd
write_to_socket_fd(fd, headers_str.bytes()) or {
eprintln('[Picoev Stream Error] Failed to write headers: ${err.msg()}')
cleanup_stream_config(ctx)
return
}
// Create PicoevStreamWriter for streaming (uses fd directly)
mut writer := PicoevStreamWriter.new(res)
// Create StreamContext with the writer
mut stream_ctx := StreamContext.new(writer)
// Execute the streaming callback
stream_config.callback(mut stream_ctx) or {
// Handle error
if error_handler := stream_config.error_handler {
error_handler(err, mut stream_ctx)
} else {
// Default: log error to console
eprintln('[Picoev Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
stream_ctx.close()
// Cleanup the stored configuration
cleanup_stream_config(ctx)
}
// write_to_socket_fd - Helper function to write bytes directly to socket fd
// Used for sending HTTP headers before streaming begins
fn write_to_socket_fd(fd int, data []u8) ! {
if data.len == 0 {
return
}
mut total_written := 0
for total_written < data.len {
remaining := data.len - total_written
ptr := unsafe { &u8(data.data) + total_written }
written := C.send(fd, ptr, remaining, 0)
if written < 0 {
return error('Failed to write to socket')
}
if written == 0 {
return error('Connection closed by peer')
}
total_written += written
}
}
// 获取状态码文本
fn get_status_text(code int) string {
return match code {
101 { 'Switching Protocols' }
200 { 'OK' }
201 { 'Created' }
204 { 'No Content' }
@@ -433,6 +704,7 @@ fn get_status_text(code int) string {
403 { 'Forbidden' }
404 { 'Not Found' }
405 { 'Method Not Allowed' }
426 { 'Upgrade Required' }
500 { 'Internal Server Error' }
502 { 'Bad Gateway' }
503 { 'Service Unavailable' }
+982
View File
@@ -0,0 +1,982 @@
// streaming.v - SSE Streaming Helper for v-hono
// Provides Server-Sent Events (SSE) and streaming response support
// Reference: Hono.js Streaming Helper API
module hono
import net.http
import time
import picohttpparser
import usockets
// C function declaration for direct socket writes
fn C.send(sockfd int, buf voidptr, len int, flags int) int
// ============================================================================
// SSE Event Structure
// ============================================================================
// SSEEvent - Server-Sent Events event structure
// Used with stream_sse() to send formatted SSE events to clients
pub struct SSEEvent {
pub:
data string // Event data (required) - supports multi-line
event string // Event type name (optional)
id string // Event ID for client reconnection (optional)
retry int // Reconnection time in milliseconds (optional, 0 = not set)
}
// ============================================================================
// StreamWriter Interface
// ============================================================================
// StreamWriter - Abstract interface for stream writing
// Allows different server backends (picoev, uSockets) to implement their own writers
pub interface StreamWriter {
// Check if the connection is still active (immutable)
is_connected() bool
mut:
// Write raw bytes to the stream
write(data []u8) !
// Write string to the stream
write_string(data string) !
// Flush the buffer to ensure data is sent
flush() !
// Close the stream connection
close() !
}
// ============================================================================
// StreamContext Structure
// ============================================================================
// StreamContext - Context provided to streaming callbacks
// Provides methods for writing data, SSE events, and managing the stream
@[heap]
pub struct StreamContext {
mut:
writer &StreamWriter
is_closed bool
abort_fn ?fn ()
}
// StreamContext.new - Create a new StreamContext with the given writer
pub fn StreamContext.new(writer &StreamWriter) StreamContext {
return StreamContext{
writer: unsafe { writer }
is_closed: false
abort_fn: none
}
}
// write - Write raw bytes to the stream
pub fn (mut ctx StreamContext) write(data []u8) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write(data)!
}
// write_string - Write a string to the stream
pub fn (mut ctx StreamContext) write_string(data string) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write_string(data)!
}
// writeln - Write a string followed by a newline character
pub fn (mut ctx StreamContext) writeln(data string) ! {
ctx.write_string(data)!
ctx.write_string('\n')!
}
// write_sse - Write an SSE event to the stream
// Formats the event according to the SSE specification:
// - event: {type}\n (if event field is set)
// - data: {line}\n (for each line of data)
// - id: {id}\n (if id field is set)
// - retry: {ms}\n (if retry > 0)
// - \n (event separator)
pub fn (mut ctx StreamContext) write_sse(event SSEEvent) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
// Write event type if specified
if event.event.len > 0 {
ctx.writer.write_string('event: ${event.event}\n')!
}
// Write data field - handle multi-line data
lines := event.data.split('\n')
for line in lines {
ctx.writer.write_string('data: ${line}\n')!
}
// Write id if specified
if event.id.len > 0 {
ctx.writer.write_string('id: ${event.id}\n')!
}
// Write retry if specified (must be > 0)
if event.retry > 0 {
ctx.writer.write_string('retry: ${event.retry}\n')!
}
// Write event separator (empty line)
ctx.writer.write_string('\n')!
// Flush to ensure event is sent immediately
ctx.writer.flush()!
}
// sleep - Pause execution for the specified milliseconds
pub fn (ctx StreamContext) sleep(ms int) {
time.sleep(ms * time.millisecond)
}
// pipe - Pipe data from a byte array to the stream
pub fn (mut ctx StreamContext) pipe(data []u8) ! {
ctx.write(data)!
}
// on_abort - Register a callback to be called when the client aborts the connection
pub fn (mut ctx StreamContext) on_abort(callback fn ()) {
ctx.abort_fn = callback
}
// close - Close the stream
pub fn (mut ctx StreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// is_open - Check if the stream is still open
pub fn (ctx StreamContext) is_open() bool {
return !ctx.is_closed && ctx.writer.is_connected()
}
// trigger_abort - Internal method to trigger the abort callback
// Called by server backends when client disconnects
pub fn (mut ctx StreamContext) trigger_abort() {
if abort_callback := ctx.abort_fn {
abort_callback()
}
ctx.is_closed = true
}
// ============================================================================
// Picoev StreamWriter Implementation
// ============================================================================
// PicoevStreamWriter - StreamWriter implementation for picoev server
// Supports chunked transfer encoding for streaming responses
// Uses direct socket fd writes for immediate data delivery (true streaming)
@[heap]
pub struct PicoevStreamWriter {
mut:
fd int // Socket file descriptor for direct writes
connected bool
}
// PicoevStreamWriter.new - Create a new PicoevStreamWriter with the given response
// Extracts the fd from picohttpparser.Response for direct socket writes
pub fn PicoevStreamWriter.new(res &picohttpparser.Response) &PicoevStreamWriter {
return &PicoevStreamWriter{
fd: res.fd
connected: true
}
}
// write - Write raw bytes to the stream using chunked transfer encoding
// Format: {hex_size}\r\n{data}\r\n
// Writes directly to socket fd for immediate delivery
pub fn (mut w PicoevStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
// Write directly to socket fd
w.write_to_fd(chunk_header.bytes())!
w.write_to_fd(data)!
w.write_to_fd('\r\n'.bytes())!
}
// write_string - Write a string to the stream using chunked transfer encoding
pub fn (mut w PicoevStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
// Write directly to socket fd
w.write_to_fd(chunk_header.bytes())!
w.write_to_fd(data.bytes())!
w.write_to_fd('\r\n'.bytes())!
}
// write_to_fd - Write bytes directly to the socket file descriptor
// This ensures immediate delivery without buffering
@[inline]
fn (mut w PicoevStreamWriter) write_to_fd(data []u8) ! {
if data.len == 0 {
return
}
mut total_written := 0
for total_written < data.len {
remaining := data.len - total_written
ptr := unsafe { &u8(data.data) + total_written }
written := C.send(w.fd, ptr, remaining, 0)
if written < 0 {
w.connected = false
return error('Failed to write to socket')
}
if written == 0 {
w.connected = false
return error('Connection closed by peer')
}
total_written += written
}
}
// flush - Flush the buffer (direct fd writes are already unbuffered)
pub fn (mut w PicoevStreamWriter) flush() ! {
// Direct fd writes are unbuffered, no explicit flush needed
// But we can use TCP_NODELAY to disable Nagle's algorithm if needed
}
// close - Close the stream by sending the final chunk
// Final chunk format: 0\r\n\r\n
pub fn (mut w PicoevStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.write_to_fd('0\r\n\r\n'.bytes()) or {}
w.connected = false
// Note: Don't close the fd here, picoev manages the connection lifecycle
}
}
// is_connected - Check if the connection is still active
pub fn (w PicoevStreamWriter) is_connected() bool {
return w.connected
}
// ============================================================================
// uSockets StreamWriter Implementation
// ============================================================================
// UsocketsStreamWriter - StreamWriter implementation for uSockets server
// Supports chunked transfer encoding for streaming responses
@[heap]
pub struct UsocketsStreamWriter {
mut:
socket usockets.Socket
connected bool
}
// UsocketsStreamWriter.new - Create a new UsocketsStreamWriter with the given socket
pub fn UsocketsStreamWriter.new(socket usockets.Socket) &UsocketsStreamWriter {
return &UsocketsStreamWriter{
socket: socket
connected: true
}
}
// write - Write raw bytes to the stream using chunked transfer encoding
// Format: {hex_size}\r\n{data}\r\n
pub fn (mut w UsocketsStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data.bytestr())
w.socket.write_bytes('\r\n')
}
// write_string - Write a string to the stream using chunked transfer encoding
pub fn (mut w UsocketsStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data)
w.socket.write_bytes('\r\n')
}
// flush - Flush the buffer (uSockets handles this automatically)
pub fn (mut w UsocketsStreamWriter) flush() ! {
// uSockets automatically flushes data
// No explicit flush needed
}
// close - Close the stream by sending the final chunk
// Final chunk format: 0\r\n\r\n
pub fn (mut w UsocketsStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.socket.write_bytes('0\r\n\r\n')
w.connected = false
}
}
// is_connected - Check if the connection is still active
pub fn (w UsocketsStreamWriter) is_connected() bool {
return w.connected
}
// mark_disconnected - Mark the connection as disconnected
// Called by server backends when client disconnects
pub fn (mut w UsocketsStreamWriter) mark_disconnected() {
w.connected = false
}
// ============================================================================
// Helper Functions for Chunked Encoding
// ============================================================================
// format_chunk_size - Format the chunk size as hex followed by CRLF
// This is used for HTTP chunked transfer encoding
@[inline]
fn format_chunk_size(size int) string {
return '${size:x}\r\n'
}
// format_chunk - Format a complete chunk with size, data, and trailing CRLF
// Returns the full chunk in chunked transfer encoding format
pub fn format_chunk(data []u8) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data.bytestr()}\r\n'
}
// format_chunk_string - Format a string as a complete chunk
pub fn format_chunk_string(data string) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data}\r\n'
}
// format_final_chunk - Return the final chunk marker for chunked encoding
pub fn format_final_chunk() string {
return '0\r\n\r\n'
}
// ============================================================================
// Streaming Helper Functions
// ============================================================================
// StreamCallback - Callback function type for streaming
// The callback receives a mutable StreamContext and can write data to the stream
pub type StreamCallback = fn (mut StreamContext) !
// StreamErrorHandler - Error handler function type for streaming
// Called when an error occurs during streaming
pub type StreamErrorHandler = fn (err IError, mut ctx StreamContext)
// StreamResponse - Response type for streaming operations
// Contains the headers that should be sent before streaming begins
pub struct StreamResponse {
pub:
status_code int = 200
headers map[string]string
is_stream bool = true
}
// stream - Basic binary streaming function
// Sets Transfer-Encoding: chunked and executes the callback with a StreamContext
//
// Parameters:
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
// callback: The callback function that writes data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// StreamResponse with the appropriate headers
//
// Example:
// stream(writer, fn (mut stream) ! {
// stream.write('Hello'.bytes())!
// stream.sleep(100)
// stream.write('World'.bytes())!
// })
pub fn stream(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for streaming
return StreamResponse{
status_code: 200
headers: {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
is_stream: true
}
}
// stream_with_headers - Stream with custom initial headers
// Allows setting additional headers before streaming begins
pub fn stream_with_headers(mut writer StreamWriter, headers map[string]string, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Merge headers with required streaming headers
mut response_headers := headers.clone()
response_headers['Transfer-Encoding'] = 'chunked'
if 'Connection' !in response_headers {
response_headers['Connection'] = 'keep-alive'
}
return StreamResponse{
status_code: 200
headers: response_headers
is_stream: true
}
}
// get_stream_headers - Get the headers required for basic streaming
// Returns a map of headers that should be set for chunked transfer encoding
pub fn get_stream_headers() map[string]string {
return {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
}
// write_stream_headers - Write HTTP headers for streaming response
// This is a helper function to write the initial HTTP response line and headers
// before starting chunked data transfer
//
// Parameters:
// writer: The StreamWriter to write headers to
// status_code: HTTP status code (default 200)
// headers: Map of header name to value
pub fn write_stream_headers(mut writer StreamWriter, status_code int, headers map[string]string) ! {
// Build HTTP response line
status_text := get_status_text(status_code)
mut response := 'HTTP/1.1 ${status_code} ${status_text}\r\n'
// Add headers
for key, value in headers {
response += '${key}: ${value}\r\n'
}
// Add required streaming headers if not present
if 'Transfer-Encoding' !in headers {
response += 'Transfer-Encoding: chunked\r\n'
}
if 'Connection' !in headers {
response += 'Connection: keep-alive\r\n'
}
// End headers section
response += '\r\n'
// Write headers directly (not chunked)
// Note: Headers are written raw, not using chunked encoding
// The chunked encoding starts after the headers
writer.write_string(response)!
}
// stream_text - Text streaming function
// Sets Content-Type: text/plain; charset=utf-8, Transfer-Encoding: chunked,
// and X-Content-Type-Options: nosniff headers, then executes the callback
// with a StreamContext for text streaming.
//
// Parameters:
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
// callback: The callback function that writes text data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// StreamResponse with the appropriate headers for text streaming
//
// Example:
// stream_text(writer, fn (mut stream) ! {
// stream.write_string('Hello ')!
// stream.sleep(100)
// stream.writeln('World')!
// })
pub fn stream_text(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE StreamText Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for text streaming
return StreamResponse{
status_code: 200
headers: {
'Content-Type': 'text/plain; charset=utf-8'
'Transfer-Encoding': 'chunked'
'X-Content-Type-Options': 'nosniff'
'Connection': 'keep-alive'
}
is_stream: true
}
}
// get_stream_text_headers - Get the headers required for text streaming
// Returns a map of headers that should be set for text streaming with chunked transfer encoding
pub fn get_stream_text_headers() map[string]string {
return {
'Content-Type': 'text/plain; charset=utf-8'
'Transfer-Encoding': 'chunked'
'X-Content-Type-Options': 'nosniff'
'Connection': 'keep-alive'
}
}
// stream_sse - Server-Sent Events streaming function
// Sets Content-Type: text/event-stream, Cache-Control: no-cache,
// and Connection: keep-alive headers, then executes the callback
// with a StreamContext for SSE streaming.
//
// Parameters:
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
// callback: The callback function that writes SSE events to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// StreamResponse with the appropriate headers for SSE streaming
//
// Example:
// stream_sse(writer, fn (mut stream) ! {
// stream.write_sse(SSEEvent{
// data: 'Hello World'
// event: 'message'
// id: '1'
// })!
// stream.sleep(1000)
// stream.write_sse(SSEEvent{
// data: 'Another message'
// event: 'update'
// id: '2'
// })!
// })
pub fn stream_sse(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE StreamSSE Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for SSE streaming
// Requirements 3.1, 3.2, 3.3:
// - Content-Type: text/event-stream
// - Cache-Control: no-cache
// - Connection: keep-alive
return StreamResponse{
status_code: 200
headers: {
'Content-Type': 'text/event-stream'
'Cache-Control': 'no-cache'
'Connection': 'keep-alive'
'Transfer-Encoding': 'chunked'
}
is_stream: true
}
}
// get_stream_sse_headers - Get the headers required for SSE streaming
// Returns a map of headers that should be set for Server-Sent Events streaming
pub fn get_stream_sse_headers() map[string]string {
return {
'Content-Type': 'text/event-stream'
'Cache-Control': 'no-cache'
'Connection': 'keep-alive'
'Transfer-Encoding': 'chunked'
}
}
// ============================================================================
// Context-Based Streaming Functions
// ============================================================================
// These functions are designed to be used in route handlers and integrate
// with the picoev and uSockets server backends.
// StreamType - Type of streaming response
pub enum StreamType {
basic // Basic binary streaming
text // Text streaming with text/plain content type
sse // Server-Sent Events streaming
}
// StreamConfig - Configuration stored in Context for streaming responses
// This is used internally by the server backends to detect and handle streaming
pub struct StreamConfig {
pub:
stream_type StreamType
callback StreamCallback = unsafe { nil }
error_handler ?StreamErrorHandler
}
// Global storage for stream callbacks (workaround for V's limitations with storing functions in maps)
// Key is a unique identifier stored in Context.store['_stream_id']
// Using shared map for thread-safe access
struct StreamCallbackStorage {
mut:
callbacks shared map[string]StreamConfig
}
// Module-level storage instance
const stream_storage = &StreamCallbackStorage{}
// store_stream_config - Store a stream configuration
fn store_stream_config(id string, config StreamConfig) {
lock stream_storage.callbacks {
stream_storage.callbacks[id] = config
}
}
// retrieve_stream_config - Retrieve a stream configuration by ID
fn retrieve_stream_config(id string) ?StreamConfig {
rlock stream_storage.callbacks {
if id in stream_storage.callbacks {
return stream_storage.callbacks[id]
}
}
return none
}
// remove_stream_config - Remove a stream configuration by ID
fn remove_stream_config(id string) {
lock stream_storage.callbacks {
stream_storage.callbacks.delete(id)
}
}
// generate_stream_id - Generate a unique stream ID
fn generate_stream_id() string {
return '${time.now().unix_milli()}_${rand_int()}'
}
// rand_int - Simple random integer generator
fn rand_int() int {
return int(time.now().unix_nano() % 1000000)
}
// c_stream - Basic binary streaming function for use in route handlers
// Sets Transfer-Encoding: chunked and stores the callback for execution by the server backend
//
// Parameters:
// c: The request Context
// callback: The callback function that writes data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// http.Response with streaming marker headers
//
// Example:
// app.get('/stream', fn (mut c hono.Context) http.Response {
// return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// stream.write('Hello'.bytes())!
// stream.sleep(100)
// stream.write('World'.bytes())!
// })
// })
pub fn c_stream(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
// Generate unique stream ID
stream_id := generate_stream_id()
// Store stream configuration
config := StreamConfig{
stream_type: .basic
callback: callback
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
}
store_stream_config(stream_id, config)
// Mark context as streaming
c.store['_stream'] = 'true'
c.store['_stream_id'] = stream_id
c.store['_stream_type'] = 'basic'
// Set streaming headers
c.headers['Transfer-Encoding'] = 'chunked'
c.headers['Connection'] = 'keep-alive'
// Return a marker response (body will be ignored, streaming will be handled by server)
mut headers := http.new_header()
headers.add_custom('Transfer-Encoding', 'chunked') or {}
headers.add_custom('Connection', 'keep-alive') or {}
return http.Response{
status_code: 200
header: headers
body: ''
}
}
// c_stream_text - Text streaming function for use in route handlers
// Sets Content-Type: text/plain; charset=utf-8, Transfer-Encoding: chunked,
// and X-Content-Type-Options: nosniff headers
//
// Parameters:
// c: The request Context
// callback: The callback function that writes text data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// http.Response with streaming marker headers
//
// Example:
// app.get('/stream-text', fn (mut c hono.Context) http.Response {
// return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
// stream.write_string('Hello ')!
// stream.sleep(100)
// stream.writeln('World')!
// })
// })
pub fn c_stream_text(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
// Generate unique stream ID
stream_id := generate_stream_id()
// Store stream configuration
config := StreamConfig{
stream_type: .text
callback: callback
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
}
store_stream_config(stream_id, config)
// Mark context as streaming
c.store['_stream'] = 'true'
c.store['_stream_id'] = stream_id
c.store['_stream_type'] = 'text'
// Set streaming headers
c.headers['Content-Type'] = 'text/plain; charset=utf-8'
c.headers['Transfer-Encoding'] = 'chunked'
c.headers['X-Content-Type-Options'] = 'nosniff'
c.headers['Connection'] = 'keep-alive'
// Return a marker response
mut headers := http.new_header()
headers.add_custom('Content-Type', 'text/plain; charset=utf-8') or {}
headers.add_custom('Transfer-Encoding', 'chunked') or {}
headers.add_custom('X-Content-Type-Options', 'nosniff') or {}
headers.add_custom('Connection', 'keep-alive') or {}
return http.Response{
status_code: 200
header: headers
body: ''
}
}
// c_stream_sse - Server-Sent Events streaming function for use in route handlers
// Sets Content-Type: text/event-stream, Cache-Control: no-cache,
// and Connection: keep-alive headers
//
// Parameters:
// c: The request Context
// callback: The callback function that writes SSE events to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// http.Response with streaming marker headers
//
// Example:
// app.get('/sse', fn (mut c hono.Context) http.Response {
// return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// stream.write_sse(hono.SSEEvent{
// data: 'Hello World'
// event: 'message'
// id: '1'
// })!
// stream.sleep(1000)
// stream.write_sse(hono.SSEEvent{
// data: 'Another message'
// event: 'update'
// id: '2'
// })!
// })
// })
pub fn c_stream_sse(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
// Generate unique stream ID
stream_id := generate_stream_id()
// Store stream configuration
config := StreamConfig{
stream_type: .sse
callback: callback
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
}
store_stream_config(stream_id, config)
// Mark context as streaming
c.store['_stream'] = 'true'
c.store['_stream_id'] = stream_id
c.store['_stream_type'] = 'sse'
// Set SSE headers (Requirements 3.1, 3.2, 3.3)
c.headers['Content-Type'] = 'text/event-stream'
c.headers['Cache-Control'] = 'no-cache'
c.headers['Connection'] = 'keep-alive'
c.headers['Transfer-Encoding'] = 'chunked'
// Return a marker response
mut headers := http.new_header()
headers.add_custom('Content-Type', 'text/event-stream') or {}
headers.add_custom('Cache-Control', 'no-cache') or {}
headers.add_custom('Connection', 'keep-alive') or {}
headers.add_custom('Transfer-Encoding', 'chunked') or {}
return http.Response{
status_code: 200
header: headers
body: ''
}
}
// is_streaming_response - Check if a Context represents a streaming response
// Used by server backends to detect streaming responses
pub fn is_streaming_response(ctx Context) bool {
return '_stream' in ctx.store && ctx.store['_stream'] == 'true'
}
// get_stream_config - Get the stream configuration from Context
// Returns the StreamConfig if this is a streaming response, none otherwise
pub fn get_stream_config(ctx Context) ?StreamConfig {
if !is_streaming_response(ctx) {
return none
}
stream_id := ctx.store['_stream_id'] or { return none }
return retrieve_stream_config(stream_id)
}
// cleanup_stream_config - Remove stream configuration after use
// Should be called by server backends after streaming is complete
pub fn cleanup_stream_config(ctx Context) {
if stream_id := ctx.store['_stream_id'] {
remove_stream_config(stream_id)
}
}
// execute_stream - Execute a streaming response with the given writer
// This is the main entry point for server backends to handle streaming
//
// Parameters:
// ctx: The request Context with streaming configuration
// writer: The StreamWriter implementation for the server backend
//
// Returns:
// true if streaming was executed, false if not a streaming response
pub fn execute_stream(ctx Context, mut writer StreamWriter) bool {
config := get_stream_config(ctx) or { return false }
// Create StreamContext with the writer
mut stream_ctx := StreamContext.new(writer)
// Execute the callback and handle errors
config.callback(mut stream_ctx) or {
// Handle error
if error_handler := config.error_handler {
error_handler(err, mut stream_ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
stream_ctx.close()
// Cleanup the stored configuration
cleanup_stream_config(ctx)
return true
}
@@ -0,0 +1,434 @@
// test_picoev_stream_writer_properties.v
// Property-Based Testing for PicoevStreamWriter chunked encoding
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 6.2
module main
import rand
import time
const test_iterations = 100
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=== PicoevStreamWriter 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Chunked Encoding Functions (copied from streaming.v for testing)
// ============================================================================
// format_chunk_size - Format the chunk size as hex followed by CRLF
@[inline]
fn format_chunk_size(size int) string {
return '${size:x}\r\n'
}
// format_chunk - Format a complete chunk with size, data, and trailing CRLF
fn format_chunk(data []u8) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data.bytestr()}\r\n'
}
// format_chunk_string - Format a string as a complete chunk
fn format_chunk_string(data string) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data}\r\n'
}
// format_final_chunk - Return the final chunk marker for chunked encoding
fn format_final_chunk() string {
return '0\r\n\r\n'
}
// ============================================================================
// Random Data Generators
// ============================================================================
// Generate random bytes for testing
fn generate_random_bytes(max_len int) []u8 {
len := rand.int_in_range(1, max_len) or { 10 }
mut data := []u8{len: len}
for i in 0 .. len {
data[i] = u8(rand.int_in_range(0, 256) or { 0 })
}
return data
}
// Generate random string for testing (printable ASCII only to avoid encoding issues)
fn generate_random_string(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
len := rand.int_in_range(1, max_len) 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
}
// ============================================================================
// Chunk Parsing Helper
// ============================================================================
// Parse a chunked encoded string and extract the data
// Returns the extracted data and whether parsing was successful
fn parse_chunked_data(chunked string) !([]u8, bool) {
// Find the size part (before first \r\n)
mut crlf_pos := -1
for i := 0; i < chunked.len - 1; i++ {
if chunked[i] == `\r` && chunked[i + 1] == `\n` {
crlf_pos = i
break
}
}
if crlf_pos == -1 {
return error('No CRLF found in chunk header')
}
size_hex := chunked[..crlf_pos]
size := parse_hex(size_hex) or {
return error('Invalid hex size: ${size_hex}')
}
// Extract data after the first \r\n
data_start := crlf_pos + 2
data_end := data_start + size
if data_end > chunked.len {
return error('Chunk data incomplete')
}
data := chunked[data_start..data_end].bytes()
// Verify trailing \r\n
if data_end + 2 > chunked.len {
return error('Missing trailing CRLF')
}
trailing := chunked[data_end..data_end + 2]
if trailing != '\r\n' {
return error('Invalid trailing CRLF: got "${trailing}"')
}
return data, true
}
// Parse hex string to int
fn parse_hex(hex_str string) !int {
mut result := 0
for c in hex_str {
result *= 16
if c >= `0` && c <= `9` {
result += int(c - `0`)
} else if c >= `a` && c <= `f` {
result += int(c - `a` + 10)
} else if c >= `A` && c <= `F` {
result += int(c - `A` + 10)
} else {
return error('Invalid hex character: ${c.ascii_str()}')
}
}
return result
}
// ============================================================================
// Property 10: Chunked 编码格式正确性
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 6.2
//
// *For any* data written to the stream, the output SHALL follow HTTP chunked
// transfer encoding format:
// - Each chunk starts with the hex size followed by \r\n
// - Chunk data followed by \r\n
// - Final chunk is 0\r\n\r\n
// ============================================================================
// Test 10a: format_chunk produces valid chunked encoding for any byte array
fn test_property_10a_format_chunk_bytes() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random data
data := generate_random_bytes(1000)
// Format as chunk
chunk := format_chunk(data)
// Verify chunk format
// 1. Should start with hex size
mut crlf_pos := -1
for j := 0; j < chunk.len - 1; j++ {
if chunk[j] == `\r` && chunk[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found in chunk header')
return false
}
size_hex := chunk[..crlf_pos]
parsed_size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
return false
}
// 2. Size should match data length
if parsed_size != data.len {
println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
return false
}
// 3. Data should be extractable and match original
extracted_data, _ := parse_chunked_data(chunk) or {
println(' Iteration ${i}: Failed to parse chunk: ${err}')
return false
}
if extracted_data != data {
println(' Iteration ${i}: Data mismatch after extraction')
return false
}
}
return true
}
// Test 10b: format_chunk_string produces valid chunked encoding for any string
fn test_property_10b_format_chunk_string() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate random string
data := generate_random_string(500)
// Format as chunk
chunk := format_chunk_string(data)
// Verify chunk format
mut crlf_pos := -1
for j := 0; j < chunk.len - 1; j++ {
if chunk[j] == `\r` && chunk[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found in chunk header')
return false
}
size_hex := chunk[..crlf_pos]
parsed_size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
return false
}
// Size should match string byte length
if parsed_size != data.len {
println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
return false
}
// Extract and verify data
extracted_data, _ := parse_chunked_data(chunk) or {
println(' Iteration ${i}: Failed to parse chunk: ${err}')
return false
}
if extracted_data.bytestr() != data {
println(' Iteration ${i}: String mismatch after extraction')
return false
}
}
return true
}
// Test 10c: format_final_chunk produces correct terminator
fn test_property_10c_final_chunk_format() bool {
final_chunk := format_final_chunk()
// Final chunk must be exactly "0\r\n\r\n"
expected := '0\r\n\r\n'
if final_chunk != expected {
println(' Final chunk mismatch - expected "${expected}", got "${final_chunk}"')
return false
}
// Verify it starts with zero size
if !final_chunk.starts_with('0\r\n') {
println(' Final chunk should start with "0\\r\\n"')
return false
}
// Verify it ends with empty line (double CRLF after size)
if !final_chunk.ends_with('\r\n\r\n') {
println(' Final chunk should end with "\\r\\n\\r\\n"')
return false
}
return true
}
// Test 10d: Empty data produces empty string (no chunk)
fn test_property_10d_empty_data_handling() bool {
// Empty byte array
empty_bytes := []u8{}
chunk_bytes := format_chunk(empty_bytes)
if chunk_bytes != '' {
println(' Empty bytes should produce empty string, got "${chunk_bytes}"')
return false
}
// Empty string
chunk_string := format_chunk_string('')
if chunk_string != '' {
println(' Empty string should produce empty string, got "${chunk_string}"')
return false
}
return true
}
// Test 10e: Chunk size is correctly formatted as lowercase hex
fn test_property_10e_hex_format() bool {
rand.seed([u32(time.now().unix()), u32(98765)])
// Test various sizes including edge cases
test_sizes := [1, 15, 16, 255, 256, 4095, 4096, 65535]
for size in test_sizes {
// Create data of specific size
mut data := []u8{len: size}
for i in 0 .. size {
data[i] = u8(i % 256)
}
chunk := format_chunk(data)
// Extract hex size
mut crlf_pos := -1
for j := 0; j < chunk.len - 1; j++ {
if chunk[j] == `\r` && chunk[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Size ${size}: No CRLF found')
return false
}
size_hex := chunk[..crlf_pos]
// Verify it's valid lowercase hex
for c in size_hex {
is_digit := c >= `0` && c <= `9`
is_lower_hex := c >= `a` && c <= `f`
if !is_digit && !is_lower_hex {
println(' Size ${size}: Invalid hex character "${c.ascii_str()}" in "${size_hex}"')
return false
}
}
// Verify parsed value matches
parsed := parse_hex(size_hex) or {
println(' Size ${size}: Failed to parse hex "${size_hex}"')
return false
}
if parsed != size {
println(' Size ${size}: Hex "${size_hex}" parsed as ${parsed}')
return false
}
}
return true
}
// Test 10f: Round-trip property - chunk then parse returns original data
fn test_property_10f_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
for i in 0 .. test_iterations {
// Generate random data
original := generate_random_bytes(500)
// Format as chunk
chunk := format_chunk(original)
// Parse back
recovered, _ := parse_chunked_data(chunk) or {
println(' Iteration ${i}: Failed to parse: ${err}')
return false
}
// Verify round-trip
if recovered != original {
println(' Iteration ${i}: Round-trip failed - data mismatch')
return false
}
}
return true
}
fn main() {
println('🚀 开始 PicoevStreamWriter 属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 10 tests
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 6.2
stats.run_property_test('Property 10a: format_chunk bytes encoding', test_property_10a_format_chunk_bytes)
stats.run_property_test('Property 10b: format_chunk_string encoding', test_property_10b_format_chunk_string)
stats.run_property_test('Property 10c: Final chunk format', test_property_10c_final_chunk_format)
stats.run_property_test('Property 10d: Empty data handling', test_property_10d_empty_data_handling)
stats.run_property_test('Property 10e: Hex format correctness', test_property_10e_hex_format)
stats.run_property_test('Property 10f: Round-trip property', test_property_10f_roundtrip)
// Print summary
stats.print_summary()
}
@@ -0,0 +1,761 @@
// test_stream_error_handling_properties.v
// Property-Based Testing for Error Handling in SSE Streaming
// Feature: sse-streaming-helper
// Property 8: 错误处理器调用
// Property 9: 流自动关闭
// Validates: Requirements 4.1, 1.5, 5.2
module main
import rand
import time
const test_iterations = 100
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=== 错误处理属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Mock StreamWriter for Testing
// ============================================================================
// MockStreamWriter - A mock implementation of StreamWriter for testing
// Captures all written data for verification
struct MockStreamWriter {
mut:
written_data []u8
connected bool
closed bool
flush_count int
}
fn MockStreamWriter.new() &MockStreamWriter {
return &MockStreamWriter{
written_data: []u8{}
connected: true
closed: false
flush_count: 0
}
}
fn (mut w MockStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data.bytes()
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) flush() ! {
w.flush_count++
}
fn (mut w MockStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.written_data << '0\r\n\r\n'.bytes()
w.connected = false
w.closed = true
}
}
fn (w MockStreamWriter) is_connected() bool {
return w.connected
}
fn (w MockStreamWriter) get_written_string() string {
return w.written_data.bytestr()
}
// ============================================================================
// StreamContext for Testing (simplified version)
// ============================================================================
// StreamContext - Context provided to streaming callbacks
@[heap]
struct StreamContext {
mut:
writer &MockStreamWriter
is_closed bool
abort_fn ?fn ()
}
// StreamContext.new - Create a new StreamContext with the given writer
fn StreamContext.new(writer &MockStreamWriter) StreamContext {
return StreamContext{
writer: unsafe { writer }
is_closed: false
abort_fn: none
}
}
// write - Write raw bytes to the stream
fn (mut ctx StreamContext) write(data []u8) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write(data)!
}
// write_string - Write a string to the stream
fn (mut ctx StreamContext) write_string(data string) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write_string(data)!
}
// close - Close the stream
fn (mut ctx StreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// is_open - Check if the stream is still open
fn (ctx StreamContext) is_open() bool {
return !ctx.is_closed && ctx.writer.is_connected()
}
// ============================================================================
// Callback and Error Handler Types
// ============================================================================
// StreamCallback - Callback function type for streaming
type StreamCallback = fn (mut StreamContext) !
// StreamErrorHandler - Error handler function type for streaming
type StreamErrorHandler = fn (err IError, mut ctx StreamContext)
// ============================================================================
// Stream Response Type
// ============================================================================
struct StreamResponse {
pub:
status_code int = 200
headers map[string]string
is_stream bool = true
}
// ============================================================================
// Streaming Helper Functions (for testing)
// ============================================================================
// stream - Basic binary streaming function with error handling
fn stream(mut writer MockStreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for streaming
return StreamResponse{
status_code: 200
headers: {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
is_stream: true
}
}
// ============================================================================
// Error Tracking for Tests
// ============================================================================
// Global variables to track error handler calls
__global error_handler_called = false
__global error_handler_error_msg = ''
__global error_handler_stream_open = false
__global error_handler_write_success = false
fn reset_error_tracking() {
error_handler_called = false
error_handler_error_msg = ''
error_handler_stream_open = false
error_handler_write_success = false
}
// ============================================================================
// Helper Functions
// ============================================================================
// Generate random error message
fn generate_random_error_message(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-.'
len := rand.int_in_range(5, max_len) or { 20 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// Generate random string for testing
fn generate_random_string(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
len := rand.int_in_range(1, max_len) 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
}
// ============================================================================
// Property 8: 错误处理器调用
// Feature: sse-streaming-helper, Property 8
// Validates: Requirements 4.1
//
// *For any* callback that throws an error, if an error handler is provided,
// the error handler SHALL be called with the error and stream context.
// ============================================================================
// Test 8a: Custom error handler is called when callback throws error
fn test_property_8a_error_handler_called() bool {
rand.seed([u32(time.now().unix()), u32(88881)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Generate random error message
error_msg := generate_random_error_message(50)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Define error handler that tracks the call
error_handler := fn (err IError, mut ctx StreamContext) {
error_handler_called = true
error_handler_error_msg = err.msg()
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
// Verify error message was passed correctly
if error_handler_error_msg != error_msg {
println(' Iteration ${i}: Error message mismatch')
println(' Expected: ${error_msg}')
println(' Got: ${error_handler_error_msg}')
return false
}
}
return true
}
// Test 8b: Error handler receives valid stream context
fn test_property_8b_error_handler_receives_context() bool {
rand.seed([u32(time.now().unix()), u32(88882)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn (mut ctx StreamContext) ! {
return error('Test error')
}
// Define error handler that checks stream context
error_handler := fn (err IError, mut ctx StreamContext) {
error_handler_called = true
error_handler_stream_open = ctx.is_open()
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
// Verify stream was still open when error handler was called
if !error_handler_stream_open {
println(' Iteration ${i}: Stream should be open when error handler is called')
return false
}
}
return true
}
// Test 8c: Error handler can write to stream before it closes
fn test_property_8c_error_handler_can_write() bool {
rand.seed([u32(time.now().unix()), u32(88883)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Generate random error response message
error_response := generate_random_string(30)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn (mut ctx StreamContext) ! {
return error('Test error')
}
// Define error handler that writes to stream
error_handler := fn [error_response] (err IError, mut ctx StreamContext) {
error_handler_called = true
ctx.write_string(error_response) or {
error_handler_write_success = false
return
}
error_handler_write_success = true
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
// Verify write was successful
if !error_handler_write_success {
println(' Iteration ${i}: Error handler failed to write to stream')
return false
}
// Verify the error response was written
written := writer.get_written_string()
if !written.contains(error_response) {
println(' Iteration ${i}: Error response not found in written data')
return false
}
}
return true
}
// Test 8d: Without error handler, error is logged (no crash)
fn test_property_8d_default_error_handling() bool {
rand.seed([u32(time.now().unix()), u32(88884)])
for i in 0 .. test_iterations {
// Generate random error message
error_msg := generate_random_error_message(50)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Call stream WITHOUT error handler - should not crash
// Default behavior logs to console via eprintln
response := stream(mut writer, callback)
// Verify stream completed (no crash)
if response.status_code != 200 {
println(' Iteration ${i}: Unexpected status code ${response.status_code}')
return false
}
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after error')
return false
}
}
return true
}
// Test 8e: Error handler called with correct error type
fn test_property_8e_error_type_preserved() bool {
rand.seed([u32(time.now().unix()), u32(88885)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Generate random error message
error_msg := generate_random_error_message(50)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Define error handler that checks error type
error_handler := fn [error_msg] (err IError, mut ctx StreamContext) {
error_handler_called = true
// Verify error message matches
if err.msg() == error_msg {
error_handler_write_success = true
}
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called with correct error
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
if !error_handler_write_success {
println(' Iteration ${i}: Error message was not preserved correctly')
return false
}
}
return true
}
// ============================================================================
// Property 9: 流自动关闭
// Feature: sse-streaming-helper, Property 9
// Validates: Requirements 1.5, 5.2
//
// *For any* stream where the callback completes normally, the stream SHALL be
// automatically closed after callback execution.
// ============================================================================
// Test 9a: Stream is closed after successful callback completion
fn test_property_9a_stream_closed_after_success() bool {
rand.seed([u32(time.now().unix()), u32(99991)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random data to write
data := generate_random_string(50)
// Define callback that completes successfully
callback := fn [data] (mut ctx StreamContext) ! {
ctx.write_string(data)!
}
// Call stream
_ := stream(mut writer, callback)
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after callback completes')
return false
}
// Verify writer is disconnected
if writer.connected {
println(' Iteration ${i}: Writer should be disconnected after close')
return false
}
}
return true
}
// Test 9b: Stream is closed after callback throws error
fn test_property_9b_stream_closed_after_error() bool {
rand.seed([u32(time.now().unix()), u32(99992)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random error message
error_msg := generate_random_error_message(30)
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Call stream (with or without error handler)
_ := stream(mut writer, callback)
// Verify stream was closed even after error
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after error')
return false
}
// Verify writer is disconnected
if writer.connected {
println(' Iteration ${i}: Writer should be disconnected after close')
return false
}
}
return true
}
// Test 9c: Final chunk marker is written when stream closes
fn test_property_9c_final_chunk_written() bool {
rand.seed([u32(time.now().unix()), u32(99993)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random data to write
data := generate_random_string(50)
// Define callback that completes successfully
callback := fn [data] (mut ctx StreamContext) ! {
ctx.write_string(data)!
}
// Call stream
_ := stream(mut writer, callback)
// Verify final chunk marker is present
written := writer.get_written_string()
if !written.ends_with('0\r\n\r\n') {
println(' Iteration ${i}: Missing final chunk marker')
return false
}
}
return true
}
// Test 9d: Stream closed only once (idempotent close)
fn test_property_9d_idempotent_close() bool {
rand.seed([u32(time.now().unix()), u32(99994)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that manually closes the stream
callback := fn (mut ctx StreamContext) ! {
ctx.write_string('test')!
ctx.close() // Manual close
}
// Call stream (which will also try to close)
_ := stream(mut writer, callback)
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed')
return false
}
// Count final chunk markers - should be exactly one
written := writer.get_written_string()
mut final_chunk_count := 0
mut pos := 0
for {
idx := written[pos..].index('0\r\n\r\n') or { break }
final_chunk_count++
pos += idx + 5
}
if final_chunk_count != 1 {
println(' Iteration ${i}: Expected 1 final chunk marker, got ${final_chunk_count}')
return false
}
}
return true
}
// Test 9e: Stream closed after error handler writes
fn test_property_9e_closed_after_error_handler_writes() bool {
rand.seed([u32(time.now().unix()), u32(99995)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random error response
error_response := generate_random_string(30)
// Define callback that throws an error
callback := fn (mut ctx StreamContext) ! {
return error('Test error')
}
// Define error handler that writes to stream
error_handler := fn [error_response] (err IError, mut ctx StreamContext) {
ctx.write_string(error_response) or {}
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify stream was closed after error handler
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after error handler')
return false
}
// Verify error response was written before close
written := writer.get_written_string()
if !written.contains(error_response) {
println(' Iteration ${i}: Error response should be written before close')
return false
}
// Verify final chunk marker is present
if !written.ends_with('0\r\n\r\n') {
println(' Iteration ${i}: Missing final chunk marker')
return false
}
}
return true
}
// Test 9f: Empty callback still closes stream
fn test_property_9f_empty_callback_closes() bool {
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Define empty callback
callback := fn (mut ctx StreamContext) ! {
// Do nothing
}
// Call stream
_ := stream(mut writer, callback)
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed even with empty callback')
return false
}
}
return true
}
fn main() {
println('🚀 开始错误处理属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 8 tests
// Feature: sse-streaming-helper, Property 8: 错误处理器调用
// Validates: Requirements 4.1
println('--- Property 8: 错误处理器调用 ---')
stats.run_property_test('Property 8a: Error handler called on error', test_property_8a_error_handler_called)
stats.run_property_test('Property 8b: Error handler receives context', test_property_8b_error_handler_receives_context)
stats.run_property_test('Property 8c: Error handler can write', test_property_8c_error_handler_can_write)
stats.run_property_test('Property 8d: Default error handling', test_property_8d_default_error_handling)
stats.run_property_test('Property 8e: Error type preserved', test_property_8e_error_type_preserved)
println('')
// Run Property 9 tests
// Feature: sse-streaming-helper, Property 9: 流自动关闭
// Validates: Requirements 1.5, 5.2
println('--- Property 9: 流自动关闭 ---')
stats.run_property_test('Property 9a: Stream closed after success', test_property_9a_stream_closed_after_success)
stats.run_property_test('Property 9b: Stream closed after error', test_property_9b_stream_closed_after_error)
stats.run_property_test('Property 9c: Final chunk written', test_property_9c_final_chunk_written)
stats.run_property_test('Property 9d: Idempotent close', test_property_9d_idempotent_close)
stats.run_property_test('Property 9e: Closed after error handler writes', test_property_9e_closed_after_error_handler_writes)
stats.run_property_test('Property 9f: Empty callback closes stream', test_property_9f_empty_callback_closes)
// Print summary
stats.print_summary()
}
+586
View File
@@ -0,0 +1,586 @@
// test_stream_function_properties.v
// Property-Based Testing for stream() function
// Feature: sse-streaming-helper
// Property 1: stream() 设置正确的 Transfer-Encoding 头
// Property 2: write() 正确输出数据
// Validates: Requirements 1.1, 1.2
module main
import rand
import time
const test_iterations = 100
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=== stream() 函数属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Mock StreamWriter for Testing
// ============================================================================
// MockStreamWriter - A mock implementation of StreamWriter for testing
// Captures all written data for verification
struct MockStreamWriter {
mut:
written_data []u8
connected bool
closed bool
flush_count int
}
fn MockStreamWriter.new() &MockStreamWriter {
return &MockStreamWriter{
written_data: []u8{}
connected: true
closed: false
flush_count: 0
}
}
fn (mut w MockStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data.bytes()
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) flush() ! {
w.flush_count++
}
fn (mut w MockStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.written_data << '0\r\n\r\n'.bytes()
w.connected = false
w.closed = true
}
}
fn (w MockStreamWriter) is_connected() bool {
return w.connected
}
fn (w MockStreamWriter) get_written_string() string {
return w.written_data.bytestr()
}
// ============================================================================
// Helper Functions
// ============================================================================
// get_stream_headers - Get the headers required for basic streaming
fn get_stream_headers() map[string]string {
return {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
}
// Generate random bytes for testing
fn generate_random_bytes(max_len int) []u8 {
len := rand.int_in_range(1, max_len) or { 10 }
mut data := []u8{len: len}
for i in 0 .. len {
data[i] = u8(rand.int_in_range(0, 256) or { 0 })
}
return data
}
// Generate random string for testing (printable ASCII only)
fn generate_random_string(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
len := rand.int_in_range(1, max_len) 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
}
// Parse hex string to int
fn parse_hex(hex_str string) !int {
mut result := 0
for c in hex_str {
result *= 16
if c >= `0` && c <= `9` {
result += int(c - `0`)
} else if c >= `a` && c <= `f` {
result += int(c - `a` + 10)
} else if c >= `A` && c <= `F` {
result += int(c - `A` + 10)
} else {
return error('Invalid hex character: ${c.ascii_str()}')
}
}
return result
}
// Parse a chunked encoded string and extract all chunks
fn parse_all_chunks(chunked string) !([][]u8, bool) {
mut chunks := [][]u8{}
mut pos := 0
for pos < chunked.len {
// Find the size part (before \r\n)
mut crlf_pos := -1
for i := pos; i < chunked.len - 1; i++ {
if chunked[i] == `\r` && chunked[i + 1] == `\n` {
crlf_pos = i
break
}
}
if crlf_pos == -1 {
return error('No CRLF found in chunk header at position ${pos}')
}
size_hex := chunked[pos..crlf_pos]
size := parse_hex(size_hex) or {
return error('Invalid hex size: ${size_hex}')
}
// Check for final chunk
if size == 0 {
// Verify final chunk format
remaining := chunked[pos..]
if remaining != '0\r\n\r\n' {
return error('Invalid final chunk format')
}
break
}
// Extract data after the \r\n
data_start := crlf_pos + 2
data_end := data_start + size
if data_end > chunked.len {
return error('Chunk data incomplete')
}
data := chunked[data_start..data_end].bytes()
chunks << data
// Verify trailing \r\n
if data_end + 2 > chunked.len {
return error('Missing trailing CRLF')
}
trailing := chunked[data_end..data_end + 2]
if trailing != '\r\n' {
return error('Invalid trailing CRLF')
}
pos = data_end + 2
}
return chunks, true
}
// ============================================================================
// Property 1: stream() 设置正确的 Transfer-Encoding 头
// Feature: sse-streaming-helper, Property 1
// Validates: Requirements 1.1
//
// *For any* call to stream(c, callback), the response SHALL contain
// Transfer-Encoding: chunked header.
// ============================================================================
// Test 1a: get_stream_headers returns Transfer-Encoding: chunked
fn test_property_1a_transfer_encoding_header() bool {
headers := get_stream_headers()
// Verify Transfer-Encoding header exists
if 'Transfer-Encoding' !in headers {
println(' Transfer-Encoding header missing')
return false
}
// Verify value is 'chunked'
if headers['Transfer-Encoding'] != 'chunked' {
println(' Transfer-Encoding should be "chunked", got "${headers['Transfer-Encoding']}"')
return false
}
return true
}
// Test 1b: get_stream_headers returns Connection: keep-alive
fn test_property_1b_connection_header() bool {
headers := get_stream_headers()
// Verify Connection header exists
if 'Connection' !in headers {
println(' Connection header missing')
return false
}
// Verify value is 'keep-alive'
if headers['Connection'] != 'keep-alive' {
println(' Connection should be "keep-alive", got "${headers['Connection']}"')
return false
}
return true
}
// Test 1c: Headers are consistent across multiple calls
fn test_property_1c_headers_consistency() bool {
for _ in 0 .. test_iterations {
headers1 := get_stream_headers()
headers2 := get_stream_headers()
// Headers should be identical
if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
println(' Transfer-Encoding inconsistent between calls')
return false
}
if headers1['Connection'] != headers2['Connection'] {
println(' Connection inconsistent between calls')
return false
}
}
return true
}
// ============================================================================
// Property 2: write() 正确输出数据
// Feature: sse-streaming-helper, Property 2
// Validates: Requirements 1.2
//
// *For any* byte array data, when stream.write(data) is called, the output
// stream SHALL contain the exact bytes from data in chunked transfer encoding format.
// ============================================================================
// Test 2a: write() outputs data in chunked format for any byte array
fn test_property_2a_write_bytes_chunked() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random data
data := generate_random_bytes(500)
// Create mock writer and write data
mut writer := MockStreamWriter.new()
writer.write(data) or {
println(' Iteration ${i}: write failed: ${err}')
return false
}
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have exactly one chunk
if chunks.len != 1 {
println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
return false
}
// Chunk data should match original
if chunks[0] != data {
println(' Iteration ${i}: Data mismatch')
return false
}
}
return true
}
// Test 2b: write_string() outputs data in chunked format for any string
fn test_property_2b_write_string_chunked() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate random string
data := generate_random_string(500)
// Create mock writer and write data
mut writer := MockStreamWriter.new()
writer.write_string(data) or {
println(' Iteration ${i}: write_string failed: ${err}')
return false
}
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have exactly one chunk
if chunks.len != 1 {
println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
return false
}
// Chunk data should match original string
if chunks[0].bytestr() != data {
println(' Iteration ${i}: String mismatch')
return false
}
}
return true
}
// Test 2c: Multiple writes produce multiple chunks
fn test_property_2c_multiple_writes() bool {
rand.seed([u32(time.now().unix()), u32(98765)])
for i in 0 .. test_iterations {
// Generate multiple random data pieces
num_writes := rand.int_in_range(2, 10) or { 3 }
mut original_data := [][]u8{}
mut writer := MockStreamWriter.new()
for _ in 0 .. num_writes {
data := generate_random_bytes(100)
original_data << data
writer.write(data) or {
println(' Iteration ${i}: write failed: ${err}')
return false
}
}
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have same number of chunks as writes
if chunks.len != num_writes {
println(' Iteration ${i}: Expected ${num_writes} chunks, got ${chunks.len}')
return false
}
// Each chunk should match original data
for j, chunk in chunks {
if chunk != original_data[j] {
println(' Iteration ${i}: Chunk ${j} data mismatch')
return false
}
}
}
return true
}
// Test 2d: close() writes final chunk marker
fn test_property_2d_close_final_chunk() bool {
for _ in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
// Write some data
data := generate_random_bytes(50)
writer.write(data) or {
println(' write failed')
return false
}
// Close the writer
writer.close() or {
println(' close failed')
return false
}
// Verify final chunk marker is present
written := writer.get_written_string()
if !written.ends_with('0\r\n\r\n') {
println(' Missing final chunk marker')
return false
}
// Verify writer is marked as closed
if writer.connected {
println(' Writer should be disconnected after close')
return false
}
if !writer.closed {
println(' Writer should be marked as closed')
return false
}
}
return true
}
// Test 2e: Empty data is handled correctly (no chunk written)
fn test_property_2e_empty_data() bool {
mut writer := MockStreamWriter.new()
// Write empty byte array
writer.write([]u8{}) or {
println(' write empty bytes failed')
return false
}
// Write empty string
writer.write_string('') or {
println(' write empty string failed')
return false
}
// No data should be written for empty inputs
if writer.written_data.len != 0 {
println(' Empty data should not produce output, got ${writer.written_data.len} bytes')
return false
}
return true
}
// Test 2f: Round-trip property - write then parse returns original data
fn test_property_2f_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
for i in 0 .. test_iterations {
// Generate random data
original := generate_random_bytes(500)
// Write to mock writer
mut writer := MockStreamWriter.new()
writer.write(original) or {
println(' Iteration ${i}: write failed')
return false
}
writer.close() or {
println(' Iteration ${i}: close failed')
return false
}
// Parse back
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Iteration ${i}: Failed to parse: ${err}')
return false
}
// Verify round-trip
if chunks.len != 1 {
println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
return false
}
if chunks[0] != original {
println(' Iteration ${i}: Round-trip failed - data mismatch')
return false
}
}
return true
}
// Test 2g: Writing to closed connection returns error
fn test_property_2g_write_after_close() bool {
mut writer := MockStreamWriter.new()
// Close the writer
writer.close() or {
println(' close failed')
return false
}
// Try to write - should fail
writer.write('test'.bytes()) or {
// Expected error
return true
}
println(' Writing to closed connection should fail')
return false
}
fn main() {
println('🚀 开始 stream() 函数属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 1 tests
// Feature: sse-streaming-helper, Property 1: stream() 设置正确的 Transfer-Encoding 头
// Validates: Requirements 1.1
println('--- Property 1: stream() 设置正确的 Transfer-Encoding 头 ---')
stats.run_property_test('Property 1a: Transfer-Encoding header', test_property_1a_transfer_encoding_header)
stats.run_property_test('Property 1b: Connection header', test_property_1b_connection_header)
stats.run_property_test('Property 1c: Headers consistency', test_property_1c_headers_consistency)
println('')
// Run Property 2 tests
// Feature: sse-streaming-helper, Property 2: write() 正确输出数据
// Validates: Requirements 1.2
println('--- Property 2: write() 正确输出数据 ---')
stats.run_property_test('Property 2a: write() bytes chunked format', test_property_2a_write_bytes_chunked)
stats.run_property_test('Property 2b: write_string() chunked format', test_property_2b_write_string_chunked)
stats.run_property_test('Property 2c: Multiple writes produce chunks', test_property_2c_multiple_writes)
stats.run_property_test('Property 2d: close() writes final chunk', test_property_2d_close_final_chunk)
stats.run_property_test('Property 2e: Empty data handling', test_property_2e_empty_data)
stats.run_property_test('Property 2f: Round-trip property', test_property_2f_roundtrip)
stats.run_property_test('Property 2g: Write after close fails', test_property_2g_write_after_close)
// Print summary
stats.print_summary()
}
+988
View File
@@ -0,0 +1,988 @@
// test_stream_sse_properties.v
// Property-Based Testing for stream_sse() function
// Feature: sse-streaming-helper
// Property 6: streamSSE() 设置正确的 HTTP 头
// Property 7: SSE 事件格式化正确性
// Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
module main
import rand
const test_iterations = 100
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=== stream_sse() 函数属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Mock StreamWriter for Testing
// ============================================================================
// MockStreamWriter - A mock implementation of StreamWriter for testing
// Captures all written data for verification
struct MockStreamWriter {
mut:
written_data []u8
connected bool
closed bool
flush_count int
}
fn MockStreamWriter.new() &MockStreamWriter {
return &MockStreamWriter{
written_data: []u8{}
connected: true
closed: false
flush_count: 0
}
}
fn (mut w MockStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data.bytes()
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) flush() ! {
w.flush_count++
}
fn (mut w MockStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.written_data << '0\r\n\r\n'.bytes()
w.connected = false
w.closed = true
}
}
fn (w MockStreamWriter) is_connected() bool {
return w.connected
}
fn (w MockStreamWriter) get_written_string() string {
return w.written_data.bytestr()
}
// extract_sse_content - Extract the actual SSE content from chunked encoding
// Removes the chunk size headers and CRLF markers to get the raw SSE data
fn (w MockStreamWriter) extract_sse_content() string {
raw := w.written_data.bytestr()
mut result := []u8{}
mut i := 0
for i < raw.len {
// Find the chunk size (hex number before \r\n)
mut size_end := i
for size_end < raw.len && raw[size_end] != `\r` {
size_end++
}
if size_end >= raw.len {
break
}
// Parse chunk size from hex string
size_str := raw[i..size_end]
chunk_size := parse_hex_int(size_str)
if chunk_size == 0 {
// Final chunk
break
}
// Skip \r\n after size
i = size_end + 2
// Extract chunk data
if i + chunk_size <= raw.len {
result << raw[i..i + chunk_size].bytes()
}
// Skip chunk data and trailing \r\n
i = i + chunk_size + 2
}
return result.bytestr()
}
// parse_hex_int - Parse a hex string to integer
fn parse_hex_int(s string) int {
mut result := 0
for c in s {
result *= 16
if c >= `0` && c <= `9` {
result += int(c - `0`)
} else if c >= `a` && c <= `f` {
result += int(c - `a` + 10)
} else if c >= `A` && c <= `F` {
result += int(c - `A` + 10)
}
}
return result
}
// ============================================================================
// SSEEvent Structure for Testing
// ============================================================================
// SSEEvent - Server-Sent Events event structure
// Mirrors the implementation in streaming.v
struct SSEEvent {
data string // Event data (required) - supports multi-line
event string // Event type name (optional)
id string // Event ID for client reconnection (optional)
retry int // Reconnection time in milliseconds (optional, 0 = not set)
}
// ============================================================================
// StreamContext for Testing
// ============================================================================
// TestStreamContext - A test implementation of StreamContext
// Provides write_sse method for testing SSE event formatting
struct TestStreamContext {
mut:
writer &MockStreamWriter
is_closed bool
}
fn TestStreamContext.new(writer &MockStreamWriter) TestStreamContext {
return TestStreamContext{
writer: unsafe { writer }
is_closed: false
}
}
fn (mut ctx TestStreamContext) write_string(data string) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write_string(data)!
}
// write_sse - Write an SSE event to the stream
// Formats the event according to the SSE specification:
// - event: {type}\n (if event field is set)
// - data: {line}\n (for each line of data)
// - id: {id}\n (if id field is set)
// - retry: {ms}\n (if retry > 0)
// - \n (event separator)
fn (mut ctx TestStreamContext) write_sse(event SSEEvent) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
// Write event type if specified
if event.event.len > 0 {
ctx.writer.write_string('event: ${event.event}\n')!
}
// Write data field - handle multi-line data
lines := event.data.split('\n')
for line in lines {
ctx.writer.write_string('data: ${line}\n')!
}
// Write id if specified
if event.id.len > 0 {
ctx.writer.write_string('id: ${event.id}\n')!
}
// Write retry if specified (must be > 0)
if event.retry > 0 {
ctx.writer.write_string('retry: ${event.retry}\n')!
}
// Write event separator (empty line)
ctx.writer.write_string('\n')!
// Flush to ensure event is sent immediately
ctx.writer.flush()!
}
fn (mut ctx TestStreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// ============================================================================
// Random Data Generators for Property Testing
// ============================================================================
// generate_random_string - Generate a random alphanumeric string
fn generate_random_string(min_len int, max_len int) string {
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
mut result := []u8{cap: len}
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result << chars[idx]
}
return result.bytestr()
}
// generate_random_multiline_string - Generate a random string with newlines
fn generate_random_multiline_string(min_lines int, max_lines int) string {
num_lines := rand.int_in_range(min_lines, max_lines + 1) or { min_lines }
mut lines := []string{cap: num_lines}
for _ in 0 .. num_lines {
lines << generate_random_string(1, 20)
}
return lines.join('\n')
}
// generate_random_sse_event - Generate a random SSE event
fn generate_random_sse_event() SSEEvent {
// Randomly decide which optional fields to include
include_event := rand.int_in_range(0, 2) or { 0 } == 1
include_id := rand.int_in_range(0, 2) or { 0 } == 1
include_retry := rand.int_in_range(0, 2) or { 0 } == 1
is_multiline := rand.int_in_range(0, 3) or { 0 } == 0 // 1/3 chance of multiline
mut data := ''
if is_multiline {
data = generate_random_multiline_string(2, 5)
} else {
data = generate_random_string(1, 50)
}
return SSEEvent{
data: data
event: if include_event { generate_random_string(3, 15) } else { '' }
id: if include_id { generate_random_string(1, 10) } else { '' }
retry: if include_retry { rand.int_in_range(100, 10000) or { 1000 } } else { 0 }
}
}
// ============================================================================
// Helper Functions
// ============================================================================
// get_stream_sse_headers - Get the headers required for SSE streaming
// This mirrors the implementation in streaming.v
fn get_stream_sse_headers() map[string]string {
return {
'Content-Type': 'text/event-stream'
'Cache-Control': 'no-cache'
'Connection': 'keep-alive'
'Transfer-Encoding': 'chunked'
}
}
// ============================================================================
// Property 6: streamSSE() 设置正确的 HTTP 头
// Feature: sse-streaming-helper, Property 6
// Validates: Requirements 3.1, 3.2, 3.3
//
// *For any* call to streamSSE(c, callback), the response SHALL contain:
// - Content-Type: text/event-stream
// - Cache-Control: no-cache
// - Connection: keep-alive
// ============================================================================
// Test 6a: Content-Type header is set correctly for SSE
fn test_property_6a_content_type_header() bool {
headers := get_stream_sse_headers()
// Verify Content-Type header exists
if 'Content-Type' !in headers {
println(' Content-Type header missing')
return false
}
// Verify value is 'text/event-stream' (Requirement 3.1)
if headers['Content-Type'] != 'text/event-stream' {
println(' Content-Type should be "text/event-stream", got "${headers['Content-Type']}"')
return false
}
return true
}
// Test 6b: Cache-Control header is set correctly for SSE
fn test_property_6b_cache_control_header() bool {
headers := get_stream_sse_headers()
// Verify Cache-Control header exists
if 'Cache-Control' !in headers {
println(' Cache-Control header missing')
return false
}
// Verify value is 'no-cache' (Requirement 3.2)
if headers['Cache-Control'] != 'no-cache' {
println(' Cache-Control should be "no-cache", got "${headers['Cache-Control']}"')
return false
}
return true
}
// Test 6c: Connection header is set correctly for SSE
fn test_property_6c_connection_header() bool {
headers := get_stream_sse_headers()
// Verify Connection header exists
if 'Connection' !in headers {
println(' Connection header missing')
return false
}
// Verify value is 'keep-alive' (Requirement 3.3)
if headers['Connection'] != 'keep-alive' {
println(' Connection should be "keep-alive", got "${headers['Connection']}"')
return false
}
return true
}
// Test 6d: Transfer-Encoding header is set for chunked transfer
fn test_property_6d_transfer_encoding_header() bool {
headers := get_stream_sse_headers()
// Verify Transfer-Encoding header exists
if 'Transfer-Encoding' !in headers {
println(' Transfer-Encoding header missing')
return false
}
// Verify value is 'chunked'
if headers['Transfer-Encoding'] != 'chunked' {
println(' Transfer-Encoding should be "chunked", got "${headers['Transfer-Encoding']}"')
return false
}
return true
}
// Test 6e: Headers are consistent across multiple calls
fn test_property_6e_headers_consistency() bool {
for _ in 0 .. test_iterations {
headers1 := get_stream_sse_headers()
headers2 := get_stream_sse_headers()
// Headers should be identical
if headers1['Content-Type'] != headers2['Content-Type'] {
println(' Content-Type inconsistent between calls')
return false
}
if headers1['Cache-Control'] != headers2['Cache-Control'] {
println(' Cache-Control inconsistent between calls')
return false
}
if headers1['Connection'] != headers2['Connection'] {
println(' Connection inconsistent between calls')
return false
}
if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
println(' Transfer-Encoding inconsistent between calls')
return false
}
}
return true
}
// Test 6f: All required SSE headers are present
fn test_property_6f_all_required_headers_present() bool {
headers := get_stream_sse_headers()
required_headers := ['Content-Type', 'Cache-Control', 'Connection', 'Transfer-Encoding']
for header in required_headers {
if header !in headers {
println(' Required header "${header}" is missing')
return false
}
}
return true
}
// Test 6g: SSE headers differ from text streaming headers
fn test_property_6g_sse_headers_differ_from_text() bool {
sse_headers := get_stream_sse_headers()
// SSE should use text/event-stream, not text/plain
if sse_headers['Content-Type'] == 'text/plain; charset=utf-8' {
println(' SSE Content-Type should not be text/plain')
return false
}
// SSE should have Cache-Control: no-cache
if 'Cache-Control' !in sse_headers {
println(' SSE should have Cache-Control header')
return false
}
return true
}
// ============================================================================
// Property 7: SSE 事件格式化正确性
// Feature: sse-streaming-helper, Property 7
// Validates: Requirements 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
//
// *For any* SSEEvent with fields (data, event, id, retry), when stream.writeSSE(event) is called:
// - If event is non-empty, output SHALL start with event: {event}\n
// - Output SHALL contain data: {data}\n for each line of data
// - If id is non-empty, output SHALL contain id: {id}\n
// - If retry > 0, output SHALL contain retry: {retry}\n
// - Output SHALL end with an additional \n to separate events
// ============================================================================
// Test 7a: Data field is always written correctly (Requirement 3.5)
// *For any* SSE event with data, the output SHALL contain "data: {value}\n"
fn test_property_7a_data_field_formatting() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
// Generate random single-line data
data := generate_random_string(1, 50)
event := SSEEvent{
data: data
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
expected := 'data: ${data}\n'
if !content.contains(expected) {
println(' Iteration ${i}: Expected data line "${expected}" not found in output')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7b: Multi-line data is split correctly (Requirement 3.5)
// *For any* SSE event with multi-line data, each line SHALL be prefixed with "data: "
fn test_property_7b_multiline_data_formatting() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
// Generate random multi-line data
data := generate_random_multiline_string(2, 5)
event := SSEEvent{
data: data
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
lines := data.split('\n')
// Each line of data should be prefixed with "data: "
for line in lines {
expected := 'data: ${line}\n'
if !content.contains(expected) {
println(' Iteration ${i}: Expected data line "${expected}" not found')
println(' Original data: "${data}"')
println(' Got: "${content}"')
return false
}
}
}
return true
}
// Test 7c: Event field is written before data (Requirement 3.6)
// *For any* SSE event with event field, output SHALL start with "event: {value}\n"
fn test_property_7c_event_field_before_data() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event_type := generate_random_string(3, 15)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
event: event_type
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
event_line := 'event: ${event_type}\n'
data_line := 'data: ${data}\n'
// Event line should exist
if !content.contains(event_line) {
println(' Iteration ${i}: Event line "${event_line}" not found')
return false
}
// Event line should come before data line
event_pos := content.index(event_line) or { -1 }
data_pos := content.index(data_line) or { -1 }
if event_pos >= data_pos {
println(' Iteration ${i}: Event line should come before data line')
println(' Event pos: ${event_pos}, Data pos: ${data_pos}')
return false
}
}
return true
}
// Test 7d: Event field is omitted when empty (Requirement 3.6)
// *For any* SSE event with empty event field, output SHALL NOT contain "event: "
fn test_property_7d_empty_event_field_omitted() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
event: '' // Empty event field
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Should not contain "event: " prefix
if content.contains('event: ') {
println(' Iteration ${i}: Empty event field should not produce "event: " line')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7e: ID field is written after data (Requirement 3.7)
// *For any* SSE event with id field, output SHALL contain "id: {value}\n" after data
fn test_property_7e_id_field_after_data() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
id := generate_random_string(1, 10)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
id: id
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
id_line := 'id: ${id}\n'
data_line := 'data: ${data}\n'
// ID line should exist
if !content.contains(id_line) {
println(' Iteration ${i}: ID line "${id_line}" not found')
return false
}
// ID line should come after data line
id_pos := content.index(id_line) or { -1 }
data_pos := content.index(data_line) or { -1 }
if id_pos <= data_pos {
println(' Iteration ${i}: ID line should come after data line')
println(' ID pos: ${id_pos}, Data pos: ${data_pos}')
return false
}
}
return true
}
// Test 7f: ID field is omitted when empty (Requirement 3.7)
// *For any* SSE event with empty id field, output SHALL NOT contain "id: "
fn test_property_7f_empty_id_field_omitted() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
id: '' // Empty id field
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Should not contain "id: " prefix
if content.contains('id: ') {
println(' Iteration ${i}: Empty id field should not produce "id: " line')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7g: Retry field is written when positive (Requirement 3.8)
// *For any* SSE event with retry > 0, output SHALL contain "retry: {value}\n"
fn test_property_7g_retry_field_when_positive() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
retry := rand.int_in_range(100, 10000) or { 1000 }
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
retry: retry
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
retry_line := 'retry: ${retry}\n'
// Retry line should exist
if !content.contains(retry_line) {
println(' Iteration ${i}: Retry line "${retry_line}" not found')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7h: Retry field is omitted when zero (Requirement 3.8)
// *For any* SSE event with retry = 0, output SHALL NOT contain "retry: "
fn test_property_7h_zero_retry_field_omitted() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
retry: 0 // Zero retry
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Should not contain "retry: " prefix
if content.contains('retry: ') {
println(' Iteration ${i}: Zero retry should not produce "retry: " line')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7i: Event separator (empty line) is always present (Requirement 3.9)
// *For any* SSE event, output SHALL end with an additional "\n" to separate events
fn test_property_7i_event_separator_present() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event := generate_random_sse_event()
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Content should end with double newline (data line + separator)
// The last line before separator should be data:, id:, or retry:
// followed by \n\n (the separator)
if !content.ends_with('\n\n') {
println(' Iteration ${i}: Event should end with double newline (separator)')
println(' Got: "${content}"')
println(' Last chars: ${content.bytes()[content.len - 4..]}')
return false
}
}
return true
}
// Test 7j: Complete SSE event with all fields (Requirements 3.4-3.9)
// *For any* SSE event with all fields set, output SHALL follow correct format and order
fn test_property_7j_complete_event_format() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event_type := generate_random_string(3, 15)
data := generate_random_string(1, 30)
id := generate_random_string(1, 10)
retry := rand.int_in_range(100, 10000) or { 1000 }
event := SSEEvent{
data: data
event: event_type
id: id
retry: retry
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Verify all fields are present
if !content.contains('event: ${event_type}\n') {
println(' Iteration ${i}: Event field missing')
return false
}
if !content.contains('data: ${data}\n') {
println(' Iteration ${i}: Data field missing')
return false
}
if !content.contains('id: ${id}\n') {
println(' Iteration ${i}: ID field missing')
return false
}
if !content.contains('retry: ${retry}\n') {
println(' Iteration ${i}: Retry field missing')
return false
}
// Verify order: event -> data -> id -> retry -> separator
event_pos := content.index('event: ') or { -1 }
data_pos := content.index('data: ') or { -1 }
id_pos := content.index('id: ') or { -1 }
retry_pos := content.index('retry: ') or { -1 }
if event_pos >= data_pos {
println(' Iteration ${i}: Event should come before data')
return false
}
if data_pos >= id_pos {
println(' Iteration ${i}: Data should come before id')
return false
}
if id_pos >= retry_pos {
println(' Iteration ${i}: ID should come before retry')
return false
}
// Verify ends with separator
if !content.ends_with('\n\n') {
println(' Iteration ${i}: Should end with event separator')
return false
}
}
return true
}
// Test 7k: Random SSE events are formatted correctly (Requirements 3.4-3.9)
// *For any* randomly generated SSE event, the formatting SHALL be correct
fn test_property_7k_random_events_formatted_correctly() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event := generate_random_sse_event()
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Data field should always be present
lines := event.data.split('\n')
for line in lines {
if !content.contains('data: ${line}\n') {
println(' Iteration ${i}: Data line missing: "data: ${line}"')
return false
}
}
// Event field should be present only if non-empty
if event.event.len > 0 {
if !content.contains('event: ${event.event}\n') {
println(' Iteration ${i}: Event field should be present')
return false
}
} else {
if content.contains('event: ') {
println(' Iteration ${i}: Event field should not be present when empty')
return false
}
}
// ID field should be present only if non-empty
if event.id.len > 0 {
if !content.contains('id: ${event.id}\n') {
println(' Iteration ${i}: ID field should be present')
return false
}
} else {
if content.contains('id: ') {
println(' Iteration ${i}: ID field should not be present when empty')
return false
}
}
// Retry field should be present only if > 0
if event.retry > 0 {
if !content.contains('retry: ${event.retry}\n') {
println(' Iteration ${i}: Retry field should be present')
return false
}
} else {
if content.contains('retry: ') {
println(' Iteration ${i}: Retry field should not be present when zero')
return false
}
}
// Should always end with separator
if !content.ends_with('\n\n') {
println(' Iteration ${i}: Should end with event separator')
return false
}
}
return true
}
fn main() {
println('🚀 开始 stream_sse() 函数属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 6 tests
// Feature: sse-streaming-helper, Property 6: streamSSE() 设置正确的 HTTP 头
// Validates: Requirements 3.1, 3.2, 3.3
println('--- Property 6: streamSSE() 设置正确的 HTTP 头 ---')
stats.run_property_test('Property 6a: Content-Type header (text/event-stream)', test_property_6a_content_type_header)
stats.run_property_test('Property 6b: Cache-Control header (no-cache)', test_property_6b_cache_control_header)
stats.run_property_test('Property 6c: Connection header (keep-alive)', test_property_6c_connection_header)
stats.run_property_test('Property 6d: Transfer-Encoding header (chunked)', test_property_6d_transfer_encoding_header)
stats.run_property_test('Property 6e: Headers consistency', test_property_6e_headers_consistency)
stats.run_property_test('Property 6f: All required headers present', test_property_6f_all_required_headers_present)
stats.run_property_test('Property 6g: SSE headers differ from text', test_property_6g_sse_headers_differ_from_text)
// Run Property 7 tests
// Feature: sse-streaming-helper, Property 7: SSE 事件格式化正确性
// Validates: Requirements 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
println('\n--- Property 7: SSE 事件格式化正确性 ---')
stats.run_property_test('Property 7a: Data field formatting', test_property_7a_data_field_formatting)
stats.run_property_test('Property 7b: Multi-line data formatting', test_property_7b_multiline_data_formatting)
stats.run_property_test('Property 7c: Event field before data', test_property_7c_event_field_before_data)
stats.run_property_test('Property 7d: Empty event field omitted', test_property_7d_empty_event_field_omitted)
stats.run_property_test('Property 7e: ID field after data', test_property_7e_id_field_after_data)
stats.run_property_test('Property 7f: Empty ID field omitted', test_property_7f_empty_id_field_omitted)
stats.run_property_test('Property 7g: Retry field when positive', test_property_7g_retry_field_when_positive)
stats.run_property_test('Property 7h: Zero retry field omitted', test_property_7h_zero_retry_field_omitted)
stats.run_property_test('Property 7i: Event separator present', test_property_7i_event_separator_present)
stats.run_property_test('Property 7j: Complete event format', test_property_7j_complete_event_format)
stats.run_property_test('Property 7k: Random events formatted correctly', test_property_7k_random_events_formatted_correctly)
// Print summary
stats.print_summary()
}
+648
View File
@@ -0,0 +1,648 @@
// test_stream_text_properties.v
// Property-Based Testing for stream_text() function
// Feature: sse-streaming-helper
// Property 3: streamText() 设置正确的 HTTP 头
// Property 4: writeln() 添加换行符
// Property 5: sleep() 暂停正确的时间
// Validates: Requirements 2.1, 2.2, 2.3, 2.5, 2.6
module main
import rand
import time
const test_iterations = 100
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=== stream_text() 函数属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Mock StreamWriter for Testing
// ============================================================================
// MockStreamWriter - A mock implementation of StreamWriter for testing
// Captures all written data for verification
struct MockStreamWriter {
mut:
written_data []u8
connected bool
closed bool
flush_count int
}
fn MockStreamWriter.new() &MockStreamWriter {
return &MockStreamWriter{
written_data: []u8{}
connected: true
closed: false
flush_count: 0
}
}
fn (mut w MockStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data.bytes()
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) flush() ! {
w.flush_count++
}
fn (mut w MockStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.written_data << '0\r\n\r\n'.bytes()
w.connected = false
w.closed = true
}
}
fn (w MockStreamWriter) is_connected() bool {
return w.connected
}
fn (w MockStreamWriter) get_written_string() string {
return w.written_data.bytestr()
}
// ============================================================================
// StreamContext for Testing (simplified version)
// ============================================================================
@[heap]
struct StreamContext {
mut:
writer &MockStreamWriter
is_closed bool
}
fn StreamContext.new(writer &MockStreamWriter) StreamContext {
return StreamContext{
writer: unsafe { writer }
is_closed: false
}
}
fn (mut ctx StreamContext) write_string(data string) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write_string(data)!
}
fn (mut ctx StreamContext) writeln(data string) ! {
ctx.write_string(data)!
ctx.write_string('\n')!
}
fn (ctx StreamContext) sleep(ms int) {
time.sleep(ms * time.millisecond)
}
fn (mut ctx StreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// ============================================================================
// Helper Functions
// ============================================================================
// get_stream_text_headers - Get the headers required for text streaming
fn get_stream_text_headers() map[string]string {
return {
'Content-Type': 'text/plain; charset=utf-8'
'Transfer-Encoding': 'chunked'
'X-Content-Type-Options': 'nosniff'
'Connection': 'keep-alive'
}
}
// Generate random string for testing (printable ASCII only)
fn generate_random_string(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-. '
len := rand.int_in_range(1, max_len) 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
}
// Parse hex string to int
fn parse_hex(hex_str string) !int {
mut result := 0
for c in hex_str {
result *= 16
if c >= `0` && c <= `9` {
result += int(c - `0`)
} else if c >= `a` && c <= `f` {
result += int(c - `a` + 10)
} else if c >= `A` && c <= `F` {
result += int(c - `A` + 10)
} else {
return error('Invalid hex character: ${c.ascii_str()}')
}
}
return result
}
// Parse a chunked encoded string and extract all chunks
fn parse_all_chunks(chunked string) !([]string, bool) {
mut chunks := []string{}
mut pos := 0
for pos < chunked.len {
// Find the size part (before \r\n)
mut crlf_pos := -1
for i := pos; i < chunked.len - 1; i++ {
if chunked[i] == `\r` && chunked[i + 1] == `\n` {
crlf_pos = i
break
}
}
if crlf_pos == -1 {
return error('No CRLF found in chunk header at position ${pos}')
}
size_hex := chunked[pos..crlf_pos]
size := parse_hex(size_hex) or {
return error('Invalid hex size: ${size_hex}')
}
// Check for final chunk
if size == 0 {
// Verify final chunk format
remaining := chunked[pos..]
if remaining != '0\r\n\r\n' {
return error('Invalid final chunk format')
}
break
}
// Extract data after the \r\n
data_start := crlf_pos + 2
data_end := data_start + size
if data_end > chunked.len {
return error('Chunk data incomplete')
}
data := chunked[data_start..data_end]
chunks << data
// Verify trailing \r\n
if data_end + 2 > chunked.len {
return error('Missing trailing CRLF')
}
trailing := chunked[data_end..data_end + 2]
if trailing != '\r\n' {
return error('Invalid trailing CRLF')
}
pos = data_end + 2
}
return chunks, true
}
// ============================================================================
// Property 3: streamText() 设置正确的 HTTP 头
// Feature: sse-streaming-helper, Property 3
// Validates: Requirements 2.1, 2.2, 2.3
//
// *For any* call to streamText(c, callback), the response SHALL contain:
// - Content-Type: text/plain; charset=utf-8
// - Transfer-Encoding: chunked
// - X-Content-Type-Options: nosniff
// ============================================================================
// Test 3a: Content-Type header is set correctly
fn test_property_3a_content_type_header() bool {
headers := get_stream_text_headers()
// Verify Content-Type header exists
if 'Content-Type' !in headers {
println(' Content-Type header missing')
return false
}
// Verify value is 'text/plain; charset=utf-8'
if headers['Content-Type'] != 'text/plain; charset=utf-8' {
println(' Content-Type should be "text/plain; charset=utf-8", got "${headers['Content-Type']}"')
return false
}
return true
}
// Test 3b: Transfer-Encoding header is set correctly
fn test_property_3b_transfer_encoding_header() bool {
headers := get_stream_text_headers()
// Verify Transfer-Encoding header exists
if 'Transfer-Encoding' !in headers {
println(' Transfer-Encoding header missing')
return false
}
// Verify value is 'chunked'
if headers['Transfer-Encoding'] != 'chunked' {
println(' Transfer-Encoding should be "chunked", got "${headers['Transfer-Encoding']}"')
return false
}
return true
}
// Test 3c: X-Content-Type-Options header is set correctly
fn test_property_3c_x_content_type_options_header() bool {
headers := get_stream_text_headers()
// Verify X-Content-Type-Options header exists
if 'X-Content-Type-Options' !in headers {
println(' X-Content-Type-Options header missing')
return false
}
// Verify value is 'nosniff'
if headers['X-Content-Type-Options'] != 'nosniff' {
println(' X-Content-Type-Options should be "nosniff", got "${headers['X-Content-Type-Options']}"')
return false
}
return true
}
// Test 3d: Headers are consistent across multiple calls
fn test_property_3d_headers_consistency() bool {
for _ in 0 .. test_iterations {
headers1 := get_stream_text_headers()
headers2 := get_stream_text_headers()
// Headers should be identical
if headers1['Content-Type'] != headers2['Content-Type'] {
println(' Content-Type inconsistent between calls')
return false
}
if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
println(' Transfer-Encoding inconsistent between calls')
return false
}
if headers1['X-Content-Type-Options'] != headers2['X-Content-Type-Options'] {
println(' X-Content-Type-Options inconsistent between calls')
return false
}
}
return true
}
// ============================================================================
// Property 4: writeln() 添加换行符
// Feature: sse-streaming-helper, Property 4
// Validates: Requirements 2.5
//
// *For any* string text, when stream.writeln(text) is called, the output
// SHALL be text followed by a newline character \n.
// ============================================================================
// Test 4a: writeln() appends newline for any string
fn test_property_4a_writeln_appends_newline() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random text
text := generate_random_string(200)
// Create mock writer and context
mut writer := MockStreamWriter.new()
mut ctx := StreamContext.new(writer)
// Call writeln
ctx.writeln(text) or {
println(' Iteration ${i}: writeln failed: ${err}')
return false
}
// Close to finalize
ctx.close()
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have exactly 2 chunks (text and newline)
if chunks.len != 2 {
println(' Iteration ${i}: Expected 2 chunks, got ${chunks.len}')
return false
}
// First chunk should be the text
if chunks[0] != text {
println(' Iteration ${i}: First chunk should be text')
return false
}
// Second chunk should be newline
if chunks[1] != '\n' {
println(' Iteration ${i}: Second chunk should be newline, got "${chunks[1]}"')
return false
}
}
return true
}
// Test 4b: writeln() with empty string still adds newline
fn test_property_4b_writeln_empty_string() bool {
// Create mock writer and context
mut writer := MockStreamWriter.new()
mut ctx := StreamContext.new(writer)
// Call writeln with empty string - this should fail because write_string skips empty
// Actually, writeln calls write_string twice: once for data, once for \n
// Empty string write is skipped, but \n should still be written
ctx.writeln('') or {
println(' writeln empty string failed: ${err}')
return false
}
// Close to finalize
ctx.close()
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Failed to parse chunks: ${err}')
return false
}
// Should have exactly 1 chunk (just the newline, empty string is skipped)
if chunks.len != 1 {
println(' Expected 1 chunk (newline only), got ${chunks.len}')
return false
}
// The chunk should be newline
if chunks[0] != '\n' {
println(' Chunk should be newline, got "${chunks[0]}"')
return false
}
return true
}
// Test 4c: Multiple writeln calls produce correct output
fn test_property_4c_multiple_writeln() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate multiple random lines
num_lines := rand.int_in_range(2, 5) or { 3 }
mut lines := []string{}
mut writer := MockStreamWriter.new()
mut ctx := StreamContext.new(writer)
for _ in 0 .. num_lines {
line := generate_random_string(50)
lines << line
ctx.writeln(line) or {
println(' Iteration ${i}: writeln failed: ${err}')
return false
}
}
// Close to finalize
ctx.close()
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have 2 chunks per line (text + newline)
expected_chunks := num_lines * 2
if chunks.len != expected_chunks {
println(' Iteration ${i}: Expected ${expected_chunks} chunks, got ${chunks.len}')
return false
}
// Verify each line and newline pair
for j, line in lines {
text_idx := j * 2
newline_idx := j * 2 + 1
if chunks[text_idx] != line {
println(' Iteration ${i}: Line ${j} text mismatch')
return false
}
if chunks[newline_idx] != '\n' {
println(' Iteration ${i}: Line ${j} newline missing')
return false
}
}
}
return true
}
// ============================================================================
// Property 5: sleep() 暂停正确的时间
// Feature: sse-streaming-helper, Property 5
// Validates: Requirements 2.6
//
// *For any* positive integer ms, when stream.sleep(ms) is called, the execution
// SHALL pause for approximately ms milliseconds (within ±10% tolerance).
// ============================================================================
// Test 5a: sleep() pauses for approximately the specified time
fn test_property_5a_sleep_duration() bool {
// Test with a few specific durations to avoid long test times
test_durations := [10, 20, 50]
for duration in test_durations {
mut writer := MockStreamWriter.new()
ctx := StreamContext.new(writer)
// Measure time before and after sleep
start := time.now()
ctx.sleep(duration)
elapsed := time.now() - start
elapsed_ms := elapsed.milliseconds()
// Allow generous tolerance for system overhead (±50% plus overhead)
// Sleep timing can vary significantly depending on system load
min_expected := int(f64(duration) * 0.5)
max_expected := int(f64(duration) * 2.0) + 20
if elapsed_ms < min_expected || elapsed_ms > max_expected {
println(' sleep(${duration}) took ${elapsed_ms}ms, expected ${min_expected}-${max_expected}ms')
return false
}
}
return true
}
// Test 5b: sleep(0) returns immediately
fn test_property_5b_sleep_zero() bool {
mut writer := MockStreamWriter.new()
ctx := StreamContext.new(writer)
// Measure time for sleep(0)
start := time.now()
ctx.sleep(0)
elapsed := time.now() - start
elapsed_ms := elapsed.milliseconds()
// sleep(0) should return very quickly (within 10ms)
if elapsed_ms > 10 {
println(' sleep(0) took ${elapsed_ms}ms, expected < 10ms')
return false
}
return true
}
// Test 5c: Multiple sleeps accumulate correctly
fn test_property_5c_multiple_sleeps() bool {
mut writer := MockStreamWriter.new()
ctx := StreamContext.new(writer)
// Sleep multiple times
sleep_duration := 10
num_sleeps := 3
start := time.now()
for _ in 0 .. num_sleeps {
ctx.sleep(sleep_duration)
}
elapsed := time.now() - start
elapsed_ms := elapsed.milliseconds()
expected_total := sleep_duration * num_sleeps
// Allow generous tolerance for system overhead (±50% plus overhead)
// Sleep timing can vary significantly depending on system load
min_expected := int(f64(expected_total) * 0.5)
max_expected := int(f64(expected_total) * 2.0) + 30
if elapsed_ms < min_expected || elapsed_ms > max_expected {
println(' ${num_sleeps} sleeps of ${sleep_duration}ms took ${elapsed_ms}ms, expected ${min_expected}-${max_expected}ms')
return false
}
return true
}
fn main() {
println('🚀 开始 stream_text() 函数属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 3 tests
// Feature: sse-streaming-helper, Property 3: streamText() 设置正确的 HTTP 头
// Validates: Requirements 2.1, 2.2, 2.3
println('--- Property 3: streamText() 设置正确的 HTTP 头 ---')
stats.run_property_test('Property 3a: Content-Type header', test_property_3a_content_type_header)
stats.run_property_test('Property 3b: Transfer-Encoding header', test_property_3b_transfer_encoding_header)
stats.run_property_test('Property 3c: X-Content-Type-Options header', test_property_3c_x_content_type_options_header)
stats.run_property_test('Property 3d: Headers consistency', test_property_3d_headers_consistency)
println('')
// Run Property 4 tests
// Feature: sse-streaming-helper, Property 4: writeln() 添加换行符
// Validates: Requirements 2.5
println('--- Property 4: writeln() 添加换行符 ---')
stats.run_property_test('Property 4a: writeln() appends newline', test_property_4a_writeln_appends_newline)
stats.run_property_test('Property 4b: writeln() empty string', test_property_4b_writeln_empty_string)
stats.run_property_test('Property 4c: Multiple writeln calls', test_property_4c_multiple_writeln)
println('')
// Run Property 5 tests
// Feature: sse-streaming-helper, Property 5: sleep() 暂停正确的时间
// Validates: Requirements 2.6
println('--- Property 5: sleep() 暂停正确的时间 ---')
stats.run_property_test('Property 5a: sleep() duration', test_property_5a_sleep_duration)
stats.run_property_test('Property 5b: sleep(0) returns immediately', test_property_5b_sleep_zero)
stats.run_property_test('Property 5c: Multiple sleeps accumulate', test_property_5c_multiple_sleeps)
// Print summary
stats.print_summary()
}
@@ -0,0 +1,624 @@
// test_usockets_stream_writer_properties.v
// Property-Based Testing for UsocketsStreamWriter chunked encoding
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 7.2
module main
import rand
import time
const test_iterations = 100
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=== UsocketsStreamWriter 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Chunked Encoding Functions (copied from streaming.v for testing)
// ============================================================================
// format_chunk_size - Format the chunk size as hex followed by CRLF
@[inline]
fn format_chunk_size(size int) string {
return '${size:x}\r\n'
}
// format_chunk - Format a complete chunk with size, data, and trailing CRLF
fn format_chunk(data []u8) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data.bytestr()}\r\n'
}
// format_chunk_string - Format a string as a complete chunk
fn format_chunk_string(data string) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data}\r\n'
}
// format_final_chunk - Return the final chunk marker for chunked encoding
fn format_final_chunk() string {
return '0\r\n\r\n'
}
// ============================================================================
// Random Data Generators
// ============================================================================
// Generate random bytes for testing
fn generate_random_bytes(max_len int) []u8 {
len := rand.int_in_range(1, max_len) or { 10 }
mut data := []u8{len: len}
for i in 0 .. len {
data[i] = u8(rand.int_in_range(0, 256) or { 0 })
}
return data
}
// Generate random string for testing (printable ASCII only)
fn generate_random_string(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
len := rand.int_in_range(1, max_len) 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
}
// ============================================================================
// Chunk Parsing Helper
// ============================================================================
// Parse a chunked encoded string and extract the data
fn parse_chunked_data(chunked string) !([]u8, bool) {
mut crlf_pos := -1
for i := 0; i < chunked.len - 1; i++ {
if chunked[i] == `\r` && chunked[i + 1] == `\n` {
crlf_pos = i
break
}
}
if crlf_pos == -1 {
return error('No CRLF found in chunk header')
}
size_hex := chunked[..crlf_pos]
size := parse_hex(size_hex) or {
return error('Invalid hex size: ${size_hex}')
}
data_start := crlf_pos + 2
data_end := data_start + size
if data_end > chunked.len {
return error('Chunk data incomplete')
}
data := chunked[data_start..data_end].bytes()
if data_end + 2 > chunked.len {
return error('Missing trailing CRLF')
}
trailing := chunked[data_end..data_end + 2]
if trailing != '\r\n' {
return error('Invalid trailing CRLF: got "${trailing}"')
}
return data, true
}
// Parse hex string to int
fn parse_hex(hex_str string) !int {
mut result := 0
for c in hex_str {
result *= 16
if c >= `0` && c <= `9` {
result += int(c - `0`)
} else if c >= `a` && c <= `f` {
result += int(c - `a` + 10)
} else if c >= `A` && c <= `F` {
result += int(c - `A` + 10)
} else {
return error('Invalid hex character: ${c.ascii_str()}')
}
}
return result
}
// ============================================================================
// Mock Socket for Testing UsocketsStreamWriter
// ============================================================================
// MockSocket - A mock socket that captures written data for testing
struct MockSocket {
mut:
buffer string
is_closed bool
}
fn MockSocket.new() MockSocket {
return MockSocket{
buffer: ''
is_closed: false
}
}
fn (mut s MockSocket) write_bytes(data string) int {
if s.is_closed {
return 0
}
s.buffer += data
return data.len
}
fn (s MockSocket) get_buffer() string {
return s.buffer
}
fn (mut s MockSocket) clear() {
s.buffer = ''
}
// ============================================================================
// UsocketsStreamWriter Simulation for Testing
// Uses the same chunked encoding logic as the real implementation
// ============================================================================
struct TestUsocketsStreamWriter {
mut:
socket &MockSocket
connected bool
}
fn TestUsocketsStreamWriter.new(socket &MockSocket) TestUsocketsStreamWriter {
return TestUsocketsStreamWriter{
socket: unsafe { socket }
connected: true
}
}
fn (mut w TestUsocketsStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data.bytestr())
w.socket.write_bytes('\r\n')
}
fn (mut w TestUsocketsStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data)
w.socket.write_bytes('\r\n')
}
fn (mut w TestUsocketsStreamWriter) close() ! {
if w.connected {
w.socket.write_bytes('0\r\n\r\n')
w.connected = false
}
}
fn (w TestUsocketsStreamWriter) is_connected() bool {
return w.connected
}
// ============================================================================
// Property 10: Chunked 编码格式正确性
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 7.2
//
// *For any* data written to the stream, the output SHALL follow HTTP chunked
// transfer encoding format:
// - Each chunk starts with the hex size followed by \r\n
// - Chunk data followed by \r\n
// - Final chunk is 0\r\n\r\n
// ============================================================================
// Test 10a: UsocketsStreamWriter.write produces valid chunked encoding for bytes
fn test_property_10a_usockets_write_bytes() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate random data
data := generate_random_bytes(1000)
// Write using the writer
writer.write(data) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
// Get the output
output := mock_socket.get_buffer()
// Verify chunk format
mut crlf_pos := -1
for j := 0; j < output.len - 1; j++ {
if output[j] == `\r` && output[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found in chunk header')
return false
}
size_hex := output[..crlf_pos]
parsed_size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
return false
}
if parsed_size != data.len {
println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
return false
}
// Extract and verify data
extracted_data, _ := parse_chunked_data(output) or {
println(' Iteration ${i}: Failed to parse chunk: ${err}')
return false
}
if extracted_data != data {
println(' Iteration ${i}: Data mismatch after extraction')
return false
}
}
return true
}
// Test 10b: UsocketsStreamWriter.write_string produces valid chunked encoding
fn test_property_10b_usockets_write_string() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate random string
data := generate_random_string(500)
// Write using the writer
writer.write_string(data) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
// Get the output
output := mock_socket.get_buffer()
// Verify chunk format
mut crlf_pos := -1
for j := 0; j < output.len - 1; j++ {
if output[j] == `\r` && output[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found in chunk header')
return false
}
size_hex := output[..crlf_pos]
parsed_size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
return false
}
if parsed_size != data.len {
println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
return false
}
// Extract and verify data
extracted_data, _ := parse_chunked_data(output) or {
println(' Iteration ${i}: Failed to parse chunk: ${err}')
return false
}
if extracted_data.bytestr() != data {
println(' Iteration ${i}: String mismatch after extraction')
return false
}
}
return true
}
// Test 10c: UsocketsStreamWriter.close produces correct final chunk
fn test_property_10c_usockets_close_final_chunk() bool {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Close the writer
writer.close() or {
println(' Close failed: ${err}')
return false
}
// Get the output
output := mock_socket.get_buffer()
// Final chunk must be exactly "0\r\n\r\n"
expected := '0\r\n\r\n'
if output != expected {
println(' Final chunk mismatch - expected "${expected}", got "${output}"')
return false
}
// Verify writer is now disconnected
if writer.is_connected() {
println(' Writer should be disconnected after close')
return false
}
return true
}
// Test 10d: UsocketsStreamWriter handles empty data correctly
fn test_property_10d_usockets_empty_data() bool {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Write empty byte array
writer.write([]u8{}) or {
println(' Empty write failed: ${err}')
return false
}
// Buffer should be empty (no chunk for empty data)
if mock_socket.get_buffer() != '' {
println(' Empty bytes should produce no output, got "${mock_socket.get_buffer()}"')
return false
}
// Write empty string
writer.write_string('') or {
println(' Empty string write failed: ${err}')
return false
}
// Buffer should still be empty
if mock_socket.get_buffer() != '' {
println(' Empty string should produce no output, got "${mock_socket.get_buffer()}"')
return false
}
return true
}
// Test 10e: UsocketsStreamWriter rejects writes after close
fn test_property_10e_usockets_write_after_close() bool {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Close the writer
writer.close() or {
println(' Close failed: ${err}')
return false
}
mock_socket.clear()
// Try to write after close - should fail
writer.write([u8(1), 2, 3]) or {
// Expected error
return true
}
println(' Write after close should have failed')
return false
}
// Test 10f: UsocketsStreamWriter multiple writes produce valid chunks
fn test_property_10f_usockets_multiple_writes() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate multiple random data chunks
num_chunks := rand.int_in_range(2, 10) or { 3 }
mut all_data := [][]u8{}
for _ in 0 .. num_chunks {
data := generate_random_bytes(100)
all_data << data
writer.write(data) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
}
// Close to add final chunk
writer.close() or {
println(' Iteration ${i}: Close failed: ${err}')
return false
}
// Parse all chunks from output
output := mock_socket.get_buffer()
mut pos := 0
mut chunk_idx := 0
for pos < output.len && chunk_idx < all_data.len {
// Find CRLF for size
mut crlf_pos := -1
for j := pos; j < output.len - 1; j++ {
if output[j] == `\r` && output[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found at chunk ${chunk_idx}')
return false
}
size_hex := output[pos..crlf_pos]
size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex at chunk ${chunk_idx}')
return false
}
// Check for final chunk
if size == 0 {
break
}
// Verify size matches expected
if size != all_data[chunk_idx].len {
println(' Iteration ${i}: Size mismatch at chunk ${chunk_idx}')
return false
}
// Extract data
data_start := crlf_pos + 2
data_end := data_start + size
extracted := output[data_start..data_end].bytes()
if extracted != all_data[chunk_idx] {
println(' Iteration ${i}: Data mismatch at chunk ${chunk_idx}')
return false
}
// Move to next chunk (skip trailing CRLF)
pos = data_end + 2
chunk_idx++
}
// Verify all chunks were found
if chunk_idx != all_data.len {
println(' Iteration ${i}: Expected ${all_data.len} chunks, found ${chunk_idx}')
return false
}
// Verify final chunk exists
if !output.ends_with('0\r\n\r\n') {
println(' Iteration ${i}: Missing final chunk')
return false
}
}
return true
}
// Test 10g: Round-trip property for UsocketsStreamWriter
fn test_property_10g_usockets_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(77777)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate random data
original := generate_random_bytes(500)
// Write using the writer
writer.write(original) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
// Parse back
output := mock_socket.get_buffer()
recovered, _ := parse_chunked_data(output) or {
println(' Iteration ${i}: Failed to parse: ${err}')
return false
}
// Verify round-trip
if recovered != original {
println(' Iteration ${i}: Round-trip failed - data mismatch')
return false
}
}
return true
}
fn main() {
println('🚀 开始 UsocketsStreamWriter 属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 10 tests for UsocketsStreamWriter
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 7.2
stats.run_property_test('Property 10a: UsocketsStreamWriter.write bytes encoding', test_property_10a_usockets_write_bytes)
stats.run_property_test('Property 10b: UsocketsStreamWriter.write_string encoding', test_property_10b_usockets_write_string)
stats.run_property_test('Property 10c: UsocketsStreamWriter.close final chunk', test_property_10c_usockets_close_final_chunk)
stats.run_property_test('Property 10d: UsocketsStreamWriter empty data handling', test_property_10d_usockets_empty_data)
stats.run_property_test('Property 10e: UsocketsStreamWriter write after close', test_property_10e_usockets_write_after_close)
stats.run_property_test('Property 10f: UsocketsStreamWriter multiple writes', test_property_10f_usockets_multiple_writes)
stats.run_property_test('Property 10g: UsocketsStreamWriter round-trip', test_property_10g_usockets_roundtrip)
// Print summary
stats.print_summary()
}
+583
View File
@@ -0,0 +1,583 @@
// test_websocket_close_handshake.v - Property test for WebSocket close handshake
// Feature: websocket-helper, Property 6: Close Handshake
// Validates: Requirements 3.3, 7.5
//
// *For any* call to `ws.close(code, reason)`, the server SHALL send a close frame
// with the specified status code and reason, and transition the connection state to `closing`.
module main
import rand
import time
const test_iterations = 100
// WebSocket opcodes (RFC 6455)
const ws_opcode_close = u8(0x8)
// WebSocket close codes (RFC 6455)
const ws_close_normal = 1000
const ws_close_going_away = 1001
const ws_close_protocol_error = 1002
const ws_close_unsupported_data = 1003
const ws_close_invalid_payload = 1007
const ws_close_policy_violation = 1008
const ws_close_message_too_big = 1009
const ws_close_internal_error = 1011
// WebSocket Ready State
enum WSReadyState {
connecting = 0
open = 1
closing = 2
closed = 3
}
struct WSCloseEvent {
code int
reason string
was_clean bool
}
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 Close Handshake 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')
}
}
// ============================================================================
// Close Frame Functions (copied from websocket.v for testing)
// ============================================================================
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 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
}
fn decode_ws_frame(data []u8) !(bool, u8, []u8) {
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 fin, opcode, payload
}
fn is_valid_close_code(code int) bool {
if code < 1000 {
return false
}
if code >= 1004 && code <= 1006 {
return false
}
if code >= 1012 && code <= 2999 {
return false
}
if code > 4999 {
return false
}
return true
}
// ============================================================================
// Mock WSContext for testing
// ============================================================================
struct MockWSContext {
mut:
ready_state WSReadyState
sent_frames [][]u8
close_called bool
close_code int
close_reason string
}
fn create_mock_ws_context() MockWSContext {
return MockWSContext{
ready_state: .open
sent_frames: [][]u8{}
close_called: false
}
}
fn (mut ws MockWSContext) close(code int, reason string) {
if ws.ready_state == .closed || ws.ready_state == .closing {
return
}
ws.ready_state = .closing
ws.close_called = true
ws.close_code = code
ws.close_reason = reason
// Build and "send" close frame
close_frame := build_close_frame(code, reason)
ws.sent_frames << close_frame
}
fn (mut ws MockWSContext) handle_close_frame(payload []u8) WSCloseEvent {
code, reason := parse_close_frame(payload)
was_clean := is_valid_close_code(code)
if ws.ready_state == .open {
ws.ready_state = .closing
close_frame := build_close_frame(code, reason)
ws.sent_frames << close_frame
}
ws.ready_state = .closed
return WSCloseEvent{
code: code
reason: reason
was_clean: was_clean
}
}
// ============================================================================
// Random Data Generators
// ============================================================================
fn get_valid_close_codes() []int {
return [
ws_close_normal,
ws_close_going_away,
ws_close_protocol_error,
ws_close_unsupported_data,
ws_close_invalid_payload,
ws_close_policy_violation,
ws_close_message_too_big,
ws_close_internal_error,
3000, 3500, 3999, // Library/framework codes
4000, 4500, 4999, // Private use codes
]
}
fn generate_random_close_code() int {
codes := get_valid_close_codes()
idx := rand.int_in_range(0, codes.len) or { 0 }
return codes[idx]
}
fn generate_random_reason() string {
reasons := [
'',
'Normal closure',
'Server shutting down',
'Protocol error detected',
'Invalid data received',
'Connection timeout',
'User disconnected',
'Session expired',
]
idx := rand.int_in_range(0, reasons.len) or { 0 }
return reasons[idx]
}
// ============================================================================
// Property 6: Close Handshake
// Feature: websocket-helper, Property 6: Close Handshake
// Validates: Requirements 3.3, 7.5
// ============================================================================
fn test_property_6_close_sends_frame_with_code_and_reason() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
code := generate_random_close_code()
reason := generate_random_reason()
mut ws := create_mock_ws_context()
ws.close(code, reason)
// Verify a close frame was sent
if ws.sent_frames.len != 1 {
println(' Iteration ${i}: Expected 1 sent frame, got ${ws.sent_frames.len}')
return false
}
// Decode the sent frame
fin, opcode, payload := decode_ws_frame(ws.sent_frames[0]) or {
println(' Iteration ${i}: Failed to decode close frame: ${err}')
return false
}
// Verify it's a close frame
if opcode != ws_opcode_close {
println(' Iteration ${i}: Expected close opcode (0x8), got 0x${opcode:02x}')
return false
}
// Verify FIN bit is set
if !fin {
println(' Iteration ${i}: FIN bit should be set for close frame')
return false
}
// Parse the close frame payload
parsed_code, parsed_reason := parse_close_frame(payload)
// Verify code matches
if parsed_code != code {
println(' Iteration ${i}: Expected code ${code}, got ${parsed_code}')
return false
}
// Verify reason matches
if parsed_reason != reason {
println(' Iteration ${i}: Expected reason "${reason}", got "${parsed_reason}"')
return false
}
}
return true
}
fn test_property_6_close_transitions_to_closing_state() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
code := generate_random_close_code()
reason := generate_random_reason()
mut ws := create_mock_ws_context()
// Verify initial state is open
if ws.ready_state != .open {
println(' Iteration ${i}: Initial state should be open')
return false
}
ws.close(code, reason)
// Verify state transitioned to closing
if ws.ready_state != .closing {
println(' Iteration ${i}: State should be closing after close(), got ${ws.ready_state}')
return false
}
}
return true
}
fn test_property_6_close_is_idempotent() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
code := generate_random_close_code()
reason := generate_random_reason()
mut ws := create_mock_ws_context()
// First close
ws.close(code, reason)
frames_after_first := ws.sent_frames.len
state_after_first := ws.ready_state
// Second close (should be no-op)
ws.close(code, reason)
// Verify no additional frames were sent
if ws.sent_frames.len != frames_after_first {
println(' Iteration ${i}: Second close should not send additional frames')
return false
}
// Verify state didn't change
if ws.ready_state != state_after_first {
println(' Iteration ${i}: State should remain ${state_after_first} after second close')
return false
}
}
return true
}
fn test_property_6_handle_incoming_close_frame() bool {
rand.seed([u32(time.now().unix()), u32(77777)])
for i in 0 .. test_iterations {
code := generate_random_close_code()
reason := generate_random_reason()
// Build incoming close frame payload
mut payload := []u8{cap: 2 + reason.len}
payload << u8(code >> 8)
payload << u8(code & 0xFF)
if reason.len > 0 {
payload << reason.bytes()
}
mut ws := create_mock_ws_context()
// Handle incoming close frame
close_event := ws.handle_close_frame(payload)
// Verify close event has correct code
if close_event.code != code {
println(' Iteration ${i}: Close event code should be ${code}, got ${close_event.code}')
return false
}
// Verify close event has correct reason
if close_event.reason != reason {
println(' Iteration ${i}: Close event reason should be "${reason}", got "${close_event.reason}"')
return false
}
// Verify was_clean is correct
expected_clean := is_valid_close_code(code)
if close_event.was_clean != expected_clean {
println(' Iteration ${i}: was_clean should be ${expected_clean}, got ${close_event.was_clean}')
return false
}
// Verify state is closed
if ws.ready_state != .closed {
println(' Iteration ${i}: State should be closed after handling close frame')
return false
}
// Verify echo close frame was sent
if ws.sent_frames.len != 1 {
println(' Iteration ${i}: Should send echo close frame')
return false
}
}
return true
}
fn test_property_6_valid_close_codes() bool {
// Test all standard valid close codes
valid_codes := [
ws_close_normal,
ws_close_going_away,
ws_close_protocol_error,
ws_close_unsupported_data,
ws_close_invalid_payload,
ws_close_policy_violation,
ws_close_message_too_big,
ws_close_internal_error,
]
for code in valid_codes {
if !is_valid_close_code(code) {
println(' Standard code ${code} should be valid')
return false
}
}
// Test library/framework codes (3000-3999)
for code in [3000, 3500, 3999] {
if !is_valid_close_code(code) {
println(' Library code ${code} should be valid')
return false
}
}
// Test private use codes (4000-4999)
for code in [4000, 4500, 4999] {
if !is_valid_close_code(code) {
println(' Private code ${code} should be valid')
return false
}
}
// Test invalid codes
invalid_codes := [0, 999, 1004, 1005, 1006, 1012, 2000, 2999, 5000, 6000]
for code in invalid_codes {
if is_valid_close_code(code) {
println(' Code ${code} should be invalid')
return false
}
}
return true
}
fn test_close_frame_round_trip() bool {
rand.seed([u32(time.now().unix()), u32(55555)])
for i in 0 .. test_iterations {
code := generate_random_close_code()
reason := generate_random_reason()
// Build close frame
frame := build_close_frame(code, reason)
// Decode frame
_, opcode, payload := decode_ws_frame(frame) or {
println(' Iteration ${i}: Failed to decode frame: ${err}')
return false
}
if opcode != ws_opcode_close {
println(' Iteration ${i}: Expected close opcode')
return false
}
// Parse payload
parsed_code, parsed_reason := parse_close_frame(payload)
// Verify round-trip
if parsed_code != code {
println(' Iteration ${i}: Code mismatch: ${code} vs ${parsed_code}')
return false
}
if parsed_reason != reason {
println(' Iteration ${i}: Reason mismatch: "${reason}" vs "${parsed_reason}"')
return false
}
}
return true
}
fn main() {
println('🚀 Starting WebSocket Close Handshake Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 6: Close Handshake
// Validates: Requirements 3.3, 7.5
stats.run_property_test('Property 6: Close Sends Frame with Code and Reason', test_property_6_close_sends_frame_with_code_and_reason)
stats.run_property_test('Property 6: Close Transitions to Closing State', test_property_6_close_transitions_to_closing_state)
stats.run_property_test('Property 6: Close is Idempotent', test_property_6_close_is_idempotent)
stats.run_property_test('Property 6: Handle Incoming Close Frame', test_property_6_handle_incoming_close_frame)
stats.run_property_test('Property 6: Valid Close Codes', test_property_6_valid_close_codes)
stats.run_property_test('Close Frame Round Trip', test_close_frame_round_trip)
// Print test summary
stats.print_summary()
}
+418
View File
@@ -0,0 +1,418 @@
// test_websocket_connection_state.v - Property test for WebSocket connection state tracking
// Feature: websocket-helper, Property 7: Connection State Tracking
// Validates: Requirements 3.5
//
// *For any* WSContext, the `ready_state` property SHALL accurately reflect the current
// connection state: `connecting` during handshake, `open` after successful handshake,
// `closing` after close initiated, and `closed` after connection terminated.
module main
import rand
import time
const test_iterations = 100
// WebSocket Ready State (copied from websocket.v for testing)
enum WSReadyState {
connecting = 0
open = 1
closing = 2
closed = 3
}
// State change event for tracking
struct WSStateChangeEvent {
previous_state WSReadyState
new_state WSReadyState
timestamp i64
}
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 Connection State 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')
}
}
// ============================================================================
// State Transition Validation (copied from websocket.v for testing)
// ============================================================================
// Valid state transitions according to WebSocket protocol:
// connecting -> open (handshake successful)
// connecting -> closed (handshake failed)
// open -> closing (close initiated)
// open -> closed (abrupt close)
// closing -> closed (close handshake complete)
fn is_valid_state_transition(from WSReadyState, to WSReadyState) bool {
match from {
.connecting {
return to == .open || to == .closed
}
.open {
return to == .closing || to == .closed
}
.closing {
return to == .closed
}
.closed {
return false
}
}
}
// ============================================================================
// Mock WSContext for testing state transitions
// ============================================================================
struct MockWSContext {
mut:
ready_state WSReadyState
state_history []WSStateChangeEvent
}
fn create_mock_ws_context() MockWSContext {
return MockWSContext{
ready_state: .connecting
state_history: []WSStateChangeEvent{}
}
}
fn (mut ws MockWSContext) transition_state(new_state WSReadyState) bool {
old_state := ws.ready_state
if !is_valid_state_transition(old_state, new_state) {
return false
}
ws.ready_state = new_state
ws.state_history << WSStateChangeEvent{
previous_state: old_state
new_state: new_state
timestamp: time.now().unix()
}
return true
}
fn (ws MockWSContext) is_open() bool {
return ws.ready_state == .open
}
fn (ws MockWSContext) is_closed() bool {
return ws.ready_state == .closed || ws.ready_state == .closing
}
fn (ws MockWSContext) can_send() bool {
return ws.ready_state == .open
}
// ============================================================================
// Random State Generators
// ============================================================================
fn get_all_states() []WSReadyState {
return [WSReadyState.connecting, .open, .closing, .closed]
}
fn generate_random_state() WSReadyState {
states := get_all_states()
idx := rand.int_in_range(0, states.len) or { 0 }
return states[idx]
}
fn generate_valid_transition_sequence() []WSReadyState {
// Generate a valid sequence of state transitions
mut sequence := []WSReadyState{}
mut current := WSReadyState.connecting
sequence << current
// Randomly decide the path
choice := rand.int_in_range(0, 4) or { 0 }
match choice {
0 {
// connecting -> open -> closing -> closed
sequence << WSReadyState.open
sequence << WSReadyState.closing
sequence << WSReadyState.closed
}
1 {
// connecting -> open -> closed (abrupt)
sequence << WSReadyState.open
sequence << WSReadyState.closed
}
2 {
// connecting -> closed (handshake failed)
sequence << WSReadyState.closed
}
else {
// connecting -> open (stay open)
sequence << WSReadyState.open
}
}
return sequence
}
// ============================================================================
// Property 7: Connection State Tracking
// Feature: websocket-helper, Property 7: Connection State Tracking
// Validates: Requirements 3.5
//
// *For any* WSContext, the `ready_state` property SHALL accurately reflect
// the current connection state.
// ============================================================================
fn test_property_7_initial_state_is_connecting() bool {
// All new connections should start in connecting state
for _ in 0 .. test_iterations {
ws := create_mock_ws_context()
if ws.ready_state != .connecting {
println(' New connection should start in connecting state')
return false
}
}
return true
}
fn test_property_7_valid_transitions_succeed() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
sequence := generate_valid_transition_sequence()
mut ws := create_mock_ws_context()
// Apply all transitions in sequence (skip first as it's initial state)
for j := 1; j < sequence.len; j++ {
target_state := sequence[j]
success := ws.transition_state(target_state)
if !success {
println(' Iteration ${i}: Valid transition from ${sequence[j-1]} to ${target_state} should succeed')
return false
}
if ws.ready_state != target_state {
println(' Iteration ${i}: State should be ${target_state} after transition, got ${ws.ready_state}')
return false
}
}
// Verify history was recorded
if ws.state_history.len != sequence.len - 1 {
println(' Iteration ${i}: State history should have ${sequence.len - 1} entries, got ${ws.state_history.len}')
return false
}
}
return true
}
fn test_property_7_invalid_transitions_fail() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
// Test invalid transitions
invalid_transitions := [
// From closed, no transitions are valid
[WSReadyState.closed, WSReadyState.connecting],
[WSReadyState.closed, WSReadyState.open],
[WSReadyState.closed, WSReadyState.closing],
// From closing, can only go to closed
[WSReadyState.closing, WSReadyState.connecting],
[WSReadyState.closing, WSReadyState.open],
// From connecting, cannot go to closing
[WSReadyState.connecting, WSReadyState.closing],
// From open, cannot go back to connecting
[WSReadyState.open, WSReadyState.connecting],
]
for transition in invalid_transitions {
from_state := transition[0]
to_state := transition[1]
mut ws := create_mock_ws_context()
// Set up initial state (may need intermediate transitions)
if from_state == .open {
ws.transition_state(.open)
} else if from_state == .closing {
ws.transition_state(.open)
ws.transition_state(.closing)
} else if from_state == .closed {
ws.transition_state(.closed)
}
// Try invalid transition
old_state := ws.ready_state
success := ws.transition_state(to_state)
if success {
println(' Invalid transition from ${from_state} to ${to_state} should fail')
return false
}
// State should remain unchanged
if ws.ready_state != old_state {
println(' State should remain ${old_state} after failed transition, got ${ws.ready_state}')
return false
}
}
return true
}
fn test_property_7_state_reflects_connection_lifecycle() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
mut ws := create_mock_ws_context()
// Initial: connecting state
if ws.ready_state != .connecting {
println(' Iteration ${i}: Initial state should be connecting')
return false
}
if ws.is_open() {
println(' Iteration ${i}: is_open() should be false in connecting state')
return false
}
if ws.can_send() {
println(' Iteration ${i}: can_send() should be false in connecting state')
return false
}
// Handshake success: open state
ws.transition_state(.open)
if ws.ready_state != .open {
println(' Iteration ${i}: State should be open after handshake')
return false
}
if !ws.is_open() {
println(' Iteration ${i}: is_open() should be true in open state')
return false
}
if !ws.can_send() {
println(' Iteration ${i}: can_send() should be true in open state')
return false
}
// Close initiated: closing state
ws.transition_state(.closing)
if ws.ready_state != .closing {
println(' Iteration ${i}: State should be closing after close initiated')
return false
}
if ws.is_open() {
println(' Iteration ${i}: is_open() should be false in closing state')
return false
}
if ws.can_send() {
println(' Iteration ${i}: can_send() should be false in closing state')
return false
}
if !ws.is_closed() {
println(' Iteration ${i}: is_closed() should be true in closing state')
return false
}
// Connection terminated: closed state
ws.transition_state(.closed)
if ws.ready_state != .closed {
println(' Iteration ${i}: State should be closed after termination')
return false
}
if ws.is_open() {
println(' Iteration ${i}: is_open() should be false in closed state')
return false
}
if ws.can_send() {
println(' Iteration ${i}: can_send() should be false in closed state')
return false
}
if !ws.is_closed() {
println(' Iteration ${i}: is_closed() should be true in closed state')
return false
}
}
return true
}
fn test_property_7_state_history_tracking() bool {
rand.seed([u32(time.now().unix()), u32(77777)])
for i in 0 .. test_iterations {
sequence := generate_valid_transition_sequence()
mut ws := create_mock_ws_context()
// Apply transitions
for j := 1; j < sequence.len; j++ {
ws.transition_state(sequence[j])
}
// Verify history
for j, event in ws.state_history {
expected_from := sequence[j]
expected_to := sequence[j + 1]
if event.previous_state != expected_from {
println(' Iteration ${i}: History[${j}] previous_state should be ${expected_from}, got ${event.previous_state}')
return false
}
if event.new_state != expected_to {
println(' Iteration ${i}: History[${j}] new_state should be ${expected_to}, got ${event.new_state}')
return false
}
if event.timestamp <= 0 {
println(' Iteration ${i}: History[${j}] should have valid timestamp')
return false
}
}
}
return true
}
fn main() {
println('🚀 Starting WebSocket Connection State Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 7: Connection State Tracking
// Validates: Requirements 3.5
stats.run_property_test('Property 7: Initial State is Connecting', test_property_7_initial_state_is_connecting)
stats.run_property_test('Property 7: Valid Transitions Succeed', test_property_7_valid_transitions_succeed)
stats.run_property_test('Property 7: Invalid Transitions Fail', test_property_7_invalid_transitions_fail)
stats.run_property_test('Property 7: State Reflects Connection Lifecycle', test_property_7_state_reflects_connection_lifecycle)
stats.run_property_test('Property 7: State History Tracking', test_property_7_state_history_tracking)
// Print test summary
stats.print_summary()
}
+451
View File
@@ -0,0 +1,451 @@
// test_websocket_context_preservation.v - Property test for WebSocket context preservation
// Feature: websocket-helper, Property 10: Context Preservation
// Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5
//
// *For any* WebSocket handler, the WSContext SHALL provide access to the original
// HTTP context including route parameters, query parameters, and middleware store
// values that were set before the upgrade.
module main
import rand
import time
import net.http
const test_iterations = 100
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 Context Preservation 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')
}
}
// ============================================================================
// Minimal type definitions for testing (to avoid import issues)
// ============================================================================
// WSReadyState - WebSocket connection state enumeration
enum WSReadyState {
connecting = 0
open = 1
closing = 2
closed = 3
}
// Context - Minimal HTTP context for testing
@[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
}
// WSContext - WebSocket context for connection operations
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
}
// create_ws_context - Create a WSContext from an HTTP Context
fn create_ws_context(c &Context, protocol string) WSContext {
return WSContext{
http_ctx: c
params: c.params.clone()
query: c.query.clone()
store: c.store.clone()
ready_state: .connecting
protocol: protocol
socket: unsafe { nil }
}
}
// ============================================================================
// 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_params() map[string]string {
mut params := map[string]string{}
num_params := rand.int_in_range(0, 6) or { 0 }
for _ in 0 .. num_params {
key := generate_random_string(3, 10)
value := generate_random_string(1, 20)
params[key] = value
}
return params
}
fn generate_random_query() map[string]string {
mut query := map[string]string{}
num_params := rand.int_in_range(0, 6) or { 0 }
for _ in 0 .. num_params {
key := generate_random_string(3, 10)
value := generate_random_string(1, 20)
query[key] = value
}
return query
}
fn generate_random_store() map[string]string {
mut store := map[string]string{}
num_items := rand.int_in_range(0, 6) or { 0 }
for _ in 0 .. num_items {
key := generate_random_string(3, 15)
value := generate_random_string(1, 30)
store[key] = value
}
return store
}
fn create_test_context(params map[string]string, query map[string]string, store map[string]string) Context {
// Build query string
mut query_parts := []string{}
for k, v in query {
query_parts << '${k}=${v}'
}
query_string := if query_parts.len > 0 { '?' + query_parts.join('&') } else { '' }
url := '/ws/test${query_string}'
mut ctx := Context{
req: http.Request{
method: .get
url: url
}
params: params
query: query
url: url
path: '/ws/test'
store: store
}
return ctx
}
// ============================================================================
// Property 10: Context Preservation
// Feature: websocket-helper, Property 10: Context Preservation
// Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5
//
// *For any* WebSocket handler, the WSContext SHALL provide access to the original
// HTTP context including route parameters, query parameters, and middleware store
// values that were set before the upgrade.
// ============================================================================
// Test that route parameters are preserved
fn test_property_10_params_preserved() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random route parameters
params := generate_random_params()
query := generate_random_query()
store := generate_random_store()
// Create HTTP context
ctx := create_test_context(params, query, store)
// Create WebSocket context
ws_ctx := create_ws_context(&ctx, '')
// Verify all params are preserved
if ws_ctx.params.len != params.len {
println(' Iteration ${i}: Params count mismatch')
println(' Expected: ${params.len}, Got: ${ws_ctx.params.len}')
return false
}
for key, expected_value in params {
if key !in ws_ctx.params {
println(' Iteration ${i}: Missing param key: ${key}')
return false
}
if ws_ctx.params[key] != expected_value {
println(' Iteration ${i}: Param value mismatch for key ${key}')
println(' Expected: ${expected_value}, Got: ${ws_ctx.params[key]}')
return false
}
}
}
return true
}
// Test that query parameters are preserved
fn test_property_10_query_preserved() bool {
rand.seed([u32(time.now().unix()), u32(23456)])
for i in 0 .. test_iterations {
params := generate_random_params()
query := generate_random_query()
store := generate_random_store()
ctx := create_test_context(params, query, store)
ws_ctx := create_ws_context(&ctx, '')
// Verify all query params are preserved
if ws_ctx.query.len != query.len {
println(' Iteration ${i}: Query count mismatch')
println(' Expected: ${query.len}, Got: ${ws_ctx.query.len}')
return false
}
for key, expected_value in query {
if key !in ws_ctx.query {
println(' Iteration ${i}: Missing query key: ${key}')
return false
}
if ws_ctx.query[key] != expected_value {
println(' Iteration ${i}: Query value mismatch for key ${key}')
println(' Expected: ${expected_value}, Got: ${ws_ctx.query[key]}')
return false
}
}
}
return true
}
// Test that middleware store values are preserved
fn test_property_10_store_preserved() bool {
rand.seed([u32(time.now().unix()), u32(34567)])
for i in 0 .. test_iterations {
params := generate_random_params()
query := generate_random_query()
store := generate_random_store()
ctx := create_test_context(params, query, store)
ws_ctx := create_ws_context(&ctx, '')
// Verify all store values are preserved
if ws_ctx.store.len != store.len {
println(' Iteration ${i}: Store count mismatch')
println(' Expected: ${store.len}, Got: ${ws_ctx.store.len}')
return false
}
for key, expected_value in store {
if key !in ws_ctx.store {
println(' Iteration ${i}: Missing store key: ${key}')
return false
}
if ws_ctx.store[key] != expected_value {
println(' Iteration ${i}: Store value mismatch for key ${key}')
println(' Expected: ${expected_value}, Got: ${ws_ctx.store[key]}')
return false
}
}
}
return true
}
// Test that HTTP context reference is preserved
fn test_property_10_http_context_reference() bool {
rand.seed([u32(time.now().unix()), u32(45678)])
for i in 0 .. test_iterations {
params := generate_random_params()
query := generate_random_query()
store := generate_random_store()
ctx := create_test_context(params, query, store)
ws_ctx := create_ws_context(&ctx, '')
// Verify HTTP context reference is valid
if ws_ctx.http_ctx == unsafe { nil } {
println(' Iteration ${i}: HTTP context reference is nil')
return false
}
// Verify we can access original context data through the reference
if ws_ctx.http_ctx.path != ctx.path {
println(' Iteration ${i}: HTTP context path mismatch')
println(' Expected: ${ctx.path}, Got: ${ws_ctx.http_ctx.path}')
return false
}
}
return true
}
// Test that modifications to WSContext don't affect original context
fn test_property_10_context_isolation() bool {
rand.seed([u32(time.now().unix()), u32(56789)])
for i in 0 .. test_iterations {
params := generate_random_params()
query := generate_random_query()
store := generate_random_store()
// Save original values
original_params := params.clone()
ctx := create_test_context(params, query, store)
ws_ctx := create_ws_context(&ctx, '')
// Since WSContext fields are immutable (by design for safety),
// we verify that the cloned maps are independent copies
// by checking that the WSContext has its own copy of the data
// Verify WSContext has independent copies (not references to original)
// The fact that params, query, store are cloned means modifications
// to the original context after WSContext creation won't affect WSContext
// Verify original values are still intact in WSContext
for key, value in original_params {
if key !in ws_ctx.params || ws_ctx.params[key] != value {
println(' Iteration ${i}: WSContext params value changed unexpectedly')
return false
}
}
// Verify the maps are equal (proper cloning)
if ws_ctx.params != params {
println(' Iteration ${i}: Params not properly cloned')
return false
}
if ws_ctx.query != query {
println(' Iteration ${i}: Query not properly cloned')
return false
}
if ws_ctx.store != store {
println(' Iteration ${i}: Store not properly cloned')
return false
}
}
return true
}
// Test that protocol is correctly set
fn test_property_10_protocol_preserved() bool {
rand.seed([u32(time.now().unix()), u32(67890)])
protocols := ['', 'chat', 'graphql-ws', 'mqtt', 'custom-protocol-v1']
for i in 0 .. test_iterations {
params := generate_random_params()
query := generate_random_query()
store := generate_random_store()
// Pick a random protocol
protocol_idx := rand.int_in_range(0, protocols.len) or { 0 }
protocol := protocols[protocol_idx]
ctx := create_test_context(params, query, store)
ws_ctx := create_ws_context(&ctx, protocol)
// Verify protocol is preserved
if ws_ctx.protocol != protocol {
println(' Iteration ${i}: Protocol mismatch')
println(' Expected: ${protocol}, Got: ${ws_ctx.protocol}')
return false
}
}
return true
}
// Test combined preservation of all context data
fn test_property_10_combined_preservation() bool {
rand.seed([u32(time.now().unix()), u32(78901)])
for i in 0 .. test_iterations {
// Generate random data
params := generate_random_params()
query := generate_random_query()
store := generate_random_store()
protocol := generate_random_string(5, 15)
ctx := create_test_context(params, query, store)
ws_ctx := create_ws_context(&ctx, protocol)
// Verify all data is preserved together
all_preserved := ws_ctx.params == params &&
ws_ctx.query == query &&
ws_ctx.store == store &&
ws_ctx.protocol == protocol &&
ws_ctx.http_ctx != unsafe { nil }
if !all_preserved {
println(' Iteration ${i}: Combined preservation failed')
return false
}
}
return true
}
fn main() {
println('🚀 Starting WebSocket Context Preservation Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 10: Context Preservation
// Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5
stats.run_property_test('Property 10.1: Route Params Preserved', test_property_10_params_preserved)
stats.run_property_test('Property 10.2: Query Params Preserved', test_property_10_query_preserved)
stats.run_property_test('Property 10.3: Store Values Preserved', test_property_10_store_preserved)
stats.run_property_test('Property 10.4: HTTP Context Reference', test_property_10_http_context_reference)
stats.run_property_test('Property 10.5: Context Isolation', test_property_10_context_isolation)
stats.run_property_test('Property 10.6: Protocol Preserved', test_property_10_protocol_preserved)
stats.run_property_test('Property 10.7: Combined Preservation', test_property_10_combined_preservation)
// Print test summary
stats.print_summary()
}
+387
View File
@@ -0,0 +1,387 @@
// 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()
}
+428
View File
@@ -0,0 +1,428 @@
// test_websocket_frame_masking.v - Property test for WebSocket frame masking
// Feature: websocket-helper, Property 12: Frame Masking
// Validates: Requirements 7.6
//
// *For any* WebSocket frame received from a client, the frame SHALL be masked
// (MASK bit set), and the server SHALL correctly unmask the payload.
// *For any* frame sent by the server, the frame SHALL NOT be masked.
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 Masking 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 with custom mask key support
// ============================================================================
struct WSFrame {
pub:
fin bool
opcode u8
masked bool
mask_key [4]u8
payload_len u64
payload []u8
}
// encode_ws_frame_with_mask - Encode with a specific mask key for testing
fn encode_ws_frame_with_mask(opcode u8, payload []u8, mask_key [4]u8) []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 (1) + payload length
if payload_len <= 125 {
frame << u8(0x80) | u8(payload_len)
} else if payload_len <= 65535 {
frame << u8(0x80) | u8(126)
frame << u8(payload_len >> 8)
frame << u8(payload_len & 0xFF)
} else {
frame << u8(0x80) | u8(127)
for i := 7; i >= 0; i-- {
frame << u8((payload_len >> (i * 8)) & 0xFF)
}
}
// Add masking key
frame << mask_key[0]
frame << mask_key[1]
frame << mask_key[2]
frame << mask_key[3]
// Add masked payload
for i, b in payload {
frame << b ^ mask_key[i % 4]
}
return frame
}
// encode_ws_frame - Standard encoding (server frames are unmasked)
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_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_random_mask_key() [4]u8 {
return [
u8(rand.int_in_range(0, 256) or { 0 }),
u8(rand.int_in_range(0, 256) or { 0 }),
u8(rand.int_in_range(0, 256) or { 0 }),
u8(rand.int_in_range(0, 256) or { 0 }),
]!
}
fn generate_random_string() string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
len := rand.int_in_range(1, 100) or { 20 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// ============================================================================
// Property 12: Frame Masking
// Feature: websocket-helper, Property 12: Frame Masking
// Validates: Requirements 7.6
//
// *For any* WebSocket frame received from a client, the frame SHALL be masked
// (MASK bit set), and the server SHALL correctly unmask the payload.
// ============================================================================
fn test_property_12_client_frames_masked() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random payload and mask key (simulating client frame)
original_payload := generate_random_bytes()
mask_key := generate_random_mask_key()
// Encode as masked frame (client -> server)
frame := encode_ws_frame_with_mask(ws_opcode_text, original_payload, mask_key)
// Decode the frame (server should unmask)
decoded := decode_ws_frame(frame) or {
println(' Iteration ${i}: Failed to decode frame: ${err}')
return false
}
// Verify MASK bit was set in the frame
if !decoded.masked {
println(' Iteration ${i}: Client frame should have MASK bit set')
return false
}
// Verify payload was correctly unmasked
if decoded.payload != original_payload {
println(' Iteration ${i}: Payload not correctly unmasked')
println(' Expected: ${original_payload}')
println(' Got: ${decoded.payload}')
return false
}
}
return true
}
// ============================================================================
// Property 12: Server frames are NOT masked
// *For any* frame sent by the server, the frame SHALL NOT be masked.
// ============================================================================
fn test_property_12_server_frames_unmasked() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate random payload
original_payload := generate_random_bytes()
// Encode as unmasked frame (server -> client)
frame := encode_ws_frame(ws_opcode_text, original_payload, false)
// Decode the frame
decoded := decode_ws_frame(frame) or {
println(' Iteration ${i}: Failed to decode frame: ${err}')
return false
}
// Verify MASK bit is NOT set
if decoded.masked {
println(' Iteration ${i}: Server frame should NOT have MASK bit set')
return false
}
// Verify payload is preserved exactly
if decoded.payload != original_payload {
println(' Iteration ${i}: Payload mismatch')
return false
}
}
return true
}
// ============================================================================
// Additional test: Masking round-trip (mask then unmask)
// ============================================================================
fn test_masking_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
// Generate random data
original := generate_random_string()
mask_key := generate_random_mask_key()
// Encode with masking
masked_frame := encode_ws_frame_with_mask(ws_opcode_text, original.bytes(), mask_key)
// Decode (which unmasks)
decoded := decode_ws_frame(masked_frame) or {
println(' Iteration ${i}: Failed to decode: ${err}')
return false
}
// Verify round-trip: unmask(mask(data)) == data
if decoded.payload.bytestr() != original {
println(' Iteration ${i}: Round-trip failed')
println(' Original: ${original}')
println(' After round-trip: ${decoded.payload.bytestr()}')
return false
}
}
return true
}
// ============================================================================
// Additional test: Different mask keys produce different masked data
// ============================================================================
fn test_different_masks_different_output() bool {
payload := 'Hello, WebSocket!'.bytes()
mask1 := [u8(0x12), 0x34, 0x56, 0x78]!
mask2 := [u8(0xAB), 0xCD, 0xEF, 0x01]!
frame1 := encode_ws_frame_with_mask(ws_opcode_text, payload, mask1)
frame2 := encode_ws_frame_with_mask(ws_opcode_text, payload, mask2)
// The frames should be different (different masks)
if frame1 == frame2 {
println(' Different masks should produce different frames')
return false
}
// But both should decode to the same payload
decoded1 := decode_ws_frame(frame1) or {
println(' Failed to decode frame1')
return false
}
decoded2 := decode_ws_frame(frame2) or {
println(' Failed to decode frame2')
return false
}
if decoded1.payload != decoded2.payload {
println(' Both frames should decode to same payload')
return false
}
return true
}
// ============================================================================
// Additional test: Zero mask key
// ============================================================================
fn test_zero_mask_key() bool {
payload := 'Test with zero mask'.bytes()
zero_mask := [u8(0), 0, 0, 0]!
frame := encode_ws_frame_with_mask(ws_opcode_text, payload, zero_mask)
decoded := decode_ws_frame(frame) or {
println(' Failed to decode frame with zero mask')
return false
}
// With zero mask, XOR should leave payload unchanged
if decoded.payload != payload {
println(' Zero mask should preserve payload')
return false
}
return true
}
fn main() {
println('🚀 Starting WebSocket Frame Masking Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 12: Frame Masking
// Validates: Requirements 7.6
stats.run_property_test('Property 12: Client Frames Masked', test_property_12_client_frames_masked)
stats.run_property_test('Property 12: Server Frames Unmasked', test_property_12_server_frames_unmasked)
stats.run_property_test('Masking Round-Trip', test_masking_roundtrip)
stats.run_property_test('Different Masks Different Output', test_different_masks_different_output)
stats.run_property_test('Zero Mask Key', test_zero_mask_key)
// Print test summary
stats.print_summary()
}
+273
View File
@@ -0,0 +1,273 @@
// test_websocket_handshake.v - Property test for WebSocket handshake compliance
// Feature: websocket-helper, Property 1: WebSocket Handshake Compliance
// Validates: Requirements 1.2, 7.1, 7.2
//
// *For any* valid WebSocket upgrade request containing a Sec-WebSocket-Key header,
// the server's Sec-WebSocket-Accept response header SHALL equal
// base64(sha1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")).
module main
import rand
import time
import crypto.sha1
import encoding.base64
const test_iterations = 100
// WebSocket magic GUID for handshake (RFC 6455)
const ws_magic_guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
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 Handshake 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 Handshake Functions (copied from websocket.v for testing)
// ============================================================================
// compute_accept_key - Compute Sec-WebSocket-Accept from Sec-WebSocket-Key
// According to RFC 6455: base64(sha1(key + GUID))
fn compute_accept_key(key string) string {
combined := key + ws_magic_guid
hash := sha1.sum(combined.bytes())
return base64.encode(hash)
}
// Reference implementation for verification
fn reference_compute_accept_key(key string) string {
// This is the exact algorithm from RFC 6455
combined := key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
hash := sha1.sum(combined.bytes())
return base64.encode(hash)
}
// ============================================================================
// Random Data Generators
// ============================================================================
// Generate a valid base64-encoded 16-byte key (as per RFC 6455)
fn generate_random_websocket_key() string {
mut bytes := []u8{cap: 16}
for _ in 0 .. 16 {
bytes << u8(rand.int_in_range(0, 256) or { 0 })
}
return base64.encode(bytes)
}
// Generate a random string for testing
fn generate_random_string() string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='
len := rand.int_in_range(10, 50) or { 24 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// ============================================================================
// Property 1: WebSocket Handshake Compliance
// Feature: websocket-helper, Property 1: WebSocket Handshake Compliance
// Validates: Requirements 1.2, 7.1, 7.2
//
// *For any* valid WebSocket upgrade request containing a Sec-WebSocket-Key header,
// the server's Sec-WebSocket-Accept response header SHALL equal
// base64(sha1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")).
// ============================================================================
fn test_property_1_handshake_compliance() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate a random WebSocket key
ws_key := generate_random_websocket_key()
// Compute accept key using our implementation
accept_key := compute_accept_key(ws_key)
// Compute expected accept key using reference implementation
expected_accept := reference_compute_accept_key(ws_key)
// Verify they match
if accept_key != expected_accept {
println(' Iteration ${i}: Accept key mismatch')
println(' Key: ${ws_key}')
println(' Expected: ${expected_accept}')
println(' Got: ${accept_key}')
return false
}
// Verify the accept key is valid base64
decoded := base64.decode(accept_key)
if decoded.len != 20 {
// SHA-1 produces 20 bytes
println(' Iteration ${i}: Accept key should decode to 20 bytes (SHA-1 hash)')
println(' Got ${decoded.len} bytes')
return false
}
}
return true
}
// ============================================================================
// Test with known RFC 6455 example
// ============================================================================
fn test_rfc_6455_example() bool {
// Example from RFC 6455 Section 1.3
// Key: "dGhlIHNhbXBsZSBub25jZQ=="
// Expected Accept: "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
key := 'dGhlIHNhbXBsZSBub25jZQ=='
expected := 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
accept := compute_accept_key(key)
if accept != expected {
println(' RFC 6455 example failed')
println(' Key: ${key}')
println(' Expected: ${expected}')
println(' Got: ${accept}')
return false
}
return true
}
// ============================================================================
// Test determinism: same key always produces same accept
// ============================================================================
fn test_deterministic_accept_key() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
ws_key := generate_random_websocket_key()
// Compute accept key multiple times
accept1 := compute_accept_key(ws_key)
accept2 := compute_accept_key(ws_key)
accept3 := compute_accept_key(ws_key)
// All should be identical
if accept1 != accept2 || accept2 != accept3 {
println(' Iteration ${i}: Accept key not deterministic')
println(' Key: ${ws_key}')
println(' Accept1: ${accept1}')
println(' Accept2: ${accept2}')
println(' Accept3: ${accept3}')
return false
}
}
return true
}
// ============================================================================
// Test different keys produce different accepts
// ============================================================================
fn test_different_keys_different_accepts() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
key1 := generate_random_websocket_key()
key2 := generate_random_websocket_key()
// Skip if we randomly generated the same key
if key1 == key2 {
continue
}
accept1 := compute_accept_key(key1)
accept2 := compute_accept_key(key2)
// Different keys should produce different accepts
if accept1 == accept2 {
println(' Iteration ${i}: Different keys produced same accept')
println(' Key1: ${key1}')
println(' Key2: ${key2}')
println(' Accept: ${accept1}')
return false
}
}
return true
}
// ============================================================================
// Test accept key format is valid base64
// ============================================================================
fn test_accept_key_valid_base64() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
for i in 0 .. test_iterations {
ws_key := generate_random_websocket_key()
accept_key := compute_accept_key(ws_key)
// Try to decode - should not fail
decoded := base64.decode(accept_key)
// SHA-1 hash is always 20 bytes
if decoded.len != 20 {
println(' Iteration ${i}: Invalid accept key length')
println(' Expected 20 bytes, got ${decoded.len}')
return false
}
// Re-encode should match original
re_encoded := base64.encode(decoded)
if re_encoded != accept_key {
println(' Iteration ${i}: Base64 round-trip failed')
return false
}
}
return true
}
fn main() {
println('🚀 Starting WebSocket Handshake Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 1: WebSocket Handshake Compliance
// Validates: Requirements 1.2, 7.1, 7.2
stats.run_property_test('Property 1: Handshake Compliance', test_property_1_handshake_compliance)
stats.run_property_test('RFC 6455 Example', test_rfc_6455_example)
stats.run_property_test('Deterministic Accept Key', test_deterministic_accept_key)
stats.run_property_test('Different Keys Different Accepts', test_different_keys_different_accepts)
stats.run_property_test('Accept Key Valid Base64', test_accept_key_valid_base64)
// Print test summary
stats.print_summary()
}
+400
View File
@@ -0,0 +1,400 @@
// test_websocket_handshake_errors.v - Property test for WebSocket handshake error handling
// Feature: websocket-helper, Property 2: Handshake Error Handling
// Validates: Requirements 1.4
//
// *For any* HTTP request to a WebSocket route that lacks required WebSocket headers
// (Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Key), the server SHALL
// return an HTTP 400 Bad Request response.
module main
import rand
import time
const test_iterations = 100
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 Handshake Error Handling 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')
}
}
// ============================================================================
// Mock Context for testing
// ============================================================================
struct MockContext {
pub:
headers map[string]string
}
// validate_upgrade_request - Validate WebSocket upgrade request headers
// Returns the WebSocket key if valid, or an error if invalid
fn validate_upgrade_request(ctx MockContext) !string {
// Check Upgrade header
upgrade := ctx.headers['Upgrade'] or {
return error('Missing Upgrade header')
}
if upgrade.to_lower() != 'websocket' {
return error('Invalid Upgrade header')
}
// Check Connection header
connection := ctx.headers['Connection'] or {
return error('Missing Connection header')
}
if !connection.to_lower().contains('upgrade') {
return error('Invalid Connection header')
}
// Check Sec-WebSocket-Key
ws_key := ctx.headers['Sec-WebSocket-Key'] or {
return error('Missing Sec-WebSocket-Key header')
}
if ws_key.len == 0 {
return error('Empty Sec-WebSocket-Key header')
}
// Check Sec-WebSocket-Version (must be 13)
ws_version := ctx.headers['Sec-WebSocket-Version'] or { '13' }
if ws_version != '13' {
return error('Unsupported WebSocket version')
}
return ws_key
}
// ============================================================================
// Random Data Generators
// ============================================================================
fn generate_random_string() string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
len := rand.int_in_range(5, 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_valid_websocket_key() string {
// Generate a valid base64-encoded 16-byte key
chars := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
mut result := ''
for _ in 0 .. 22 {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result + '=='
}
// ============================================================================
// Property 2: Handshake Error Handling
// Feature: websocket-helper, Property 2: Handshake Error Handling
// Validates: Requirements 1.4
//
// *For any* HTTP request to a WebSocket route that lacks required WebSocket headers,
// the server SHALL return an error.
// ============================================================================
// Test: Missing Upgrade header should fail
fn test_property_2_missing_upgrade_header() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Create context without Upgrade header
ctx := MockContext{
headers: {
'Connection': 'Upgrade'
'Sec-WebSocket-Key': generate_valid_websocket_key()
'Sec-WebSocket-Version': '13'
}
}
// Validation should fail
validate_upgrade_request(ctx) or {
// Expected to fail
if !err.msg().contains('Upgrade') {
println(' Iteration ${i}: Wrong error message: ${err.msg()}')
return false
}
continue
}
// If we get here, validation succeeded when it should have failed
println(' Iteration ${i}: Should have failed without Upgrade header')
return false
}
return true
}
// Test: Missing Connection header should fail
fn test_property_2_missing_connection_header() bool {
rand.seed([u32(time.now().unix()), u32(23456)])
for i in 0 .. test_iterations {
// Create context without Connection header
ctx := MockContext{
headers: {
'Upgrade': 'websocket'
'Sec-WebSocket-Key': generate_valid_websocket_key()
'Sec-WebSocket-Version': '13'
}
}
// Validation should fail
validate_upgrade_request(ctx) or {
// Expected to fail
if !err.msg().contains('Connection') {
println(' Iteration ${i}: Wrong error message: ${err.msg()}')
return false
}
continue
}
// If we get here, validation succeeded when it should have failed
println(' Iteration ${i}: Should have failed without Connection header')
return false
}
return true
}
// Test: Missing Sec-WebSocket-Key header should fail
fn test_property_2_missing_websocket_key() bool {
rand.seed([u32(time.now().unix()), u32(34567)])
for i in 0 .. test_iterations {
// Create context without Sec-WebSocket-Key header
ctx := MockContext{
headers: {
'Upgrade': 'websocket'
'Connection': 'Upgrade'
'Sec-WebSocket-Version': '13'
}
}
// Validation should fail
validate_upgrade_request(ctx) or {
// Expected to fail
if !err.msg().contains('Sec-WebSocket-Key') {
println(' Iteration ${i}: Wrong error message: ${err.msg()}')
return false
}
continue
}
// If we get here, validation succeeded when it should have failed
println(' Iteration ${i}: Should have failed without Sec-WebSocket-Key header')
return false
}
return true
}
// Test: Invalid Upgrade header value should fail
fn test_property_2_invalid_upgrade_value() bool {
rand.seed([u32(time.now().unix()), u32(45678)])
invalid_values := ['http', 'tcp', 'socket', 'ws', 'WebSocket2', '']
for i, invalid_value in invalid_values {
ctx := MockContext{
headers: {
'Upgrade': invalid_value
'Connection': 'Upgrade'
'Sec-WebSocket-Key': generate_valid_websocket_key()
'Sec-WebSocket-Version': '13'
}
}
// Validation should fail
validate_upgrade_request(ctx) or {
// Expected to fail
continue
}
// If we get here, validation succeeded when it should have failed
println(' Test ${i}: Should have failed with invalid Upgrade value: "${invalid_value}"')
return false
}
return true
}
// Test: Invalid Connection header value should fail
fn test_property_2_invalid_connection_value() bool {
rand.seed([u32(time.now().unix()), u32(56789)])
invalid_values := ['keep-alive', 'close', 'http', '']
for i, invalid_value in invalid_values {
ctx := MockContext{
headers: {
'Upgrade': 'websocket'
'Connection': invalid_value
'Sec-WebSocket-Key': generate_valid_websocket_key()
'Sec-WebSocket-Version': '13'
}
}
// Validation should fail
validate_upgrade_request(ctx) or {
// Expected to fail
continue
}
// If we get here, validation succeeded when it should have failed
println(' Test ${i}: Should have failed with invalid Connection value: "${invalid_value}"')
return false
}
return true
}
// Test: Valid headers should succeed
fn test_valid_headers_succeed() bool {
rand.seed([u32(time.now().unix()), u32(67890)])
for i in 0 .. test_iterations {
ws_key := generate_valid_websocket_key()
ctx := MockContext{
headers: {
'Upgrade': 'websocket'
'Connection': 'Upgrade'
'Sec-WebSocket-Key': ws_key
'Sec-WebSocket-Version': '13'
}
}
// Validation should succeed
result := validate_upgrade_request(ctx) or {
println(' Iteration ${i}: Should have succeeded with valid headers: ${err.msg()}')
return false
}
// Verify the returned key matches
if result != ws_key {
println(' Iteration ${i}: Returned key mismatch')
println(' Expected: ${ws_key}')
println(' Got: ${result}')
return false
}
}
return true
}
// Test: Case-insensitive header values
fn test_case_insensitive_headers() bool {
test_cases := [
['WebSocket', 'Upgrade'],
['WEBSOCKET', 'UPGRADE'],
['websocket', 'upgrade'],
['WebSocket', 'upgrade'],
['websocket', 'Upgrade, keep-alive'],
]
for i, test_case in test_cases {
ws_key := generate_valid_websocket_key()
ctx := MockContext{
headers: {
'Upgrade': test_case[0]
'Connection': test_case[1]
'Sec-WebSocket-Key': ws_key
'Sec-WebSocket-Version': '13'
}
}
// Validation should succeed
validate_upgrade_request(ctx) or {
println(' Test ${i}: Should have succeeded with case variation')
println(' Upgrade: ${test_case[0]}, Connection: ${test_case[1]}')
println(' Error: ${err.msg()}')
return false
}
}
return true
}
// Test: Unsupported WebSocket version should fail
fn test_unsupported_websocket_version() bool {
invalid_versions := ['8', '9', '10', '11', '12', '14', '0', '']
for i, version in invalid_versions {
ctx := MockContext{
headers: {
'Upgrade': 'websocket'
'Connection': 'Upgrade'
'Sec-WebSocket-Key': generate_valid_websocket_key()
'Sec-WebSocket-Version': version
}
}
// Validation should fail
validate_upgrade_request(ctx) or {
// Expected to fail
continue
}
// If we get here, validation succeeded when it should have failed
println(' Test ${i}: Should have failed with unsupported version: "${version}"')
return false
}
return true
}
fn main() {
println('🚀 Starting WebSocket Handshake Error Handling Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 2: Handshake Error Handling
// Validates: Requirements 1.4
stats.run_property_test('Property 2: Missing Upgrade Header', test_property_2_missing_upgrade_header)
stats.run_property_test('Property 2: Missing Connection Header', test_property_2_missing_connection_header)
stats.run_property_test('Property 2: Missing WebSocket Key', test_property_2_missing_websocket_key)
stats.run_property_test('Property 2: Invalid Upgrade Value', test_property_2_invalid_upgrade_value)
stats.run_property_test('Property 2: Invalid Connection Value', test_property_2_invalid_connection_value)
stats.run_property_test('Valid Headers Succeed', test_valid_headers_succeed)
stats.run_property_test('Case Insensitive Headers', test_case_insensitive_headers)
stats.run_property_test('Unsupported WebSocket Version', test_unsupported_websocket_version)
// Print test summary
stats.print_summary()
}
+384
View File
@@ -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()
}
@@ -0,0 +1,880 @@
// 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()
}
+534
View File
@@ -0,0 +1,534 @@
// test_websocket_message_size.v - Property test for WebSocket message size limit
// Feature: websocket-helper, Property 8: Message Size Limit
// Validates: Requirements 4.2
//
// *For any* incoming WebSocket message exceeding the configured max_message_size,
// the server SHALL close the connection with status code 1009 (Message Too Big)
// and invoke the on_error callback.
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)
const ws_opcode_close = u8(0x8)
const ws_opcode_ping = u8(0x9)
const ws_opcode_pong = u8(0xA)
// WebSocket close codes
const ws_close_message_too_big = 1009
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 Message Size Limit 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 Structure (copied from websocket.v for testing)
// ============================================================================
struct WSFrame {
pub:
fin bool
opcode u8
masked bool
mask_key [4]u8
payload_len u64
payload []u8
}
// WSReadyState - WebSocket connection state enumeration
enum WSReadyState {
connecting = 0
open = 1
closing = 2
closed = 3
}
// WSStateChangeEvent - Event structure for state change notifications
struct WSStateChangeEvent {
pub:
previous_state WSReadyState
new_state WSReadyState
timestamp i64
}
// WSContext - Simplified WebSocket context for testing
struct WSContext {
pub mut:
ready_state WSReadyState
state_history []WSStateChangeEvent
close_code int
close_reason string
error_messages []string
}
fn (mut ws WSContext) force_close(code int, reason string) {
ws.close_code = code
ws.close_reason = reason
ws.ready_state = .closed
ws.state_history << WSStateChangeEvent{
previous_state: .open
new_state: .closed
timestamp: time.now().unix()
}
}
// WSMessageEvent - Message event structure
struct WSMessageEvent {
pub:
data string
data_bytes []u8
is_binary bool
}
// WSCloseEvent - Close event structure
struct WSCloseEvent {
pub:
code int
reason string
was_clean bool
}
// Event handler types
type WSOpenHandler = fn (mut ws WSContext)
type WSMessageHandler = fn (event WSMessageEvent, mut ws WSContext)
type WSCloseHandler = fn (event WSCloseEvent, mut ws WSContext)
type WSErrorHandler = fn (error string, mut ws WSContext)
// WSEvents - WebSocket event handlers configuration
struct WSEvents {
pub:
on_open ?WSOpenHandler
on_message ?WSMessageHandler
on_close ?WSCloseHandler
on_error ?WSErrorHandler
}
// ============================================================================
// Message Size Validation Function (copied from websocket.v for testing)
// ============================================================================
fn validate_message_size(frame WSFrame, max_message_size int, events WSEvents, mut ws WSContext) bool {
// Only validate data frames (text and binary)
if frame.opcode != ws_opcode_text && frame.opcode != ws_opcode_binary {
return true
}
// Check if message size exceeds the limit
if max_message_size > 0 && int(frame.payload_len) > max_message_size {
// Invoke on_error callback
error_msg := 'Message size ${frame.payload_len} exceeds maximum allowed size ${max_message_size}'
if on_error := events.on_error {
on_error(error_msg, mut ws)
}
// Close connection with 1009 (Message Too Big)
ws.force_close(ws_close_message_too_big, 'Message too big')
return false
}
return true
}
// ============================================================================
// Random Data Generators
// ============================================================================
fn generate_random_payload(size int) []u8 {
mut result := []u8{cap: size}
for _ in 0 .. size {
result << u8(rand.int_in_range(0, 256) or { 0 })
}
return result
}
fn generate_random_text_payload(size int) []u8 {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
mut result := []u8{cap: size}
for _ in 0 .. size {
idx := rand.int_in_range(0, chars.len) or { 0 }
result << chars[idx]
}
return result
}
fn create_test_frame(opcode u8, payload []u8) WSFrame {
return WSFrame{
fin: true
opcode: opcode
masked: false
mask_key: [4]u8{}
payload_len: u64(payload.len)
payload: payload
}
}
fn create_test_ws_context() WSContext {
return WSContext{
ready_state: .open
state_history: []
close_code: 0
close_reason: ''
error_messages: []
}
}
// ============================================================================
// Property 8: Message Size Limit
// Feature: websocket-helper, Property 8: Message Size Limit
// Validates: Requirements 4.2
//
// *For any* incoming WebSocket message exceeding the configured max_message_size,
// the server SHALL close the connection with status code 1009 (Message Too Big)
// and invoke the on_error callback.
// ============================================================================
fn test_property_8_oversized_messages_rejected() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random max_message_size between 100 and 10000
max_size := rand.int_in_range(100, 10000) or { 1000 }
// Generate a payload that exceeds the max size
excess := rand.int_in_range(1, 1000) or { 100 }
oversized_payload := generate_random_payload(max_size + excess)
// Create a text frame with oversized payload
frame := create_test_frame(ws_opcode_text, oversized_payload)
// Create test context
mut ws := create_test_ws_context()
// Create events with error handler that stores error in ws.error_messages
events := WSEvents{
on_error: fn (err string, mut ws WSContext) {
ws.error_messages << err
}
}
// Validate message size
result := validate_message_size(frame, max_size, events, mut ws)
// Verify validation returns false for oversized message
if result {
println(' Iteration ${i}: Expected validation to return false for oversized message')
println(' Max size: ${max_size}, Payload size: ${oversized_payload.len}')
return false
}
// Verify connection was closed with 1009
if ws.close_code != ws_close_message_too_big {
println(' Iteration ${i}: Expected close code 1009, got ${ws.close_code}')
return false
}
// Verify connection state is closed
if ws.ready_state != .closed {
println(' Iteration ${i}: Expected connection state to be closed')
return false
}
// Verify on_error was invoked by checking error_messages
if ws.error_messages.len == 0 {
println(' Iteration ${i}: Expected on_error callback to be invoked')
return false
}
}
return true
}
fn test_property_8_valid_messages_accepted() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate random max_message_size between 100 and 10000
max_size := rand.int_in_range(100, 10000) or { 1000 }
// Generate a payload that is within the limit
payload_size := rand.int_in_range(0, max_size) or { 50 }
valid_payload := generate_random_payload(payload_size)
// Create a text frame with valid payload
frame := create_test_frame(ws_opcode_text, valid_payload)
// Create test context
mut ws := create_test_ws_context()
// Create events with error handler
events := WSEvents{
on_error: fn (err string, mut ws WSContext) {
ws.error_messages << err
}
}
// Validate message size
result := validate_message_size(frame, max_size, events, mut ws)
// Verify validation returns true for valid message
if !result {
println(' Iteration ${i}: Expected validation to return true for valid message')
println(' Max size: ${max_size}, Payload size: ${valid_payload.len}')
return false
}
// Verify connection was NOT closed
if ws.close_code != 0 {
println(' Iteration ${i}: Connection should not be closed for valid message')
return false
}
// Verify connection state is still open
if ws.ready_state != .open {
println(' Iteration ${i}: Connection state should remain open')
return false
}
// Verify on_error was NOT invoked
if ws.error_messages.len > 0 {
println(' Iteration ${i}: on_error should not be invoked for valid message')
return false
}
}
return true
}
fn test_property_8_binary_messages_validated() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
// Generate random max_message_size
max_size := rand.int_in_range(100, 5000) or { 500 }
// Randomly decide if message should be oversized or valid
is_oversized := rand.int_in_range(0, 2) or { 0 } == 1
payload_size := if is_oversized {
max_size + rand.int_in_range(1, 500) or { 100 }
} else {
rand.int_in_range(0, max_size) or { 50 }
}
payload := generate_random_payload(payload_size)
// Create a binary frame
frame := create_test_frame(ws_opcode_binary, payload)
// Create test context
mut ws := create_test_ws_context()
events := WSEvents{
on_error: fn (err string, mut ws WSContext) {
ws.error_messages << err
}
}
// Validate message size
result := validate_message_size(frame, max_size, events, mut ws)
// Verify result matches expectation
if is_oversized {
if result {
println(' Iteration ${i}: Expected false for oversized binary message')
return false
}
if ws.close_code != ws_close_message_too_big {
println(' Iteration ${i}: Expected close code 1009 for oversized binary')
return false
}
} else {
if !result {
println(' Iteration ${i}: Expected true for valid binary message')
return false
}
if ws.close_code != 0 {
println(' Iteration ${i}: Should not close for valid binary message')
return false
}
}
}
return true
}
fn test_property_8_control_frames_not_validated() bool {
rand.seed([u32(time.now().unix()), u32(77777)])
// Control frames (ping, pong, close) should not be subject to message size validation
control_opcodes := [ws_opcode_ping, ws_opcode_pong, ws_opcode_close]
for i in 0 .. test_iterations {
// Use a very small max_message_size
max_size := 10
// Generate payload larger than max_size
payload := generate_random_payload(50)
// Pick a random control opcode
opcode := control_opcodes[rand.int_in_range(0, control_opcodes.len) or { 0 }]
// Create a control frame with "oversized" payload
frame := create_test_frame(opcode, payload)
// Create test context
mut ws := create_test_ws_context()
events := WSEvents{
on_error: fn (err string, mut ws WSContext) {
ws.error_messages << err
}
}
// Validate message size - should pass for control frames
result := validate_message_size(frame, max_size, events, mut ws)
// Control frames should always pass validation
if !result {
println(' Iteration ${i}: Control frame (opcode 0x${opcode:02x}) should not be size-validated')
return false
}
// Connection should remain open
if ws.ready_state != .open {
println(' Iteration ${i}: Connection should remain open for control frames')
return false
}
// No error should be raised
if ws.error_messages.len > 0 {
println(' Iteration ${i}: No error should be raised for control frames')
return false
}
}
return true
}
fn test_property_8_exact_boundary() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
for i in 0 .. test_iterations {
// Generate random max_message_size
max_size := rand.int_in_range(100, 5000) or { 500 }
// Test exact boundary - message exactly at max_size should be accepted
exact_payload := generate_random_payload(max_size)
frame := create_test_frame(ws_opcode_text, exact_payload)
mut ws := create_test_ws_context()
events := WSEvents{}
result := validate_message_size(frame, max_size, events, mut ws)
// Exact boundary should be accepted
if !result {
println(' Iteration ${i}: Message at exact boundary (${max_size}) should be accepted')
return false
}
// Test one byte over - should be rejected
over_payload := generate_random_payload(max_size + 1)
over_frame := create_test_frame(ws_opcode_text, over_payload)
mut ws2 := create_test_ws_context()
result2 := validate_message_size(over_frame, max_size, events, mut ws2)
if result2 {
println(' Iteration ${i}: Message one byte over (${max_size + 1}) should be rejected')
return false
}
}
return true
}
fn test_property_8_zero_max_size_disables_validation() bool {
rand.seed([u32(time.now().unix()), u32(22222)])
for i in 0 .. test_iterations {
// Generate a large payload
payload_size := rand.int_in_range(1000, 100000) or { 10000 }
payload := generate_random_payload(payload_size)
frame := create_test_frame(ws_opcode_text, payload)
mut ws := create_test_ws_context()
events := WSEvents{}
// With max_size = 0, validation should be disabled
result := validate_message_size(frame, 0, events, mut ws)
if !result {
println(' Iteration ${i}: With max_size=0, all messages should be accepted')
return false
}
if ws.ready_state != .open {
println(' Iteration ${i}: Connection should remain open when validation disabled')
return false
}
}
return true
}
fn main() {
println('🚀 Starting WebSocket Message Size Limit Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 8: Message Size Limit
// Validates: Requirements 4.2
stats.run_property_test('Property 8: Oversized Messages Rejected', test_property_8_oversized_messages_rejected)
stats.run_property_test('Property 8: Valid Messages Accepted', test_property_8_valid_messages_accepted)
stats.run_property_test('Property 8: Binary Messages Validated', test_property_8_binary_messages_validated)
stats.run_property_test('Property 8: Control Frames Not Validated', test_property_8_control_frames_not_validated)
stats.run_property_test('Property 8: Exact Boundary', test_property_8_exact_boundary)
stats.run_property_test('Property 8: Zero Max Size Disables Validation', test_property_8_zero_max_size_disables_validation)
// Print test summary
stats.print_summary()
}
+578
View File
@@ -0,0 +1,578 @@
// test_websocket_ping_pong.v - Property test for WebSocket ping/pong handling
// Feature: websocket-helper, Property 11: Ping/Pong Handling
// Validates: Requirements 7.4
//
// *For any* ping frame received from a client, the server SHALL respond with
// a pong frame containing the same payload data.
module main
import rand
import time
const test_iterations = 100
// WebSocket opcodes (RFC 6455)
const ws_opcode_ping = u8(0x9)
const ws_opcode_pong = u8(0xA)
// WebSocket Ready State
enum WSReadyState {
connecting = 0
open = 1
closing = 2
closed = 3
}
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 Ping/Pong 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')
}
}
// ============================================================================
// Frame Encoding/Decoding (copied from websocket.v for testing)
// ============================================================================
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) !(bool, u8, []u8) {
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 fin, opcode, payload
}
// ============================================================================
// Mock WSContext for testing
// ============================================================================
struct MockWSContext {
mut:
ready_state WSReadyState
sent_frames [][]u8
ping_interval int
last_ping_sent i64
last_pong_received i64
pong_timeout int
awaiting_pong bool
}
fn create_mock_ws_context() MockWSContext {
return MockWSContext{
ready_state: .open
sent_frames: [][]u8{}
ping_interval: 30000
pong_timeout: 10000
}
}
fn (mut ws MockWSContext) send_ping(payload []u8) ![]u8 {
if ws.ready_state != .open {
return error('WebSocket connection is not open')
}
if payload.len > 125 {
return error('Ping payload too large (max 125 bytes)')
}
frame := encode_ws_frame(ws_opcode_ping, payload, false)
ws.sent_frames << frame
ws.last_ping_sent = time.now().unix()
ws.awaiting_pong = true
return frame
}
fn (mut ws MockWSContext) handle_ping(payload []u8) ![]u8 {
if ws.ready_state != .open {
return error('WebSocket connection is not open')
}
// Build pong frame with same payload
pong_frame := encode_ws_frame(ws_opcode_pong, payload, false)
ws.sent_frames << pong_frame
return pong_frame
}
fn (mut ws MockWSContext) handle_pong(payload []u8) {
ws.last_pong_received = time.now().unix()
ws.awaiting_pong = false
}
fn (ws MockWSContext) is_pong_timeout() bool {
if !ws.awaiting_pong {
return false
}
if ws.pong_timeout <= 0 {
return false
}
elapsed := time.now().unix() - ws.last_ping_sent
timeout_seconds := ws.pong_timeout / 1000
return elapsed > timeout_seconds
}
fn (ws MockWSContext) should_send_ping() bool {
if ws.ping_interval <= 0 {
return false
}
if ws.ready_state != .open {
return false
}
if ws.awaiting_pong {
return false
}
elapsed := time.now().unix() - ws.last_ping_sent
interval_seconds := ws.ping_interval / 1000
return elapsed >= interval_seconds
}
// ============================================================================
// Random Data Generators
// ============================================================================
fn generate_random_ping_payload() []u8 {
// Ping payload must be 125 bytes or less per RFC 6455
len := rand.int_in_range(0, 126) 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_small_payload() []u8 {
len := rand.int_in_range(0, 20) or { 10 }
mut result := []u8{cap: len}
for _ in 0 .. len {
result << u8(rand.int_in_range(0, 256) or { 0 })
}
return result
}
// ============================================================================
// Property 11: Ping/Pong Handling
// Feature: websocket-helper, Property 11: Ping/Pong Handling
// Validates: Requirements 7.4
//
// *For any* ping frame received from a client, the server SHALL respond with
// a pong frame containing the same payload data.
// ============================================================================
fn test_property_11_pong_echoes_ping_payload() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
ping_payload := generate_random_ping_payload()
mut ws := create_mock_ws_context()
// Handle incoming ping
pong_frame := ws.handle_ping(ping_payload) or {
println(' Iteration ${i}: Failed to handle ping: ${err}')
return false
}
// Decode the pong frame
fin, opcode, pong_payload := decode_ws_frame(pong_frame) or {
println(' Iteration ${i}: Failed to decode pong frame: ${err}')
return false
}
// Verify it's a pong frame
if opcode != ws_opcode_pong {
println(' Iteration ${i}: Expected pong opcode (0xA), got 0x${opcode:02x}')
return false
}
// Verify FIN bit is set
if !fin {
println(' Iteration ${i}: FIN bit should be set for pong frame')
return false
}
// Verify payload matches ping payload
if pong_payload != ping_payload {
println(' Iteration ${i}: Pong payload should match ping payload')
println(' Ping payload length: ${ping_payload.len}')
println(' Pong payload length: ${pong_payload.len}')
return false
}
}
return true
}
fn test_property_11_ping_sends_frame() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
payload := generate_small_payload()
mut ws := create_mock_ws_context()
// Send ping
ping_frame := ws.send_ping(payload) or {
println(' Iteration ${i}: Failed to send ping: ${err}')
return false
}
// Decode the ping frame
fin, opcode, decoded_payload := decode_ws_frame(ping_frame) or {
println(' Iteration ${i}: Failed to decode ping frame: ${err}')
return false
}
// Verify it's a ping frame
if opcode != ws_opcode_ping {
println(' Iteration ${i}: Expected ping opcode (0x9), got 0x${opcode:02x}')
return false
}
// Verify FIN bit is set
if !fin {
println(' Iteration ${i}: FIN bit should be set for ping frame')
return false
}
// Verify payload matches
if decoded_payload != payload {
println(' Iteration ${i}: Ping payload mismatch')
return false
}
// Verify awaiting_pong is set
if !ws.awaiting_pong {
println(' Iteration ${i}: awaiting_pong should be true after sending ping')
return false
}
}
return true
}
fn test_property_11_pong_clears_awaiting_flag() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
payload := generate_small_payload()
mut ws := create_mock_ws_context()
// Send ping
ws.send_ping(payload) or {
println(' Iteration ${i}: Failed to send ping: ${err}')
return false
}
// Verify awaiting_pong is set
if !ws.awaiting_pong {
println(' Iteration ${i}: awaiting_pong should be true after ping')
return false
}
// Handle pong
ws.handle_pong(payload)
// Verify awaiting_pong is cleared
if ws.awaiting_pong {
println(' Iteration ${i}: awaiting_pong should be false after pong')
return false
}
// Verify last_pong_received is updated
if ws.last_pong_received <= 0 {
println(' Iteration ${i}: last_pong_received should be updated')
return false
}
}
return true
}
fn test_property_11_ping_payload_size_limit() bool {
// Test that ping payload > 125 bytes is rejected
mut ws := create_mock_ws_context()
// Create payload larger than 125 bytes
large_payload := []u8{len: 126, init: u8(0x42)}
// Should fail
ws.send_ping(large_payload) or {
// Expected error
return true
}
println(' Large ping payload should be rejected')
return false
}
fn test_property_11_ping_requires_open_connection() bool {
// Test that ping fails on non-open connections
states := [WSReadyState.connecting, .closing, .closed]
for state in states {
mut ws := create_mock_ws_context()
ws.ready_state = state
ws.send_ping([]u8{}) or {
// Expected error
continue
}
println(' Ping should fail in ${state} state')
return false
}
return true
}
fn test_property_11_should_send_ping_logic() bool {
// Test should_send_ping logic
// Case 1: Ping disabled (interval = 0)
mut ws1 := create_mock_ws_context()
ws1.ping_interval = 0
if ws1.should_send_ping() {
println(' should_send_ping should be false when interval is 0')
return false
}
// Case 2: Connection not open
mut ws2 := create_mock_ws_context()
ws2.ready_state = .closing
if ws2.should_send_ping() {
println(' should_send_ping should be false when not open')
return false
}
// Case 3: Already awaiting pong
mut ws3 := create_mock_ws_context()
ws3.awaiting_pong = true
if ws3.should_send_ping() {
println(' should_send_ping should be false when awaiting pong')
return false
}
// Case 4: Not enough time elapsed
mut ws4 := create_mock_ws_context()
ws4.ping_interval = 30000 // 30 seconds
ws4.last_ping_sent = time.now().unix() // Just sent
if ws4.should_send_ping() {
println(' should_send_ping should be false when interval not elapsed')
return false
}
return true
}
fn test_ping_pong_round_trip() bool {
rand.seed([u32(time.now().unix()), u32(77777)])
for i in 0 .. test_iterations {
payload := generate_random_ping_payload()
// Build ping frame
ping_frame := encode_ws_frame(ws_opcode_ping, payload, false)
// Decode ping frame
_, ping_opcode, ping_payload := decode_ws_frame(ping_frame) or {
println(' Iteration ${i}: Failed to decode ping frame: ${err}')
return false
}
if ping_opcode != ws_opcode_ping {
println(' Iteration ${i}: Expected ping opcode')
return false
}
// Build pong frame with same payload
pong_frame := encode_ws_frame(ws_opcode_pong, ping_payload, false)
// Decode pong frame
_, pong_opcode, pong_payload := decode_ws_frame(pong_frame) or {
println(' Iteration ${i}: Failed to decode pong frame: ${err}')
return false
}
if pong_opcode != ws_opcode_pong {
println(' Iteration ${i}: Expected pong opcode')
return false
}
// Verify payload round-trip
if pong_payload != payload {
println(' Iteration ${i}: Payload mismatch in round-trip')
return false
}
}
return true
}
fn test_empty_ping_payload() bool {
mut ws := create_mock_ws_context()
// Empty payload should work
pong_frame := ws.handle_ping([]u8{}) or {
println(' Empty ping payload should be handled')
return false
}
// Decode and verify
_, opcode, payload := decode_ws_frame(pong_frame) or {
println(' Failed to decode pong frame')
return false
}
if opcode != ws_opcode_pong {
println(' Expected pong opcode')
return false
}
if payload.len != 0 {
println(' Pong payload should be empty')
return false
}
return true
}
fn main() {
println('🚀 Starting WebSocket Ping/Pong Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 11: Ping/Pong Handling
// Validates: Requirements 7.4
stats.run_property_test('Property 11: Pong Echoes Ping Payload', test_property_11_pong_echoes_ping_payload)
stats.run_property_test('Property 11: Ping Sends Frame', test_property_11_ping_sends_frame)
stats.run_property_test('Property 11: Pong Clears Awaiting Flag', test_property_11_pong_clears_awaiting_flag)
stats.run_property_test('Property 11: Ping Payload Size Limit', test_property_11_ping_payload_size_limit)
stats.run_property_test('Property 11: Ping Requires Open Connection', test_property_11_ping_requires_open_connection)
stats.run_property_test('Property 11: Should Send Ping Logic', test_property_11_should_send_ping_logic)
stats.run_property_test('Ping/Pong Round Trip', test_ping_pong_round_trip)
stats.run_property_test('Empty Ping Payload', test_empty_ping_payload)
// Print test summary
stats.print_summary()
}
+475
View File
@@ -0,0 +1,475 @@
// test_websocket_route_patterns.v - Property test for WebSocket route pattern support
// Feature: websocket-helper, Property 3: Route Pattern Support
// Validates: Requirements 1.5
//
// *For any* WebSocket route registered with path parameters (e.g., /ws/:room/:id),
// when a matching request arrives, the handler SHALL receive a WSContext with
// correctly extracted parameter values.
module main
import rand
import time
import regex
const test_iterations = 100
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 Route Pattern 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')
}
}
// ============================================================================
// Route Parameter Extraction (simplified from router.v)
// ============================================================================
// Extract parameter names from a route pattern
fn extract_param_names(pattern string) []string {
mut param_names := []string{}
mut param_reg := regex.regex_opt(r':[a-zA-Z_][a-zA-Z0-9_]*') or { return param_names }
all_params := param_reg.find_all_str(pattern)
for param in all_params {
param_names << param[1..] // Remove the colon
}
return param_names
}
// Convert route pattern to regex and extract params from actual path
fn match_route_pattern(pattern string, actual_path string) ?map[string]string {
if !pattern.contains(':') {
// Static route - no params
if pattern == actual_path {
return map[string]string{}
}
return none
}
// Extract parameter names
param_names := extract_param_names(pattern)
// Build regex pattern
mut regex_pattern := pattern
// Escape special regex characters
special_chars := ['?', '+', '.', '(', ')', '[', ']', '{', '}', '^', '\$', '|']
for ch in special_chars {
regex_pattern = regex_pattern.replace(ch, '\\${ch}')
}
// Replace :param with capture groups
mut param_reg := regex.regex_opt(r':[a-zA-Z_][a-zA-Z0-9_]*') or { return none }
regex_pattern = param_reg.replace_by_fn(regex_pattern, fn (re regex.RE, in_txt string, start int, end int) string {
param_name := in_txt[start + 1..end]
return '(?P<${param_name}>[^/]+)'
})
// Add anchors
regex_pattern = '^${regex_pattern}\$'
// Compile and match
mut compiled := regex.regex_opt(regex_pattern) or { return none }
if !compiled.matches_string(actual_path) {
return none
}
// Extract parameter values
mut params := map[string]string{}
for param_name in param_names {
value := compiled.get_group_by_name(actual_path, param_name)
params[param_name] = value
}
return params
}
// ============================================================================
// Random Data Generators
// ============================================================================
fn generate_random_param_name() string {
chars := 'abcdefghijklmnopqrstuvwxyz'
len := rand.int_in_range(3, 10) or { 5 }
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_param_value() string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'
len := rand.int_in_range(1, 20) or { 5 }
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_static_segment() string {
chars := 'abcdefghijklmnopqrstuvwxyz'
len := rand.int_in_range(2, 10) or { 5 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// Generate a random route pattern with parameters
fn generate_random_route_pattern() (string, []string) {
num_segments := rand.int_in_range(2, 6) or { 3 }
mut segments := []string{}
mut param_names := []string{}
// First segment is always static (like /ws or /api)
segments << generate_random_static_segment()
for _ in 1 .. num_segments {
// Randomly decide if this segment is a parameter or static
is_param := rand.int_in_range(0, 2) or { 0 } == 1
if is_param {
param_name := generate_random_param_name()
// Ensure unique param names
if param_name !in param_names {
param_names << param_name
segments << ':${param_name}'
} else {
segments << generate_random_static_segment()
}
} else {
segments << generate_random_static_segment()
}
}
pattern := '/' + segments.join('/')
return pattern, param_names
}
// Generate an actual path that matches a pattern with specific values
fn generate_matching_path(pattern string, param_values map[string]string) string {
mut path := pattern
for name, value in param_values {
path = path.replace(':${name}', value)
}
return path
}
// ============================================================================
// Property 3: Route Pattern Support
// Feature: websocket-helper, Property 3: Route Pattern Support
// Validates: Requirements 1.5
//
// *For any* WebSocket route registered with path parameters (e.g., /ws/:room/:id),
// when a matching request arrives, the handler SHALL receive a WSContext with
// correctly extracted parameter values.
// ============================================================================
// Test single parameter extraction
fn test_property_3_single_param() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate a pattern with one parameter
param_name := generate_random_param_name()
prefix := generate_random_static_segment()
pattern := '/${prefix}/:${param_name}'
// Generate a random value for the parameter
param_value := generate_random_param_value()
actual_path := '/${prefix}/${param_value}'
// Extract parameters
params := match_route_pattern(pattern, actual_path) or {
println(' Iteration ${i}: Pattern did not match')
println(' Pattern: ${pattern}')
println(' Path: ${actual_path}')
return false
}
// Verify parameter was extracted correctly
if param_name !in params {
println(' Iteration ${i}: Parameter not found: ${param_name}')
return false
}
if params[param_name] != param_value {
println(' Iteration ${i}: Parameter value mismatch')
println(' Expected: ${param_value}')
println(' Got: ${params[param_name]}')
return false
}
}
return true
}
// Test multiple parameter extraction
fn test_property_3_multiple_params() bool {
rand.seed([u32(time.now().unix()), u32(23456)])
for i in 0 .. test_iterations {
// Generate a pattern with multiple parameters
pattern, param_names := generate_random_route_pattern()
// Skip if no parameters were generated
if param_names.len == 0 {
continue
}
// Generate random values for each parameter
mut expected_values := map[string]string{}
for name in param_names {
expected_values[name] = generate_random_param_value()
}
// Generate the actual path
actual_path := generate_matching_path(pattern, expected_values)
// Extract parameters
params := match_route_pattern(pattern, actual_path) or {
println(' Iteration ${i}: Pattern did not match')
println(' Pattern: ${pattern}')
println(' Path: ${actual_path}')
return false
}
// Verify all parameters were extracted correctly
for name, expected_value in expected_values {
if name !in params {
println(' Iteration ${i}: Parameter not found: ${name}')
return false
}
if params[name] != expected_value {
println(' Iteration ${i}: Parameter value mismatch for ${name}')
println(' Expected: ${expected_value}')
println(' Got: ${params[name]}')
return false
}
}
}
return true
}
// Test common WebSocket route patterns
fn test_property_3_common_patterns() bool {
// Test common WebSocket route patterns
patterns := ['/ws/:room', '/ws/:room/:user', '/api/ws/:id', '/chat/:room/user/:userId', '/v1/ws/:tenant/:channel']
paths := ['/ws/general', '/ws/chat/alice', '/api/ws/12345', '/chat/lobby/user/u123', '/v1/ws/acme/notifications']
expected_keys := [['room'], ['room', 'user'], ['id'], ['room', 'userId'], ['tenant', 'channel']]
expected_values := [['general'], ['chat', 'alice'], ['12345'], ['lobby', 'u123'], ['acme', 'notifications']]
for i, pattern in patterns {
path := paths[i]
keys := expected_keys[i]
values := expected_values[i]
params := match_route_pattern(pattern, path) or {
println(' Pattern did not match: ${pattern} vs ${path}')
return false
}
for j, name in keys {
expected_value := values[j]
if name !in params {
println(' Parameter not found: ${name}')
return false
}
if params[name] != expected_value {
println(' Parameter mismatch for ${name}: expected ${expected_value}, got ${params[name]}')
return false
}
}
}
return true
}
// Test that non-matching paths don't extract params
fn test_property_3_non_matching_paths() bool {
rand.seed([u32(time.now().unix()), u32(34567)])
for i in 0 .. test_iterations {
// Generate a pattern
pattern, _ := generate_random_route_pattern()
// Generate a completely different path
different_pattern, _ := generate_random_route_pattern()
mut different_values := map[string]string{}
different_param_names := extract_param_names(different_pattern)
for name in different_param_names {
different_values[name] = generate_random_param_value()
}
different_path := generate_matching_path(different_pattern, different_values)
// Try to match - should fail if patterns are different
if pattern != different_pattern {
result := match_route_pattern(pattern, different_path)
// It's okay if it matches by coincidence, but if it does,
// the params should still be valid extractions
if result != none {
// Verify the extracted params are valid (non-empty)
for _, value in result {
if value.len == 0 {
println(' Iteration ${i}: Empty parameter value extracted')
return false
}
}
}
}
}
return true
}
// Test parameter extraction preserves special characters in values
fn test_property_3_special_values() bool {
// Test that parameter values with allowed special characters are preserved
patterns := ['/ws/:room', '/ws/:room', '/ws/:id', '/chat/:room/:user']
paths := ['/ws/room-123', '/ws/room_456', '/ws/ABC123xyz', '/chat/my-room/user_1']
expected_keys := [['room'], ['room'], ['id'], ['room', 'user']]
expected_values := [['room-123'], ['room_456'], ['ABC123xyz'], ['my-room', 'user_1']]
for i, pattern in patterns {
path := paths[i]
keys := expected_keys[i]
values := expected_values[i]
params := match_route_pattern(pattern, path) or {
println(' Pattern did not match: ${pattern} vs ${path}')
return false
}
for j, name in keys {
expected_value := values[j]
if params[name] != expected_value {
println(' Parameter mismatch for ${name}: expected ${expected_value}, got ${params[name]}')
return false
}
}
}
return true
}
// Test static routes (no parameters)
fn test_property_3_static_routes() bool {
rand.seed([u32(time.now().unix()), u32(45678)])
for i in 0 .. test_iterations {
// Generate a static route (no parameters)
num_segments := rand.int_in_range(1, 5) or { 2 }
mut segments := []string{}
for _ in 0 .. num_segments {
segments << generate_random_static_segment()
}
pattern := '/' + segments.join('/')
// Match against itself
params := match_route_pattern(pattern, pattern) or {
println(' Iteration ${i}: Static pattern did not match itself')
println(' Pattern: ${pattern}')
return false
}
// Static routes should have no parameters
if params.len != 0 {
println(' Iteration ${i}: Static route should have no params')
println(' Got: ${params}')
return false
}
}
return true
}
// Test parameter count matches
fn test_property_3_param_count() bool {
rand.seed([u32(time.now().unix()), u32(56789)])
for i in 0 .. test_iterations {
// Generate a pattern with known number of parameters
pattern, param_names := generate_random_route_pattern()
// Generate values
mut values := map[string]string{}
for name in param_names {
values[name] = generate_random_param_value()
}
actual_path := generate_matching_path(pattern, values)
params := match_route_pattern(pattern, actual_path) or {
// If no params in pattern, this is expected for static routes
if param_names.len == 0 {
continue
}
println(' Iteration ${i}: Pattern did not match')
return false
}
// Verify param count matches
if params.len != param_names.len {
println(' Iteration ${i}: Param count mismatch')
println(' Expected: ${param_names.len}')
println(' Got: ${params.len}')
return false
}
}
return true
}
fn main() {
println('🚀 Starting WebSocket Route Pattern Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 3: Route Pattern Support
// Validates: Requirements 1.5
stats.run_property_test('Property 3.1: Single Parameter Extraction', test_property_3_single_param)
stats.run_property_test('Property 3.2: Multiple Parameters Extraction', test_property_3_multiple_params)
stats.run_property_test('Property 3.3: Common WebSocket Patterns', test_property_3_common_patterns)
stats.run_property_test('Property 3.4: Non-Matching Paths', test_property_3_non_matching_paths)
stats.run_property_test('Property 3.5: Special Characters in Values', test_property_3_special_values)
stats.run_property_test('Property 3.6: Static Routes', test_property_3_static_routes)
stats.run_property_test('Property 3.7: Parameter Count Matches', test_property_3_param_count)
// Print test summary
stats.print_summary()
}
+392
View File
@@ -0,0 +1,392 @@
// test_websocket_subprotocol.v - Property test for WebSocket subprotocol negotiation
// Feature: websocket-helper, Property 9: Subprotocol Negotiation
// Validates: Requirements 4.4
//
// *For any* WebSocket handshake where the client requests subprotocols via
// Sec-WebSocket-Protocol header and the server has matching protocols configured,
// the response SHALL include exactly one matching protocol in the
// Sec-WebSocket-Protocol response header.
module main
import rand
import time
import net.http
const test_iterations = 100
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 Subprotocol Negotiation 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')
}
}
// ============================================================================
// Subprotocol Negotiation Functions (copied from websocket.v for testing)
// ============================================================================
// negotiate_subprotocol_from_header - Negotiate subprotocol from client header value
// This is a simplified version that takes the header value directly
fn negotiate_subprotocol_from_header(client_protocols_header string, supported_protocols []string) string {
if supported_protocols.len == 0 {
return ''
}
if client_protocols_header.len == 0 {
return ''
}
// Parse comma-separated list
requested := client_protocols_header.split(',').map(it.trim_space())
// Find first matching protocol
for proto in requested {
if proto in supported_protocols {
return proto
}
}
return ''
}
// create_handshake_response - Create WebSocket handshake response
fn create_handshake_response(key string, protocol string) http.Response {
mut headers := http.new_header()
headers.add_custom('Upgrade', 'websocket') or {}
headers.add_custom('Connection', 'Upgrade') or {}
headers.add_custom('Sec-WebSocket-Accept', key) or {}
if protocol.len > 0 {
headers.add_custom('Sec-WebSocket-Protocol', protocol) or {}
}
return http.Response{
status_code: 101
header: headers
body: ''
}
}
// ============================================================================
// Random Data Generators
// ============================================================================
fn generate_random_protocol_name() string {
// Generate protocol names like: chat, graphql-ws, mqtt, soap, wamp, etc.
prefixes := ['chat', 'graphql', 'mqtt', 'soap', 'wamp', 'stomp', 'xmpp', 'json', 'binary', 'proto']
suffixes := ['', '-ws', '-v1', '-v2', '.subprotocol', '-rpc', '-stream']
prefix := prefixes[rand.int_in_range(0, prefixes.len) or { 0 }]
suffix := suffixes[rand.int_in_range(0, suffixes.len) or { 0 }]
return prefix + suffix
}
fn generate_random_protocol_list(count int) []string {
mut protocols := []string{cap: count}
for _ in 0 .. count {
proto := generate_random_protocol_name()
if proto !in protocols {
protocols << proto
}
}
return protocols
}
fn protocols_to_header(protocols []string) string {
return protocols.join(', ')
}
// ============================================================================
// Property 9: Subprotocol Negotiation
// Feature: websocket-helper, Property 9: Subprotocol Negotiation
// Validates: Requirements 4.4
//
// *For any* WebSocket handshake where the client requests subprotocols via
// Sec-WebSocket-Protocol header and the server has matching protocols configured,
// the response SHALL include exactly one matching protocol in the
// Sec-WebSocket-Protocol response header.
// ============================================================================
fn test_property_9_matching_protocol_selected() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate server's supported protocols (1-5 protocols)
server_count := rand.int_in_range(1, 6) or { 2 }
server_protocols := generate_random_protocol_list(server_count)
// Generate client's requested protocols (1-5 protocols)
client_count := rand.int_in_range(1, 6) or { 2 }
mut client_protocols := generate_random_protocol_list(client_count)
// Ensure at least one matching protocol by adding one from server list
matching_idx := rand.int_in_range(0, server_protocols.len) or { 0 }
matching_protocol := server_protocols[matching_idx]
// Insert matching protocol at random position in client list
insert_pos := rand.int_in_range(0, client_protocols.len + 1) or { 0 }
client_protocols.insert(insert_pos, matching_protocol)
// Create header string
client_header := protocols_to_header(client_protocols)
// Negotiate
result := negotiate_subprotocol_from_header(client_header, server_protocols)
// Verify exactly one protocol is returned
if result.len == 0 {
println(' Iteration ${i}: Expected a matching protocol to be selected')
println(' Server protocols: ${server_protocols}')
println(' Client protocols: ${client_protocols}')
return false
}
// Verify the selected protocol is in both lists
if result !in server_protocols {
println(' Iteration ${i}: Selected protocol not in server list')
println(' Selected: ${result}')
println(' Server protocols: ${server_protocols}')
return false
}
if result !in client_protocols {
println(' Iteration ${i}: Selected protocol not in client list')
println(' Selected: ${result}')
println(' Client protocols: ${client_protocols}')
return false
}
}
return true
}
fn test_property_9_no_match_returns_empty() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate completely different protocol lists
server_protocols := ['server-proto-a', 'server-proto-b', 'server-proto-c']
client_protocols := ['client-proto-x', 'client-proto-y', 'client-proto-z']
client_header := protocols_to_header(client_protocols)
// Negotiate
result := negotiate_subprotocol_from_header(client_header, server_protocols)
// Verify no protocol is selected when there's no match
if result.len > 0 {
println(' Iteration ${i}: Expected empty result when no match')
println(' Server protocols: ${server_protocols}')
println(' Client protocols: ${client_protocols}')
println(' Got: ${result}')
return false
}
}
return true
}
fn test_property_9_first_match_selected() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
// Server supports multiple protocols
server_protocols := ['proto-a', 'proto-b', 'proto-c', 'proto-d']
// Client requests protocols in specific order with multiple matches
// The first matching protocol in client's list should be selected
client_protocols := ['proto-c', 'proto-a', 'proto-d']
client_header := protocols_to_header(client_protocols)
// Negotiate
result := negotiate_subprotocol_from_header(client_header, server_protocols)
// Verify the first matching protocol (proto-c) is selected
if result != 'proto-c' {
println(' Iteration ${i}: Expected first matching protocol to be selected')
println(' Expected: proto-c')
println(' Got: ${result}')
return false
}
}
return true
}
fn test_property_9_response_includes_protocol() bool {
rand.seed([u32(time.now().unix()), u32(77777)])
for i in 0 .. test_iterations {
// Generate a random protocol
protocol := generate_random_protocol_name()
// Create handshake response with protocol
response := create_handshake_response('dummy-accept-key', protocol)
// Verify response includes the protocol header
proto_header := response.header.get_custom('Sec-WebSocket-Protocol') or { '' }
if proto_header != protocol {
println(' Iteration ${i}: Response should include Sec-WebSocket-Protocol header')
println(' Expected: ${protocol}')
println(' Got: ${proto_header}')
return false
}
// Verify status code is 101
if response.status_code != 101 {
println(' Iteration ${i}: Response status should be 101')
println(' Got: ${response.status_code}')
return false
}
}
return true
}
fn test_property_9_empty_protocol_not_in_response() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
for _ in 0 .. test_iterations {
// Create handshake response without protocol
response := create_handshake_response('dummy-accept-key', '')
// Verify response does NOT include the protocol header
proto_header := response.header.get_custom('Sec-WebSocket-Protocol') or { '' }
if proto_header.len > 0 {
println(' Response should not include Sec-WebSocket-Protocol when empty')
println(' Got: ${proto_header}')
return false
}
}
return true
}
fn test_property_9_empty_server_protocols() bool {
rand.seed([u32(time.now().unix()), u32(22222)])
for i in 0 .. test_iterations {
// Client requests protocols but server has none configured
client_protocols := generate_random_protocol_list(3)
client_header := protocols_to_header(client_protocols)
// Empty server protocols
server_protocols := []string{}
// Negotiate
result := negotiate_subprotocol_from_header(client_header, server_protocols)
// Should return empty when server has no protocols
if result.len > 0 {
println(' Iteration ${i}: Expected empty when server has no protocols')
println(' Got: ${result}')
return false
}
}
return true
}
fn test_property_9_empty_client_header() bool {
rand.seed([u32(time.now().unix()), u32(33333)])
for i in 0 .. test_iterations {
// Server has protocols but client sends empty header
server_protocols := generate_random_protocol_list(3)
// Negotiate with empty client header
result := negotiate_subprotocol_from_header('', server_protocols)
// Should return empty when client sends no protocols
if result.len > 0 {
println(' Iteration ${i}: Expected empty when client sends no protocols')
println(' Got: ${result}')
return false
}
}
return true
}
fn test_property_9_whitespace_handling() bool {
// Test that whitespace around protocol names is handled correctly
server_protocols := ['chat', 'graphql-ws', 'mqtt']
// Various whitespace formats
test_cases := [
'chat, graphql-ws, mqtt', // Standard spacing
'chat,graphql-ws,mqtt', // No spaces
' chat , graphql-ws , mqtt ', // Extra spaces
'chat ,graphql-ws, mqtt', // Mixed spacing
]
for i, client_header in test_cases {
result := negotiate_subprotocol_from_header(client_header, server_protocols)
// Should find 'chat' as first match regardless of whitespace
if result != 'chat' {
println(' Test case ${i}: Whitespace handling failed')
println(' Header: "${client_header}"')
println(' Expected: chat')
println(' Got: ${result}')
return false
}
}
return true
}
fn main() {
println('🚀 Starting WebSocket Subprotocol Negotiation Property Tests...')
println('Each property test runs ${test_iterations} iterations\n')
mut stats := PropertyTestStats{}
// Run property tests
// Feature: websocket-helper, Property 9: Subprotocol Negotiation
// Validates: Requirements 4.4
stats.run_property_test('Property 9: Matching Protocol Selected', test_property_9_matching_protocol_selected)
stats.run_property_test('Property 9: No Match Returns Empty', test_property_9_no_match_returns_empty)
stats.run_property_test('Property 9: First Match Selected', test_property_9_first_match_selected)
stats.run_property_test('Property 9: Response Includes Protocol', test_property_9_response_includes_protocol)
stats.run_property_test('Property 9: Empty Protocol Not In Response', test_property_9_empty_protocol_not_in_response)
stats.run_property_test('Property 9: Empty Server Protocols', test_property_9_empty_server_protocols)
stats.run_property_test('Property 9: Empty Client Header', test_property_9_empty_client_header)
stats.run_property_test('Property 9: Whitespace Handling', test_property_9_whitespace_handling)
// Print test summary
stats.print_summary()
}
+94 -27
View File
@@ -1,13 +1,36 @@
#!/bin/bash
# uSockets 编译脚本
# 用于重新编译 libusockets_full.a
# 用于重新编译 libusockets_full.a (包含 uSockets + libuv)
# 支持: Windows (Git Bash/MSYS2), macOS (Intel/Apple Silicon), Linux
# 输出到: usockets/lib/{platform}/libusockets_full.a
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
USOCKETS_DIR="/tmp/uSockets"
LIBUV_DIR="/tmp/libuv"
MERGE_DIR="/tmp/merge_libs"
echo "=== uSockets 编译脚本 ==="
echo "检测到系统: $OSTYPE"
# 确定输出目录
if [[ "$OSTYPE" == "darwin"* ]]; then
if [[ $(uname -m) == "arm64" ]]; then
OUTPUT_DIR="$SCRIPT_DIR/lib/macos-arm64"
else
OUTPUT_DIR="$SCRIPT_DIR/lib/macos-x64"
fi
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
OUTPUT_DIR="$SCRIPT_DIR/lib/windows"
elif [[ "$OSTYPE" == "linux"* ]]; then
OUTPUT_DIR="$SCRIPT_DIR/lib/linux"
else
OUTPUT_DIR="$SCRIPT_DIR/lib/linux"
fi
echo "输出目录: $OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
# 检查是否有 uSockets 源码
if [ ! -d "$USOCKETS_DIR" ]; then
@@ -15,41 +38,85 @@ if [ ! -d "$USOCKETS_DIR" ]; then
git clone --depth 1 https://github.com/uNetworking/uSockets.git "$USOCKETS_DIR"
fi
# 检查是否有 libuv 源码
if [ ! -d "$LIBUV_DIR" ]; then
echo "克隆 libuv..."
git clone --depth 1 --branch v1.48.0 https://github.com/libuv/libuv.git "$LIBUV_DIR"
fi
# 应用 backlog 修改
echo "应用 backlog=16384 修改..."
if [ -f "$SCRIPT_DIR/src/bsd.c" ]; then
cp "$SCRIPT_DIR/src/bsd.c" "$USOCKETS_DIR/src/bsd.c"
else
# 如果没有本地修改的源码,直接修改
sed -i.bak 's/listen(listenFd, 512)/listen(listenFd, 16384)/g' "$USOCKETS_DIR/src/bsd.c"
fi
# 编译
echo "编译 uSockets..."
cd "$USOCKETS_DIR"
make clean
# 根据系统选择编译参数
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
if [[ $(uname -m) == "arm64" ]]; then
# Apple Silicon
WITH_LIBUV=1 CFLAGS="-I/opt/homebrew/include" make
else
# Intel Mac
WITH_LIBUV=1 CFLAGS="-I/usr/local/include" make
# 编译 libuv
compile_libuv() {
echo "编译 libuv..."
cd "$LIBUV_DIR"
rm -rf build 2>/dev/null || true
mkdir -p build
cd build
if ! command -v cmake &> /dev/null; then
echo "错误: 需要安装 cmake"
echo " macOS: brew install cmake"
echo " Linux: sudo apt install cmake 或 sudo yum install cmake"
echo " Windows: scoop install cmake 或 choco install cmake"
exit 1
fi
else
# Linux
WITH_LIBUV=1 make
fi
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
cmake .. -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DLIBUV_BUILD_SHARED=OFF
else
cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DLIBUV_BUILD_SHARED=OFF
fi
cmake --build . --config Release
}
# 复制到 v-hono
echo "复制库文件..."
cp "$USOCKETS_DIR/uSockets.a" "$SCRIPT_DIR/../lib/libusockets_full.a"
# 编译 uSockets
compile_usockets() {
echo "编译 uSockets..."
cd "$USOCKETS_DIR"
rm -f *.o *.a 2>/dev/null || true
gcc -DLIBUS_NO_SSL -DLIBUS_USE_LIBUV -std=c11 -Isrc -I"$LIBUV_DIR/include" -O3 -c src/*.c src/eventing/*.c src/crypto/*.c
ar rcs uSockets.a *.o
rm -f *.o
}
echo "=== 编译完成 ==="
echo "库文件: $SCRIPT_DIR/../lib/libusockets_full.a"
# 合并库文件
merge_libraries() {
echo "合并库文件..."
rm -rf "$MERGE_DIR"
mkdir -p "$MERGE_DIR"
cd "$MERGE_DIR"
ar x "$USOCKETS_DIR/uSockets.a"
ar x "$LIBUV_DIR/build/libuv.a"
ar rcs "$OUTPUT_DIR/libusockets_full.a" *.o
rm -rf "$MERGE_DIR"
}
# 执行编译
compile_libuv
compile_usockets
merge_libraries
# 显示结果
LIB_SIZE=$(ls -lh "$OUTPUT_DIR/libusockets_full.a" | awk '{print $5}')
echo ""
echo "重新编译 v-hono 应用:"
echo " v -enable-globals -prod -o your_app your_app.v"
echo "=== 编译完成 ==="
echo "库文件: $OUTPUT_DIR/libusockets_full.a ($LIB_SIZE)"
echo ""
echo "编译 v-hono 应用:"
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
echo " v -enable-globals -cc gcc -ldflags \"-ldbghelp\" -o app.exe app.v"
else
echo " v -enable-globals -prod -o app app.v"
fi
Binary file not shown.
Binary file not shown.
+16 -23
View File
@@ -2,41 +2,36 @@ module usockets
// 条件编译:根据操作系统选择正确的路径和库
// @VMODROOT 指向包含 v.mod 的模块根目录 (v-hono/)
// 预编译库按平台存放在 usockets/lib/{platform}/ 目录
$if windows {
#flag -DLIBUS_USE_LIBUV
#flag -DLIBUS_NO_SSL
#flag -I@VMODROOT/usockets/include
#flag -L@VMODROOT/lib
#flag @VMODROOT/lib/libusockets_full.a
#flag -lws2_32 -liphlpapi -lpsapi -luserenv -lole32
#flag -L@VMODROOT/usockets/lib/windows
#flag @VMODROOT/usockets/lib/windows/libusockets_full.a
#flag -lws2_32 -liphlpapi -lpsapi -luserenv -lole32 -ldbghelp
} $else $if macos {
// macOS: 支持 Apple Silicon (M1/M2/M3) 和 Intel Mac
#flag -DLIBUS_USE_LIBUV
#flag -DLIBUS_NO_SSL
// libuv 路径 - 根据架构选择 Homebrew 路径
$if arm64 {
#flag -I/opt/homebrew/include
#flag -L/opt/homebrew/lib
} $else {
#flag -I/usr/local/include
#flag -L/usr/local/lib
}
// uSockets 库路径
#flag -I@VMODROOT/usockets/include
#flag -L@VMODROOT/lib
#flag @VMODROOT/lib/libusockets_full.a
#flag -luv
$if arm64 {
// Apple Silicon (M1/M2/M3)
#flag -L@VMODROOT/usockets/lib/macos-arm64
#flag @VMODROOT/usockets/lib/macos-arm64/libusockets_full.a
} $else {
// Intel Mac
#flag -L@VMODROOT/usockets/lib/macos-x64
#flag @VMODROOT/usockets/lib/macos-x64/libusockets_full.a
}
} $else {
// Linux 和其他平台
#flag -DLIBUS_USE_LIBUV
#flag -DLIBUS_NO_SSL
#flag -I@VMODROOT/usockets/include
#flag -L@VMODROOT/lib
#flag @VMODROOT/lib/libusockets_full.a
#flag -luv
#flag -L@VMODROOT/usockets/lib/linux
#flag @VMODROOT/usockets/lib/linux/libusockets_full.a
}
#include "libusockets.h"
@@ -105,8 +100,6 @@ pub fn (ctx SocketContext) on_writable(h voidptr) { C.us_socket_context_on_writa
pub fn (ctx SocketContext) on_timeout(h voidptr) { C.us_socket_context_on_timeout(0, ctx, h) }
pub fn (ctx SocketContext) on_end(h voidptr) { C.us_socket_context_on_end(0, ctx, h) }
pub fn (ctx SocketContext) listen(port int) ListenSocket {
// 参数: ssl=0, context, host=nil, port, options=LIBUS_LISTEN_DEFAULT(0), socket_ext_size=0
// 注意: backlog 是在 uSockets 库内部硬编码的,不通过此函数传递
return C.us_socket_context_listen(0, ctx, unsafe { nil }, port, 0, 0)
}
+314
View File
@@ -84,6 +84,141 @@ fn usockets_on_open(s usockets.Socket, is_client int, ip &char, ip_length int) u
return s
}
// ============================================================================
// WebSocket Upgrade Detection and Handling for uSockets
// ============================================================================
// Check if a raw HTTP request is a WebSocket upgrade request
fn is_usockets_ws_upgrade(raw_data string) bool {
// Check for Upgrade: websocket header (case-insensitive)
mut has_upgrade := false
mut has_connection := false
mut has_ws_key := false
lines := raw_data.split('\r\n')
for line in lines {
lower_line := line.to_lower()
if lower_line.starts_with('upgrade:') {
if lower_line.contains('websocket') {
has_upgrade = true
}
} else if lower_line.starts_with('connection:') {
if lower_line.contains('upgrade') {
has_connection = true
}
} else if lower_line.starts_with('sec-websocket-key:') {
has_ws_key = true
}
}
return has_upgrade && has_connection && has_ws_key
}
// Get WebSocket key from raw HTTP request headers
fn get_usockets_ws_key(raw_data string) string {
lines := raw_data.split('\r\n')
for line in lines {
if line.to_lower().starts_with('sec-websocket-key:') {
parts := line.split(':')
if parts.len >= 2 {
return parts[1..].join(':').trim_space()
}
}
}
return ''
}
// Get WebSocket protocol from raw HTTP request headers
fn get_usockets_ws_protocol(raw_data string) string {
lines := raw_data.split('\r\n')
for line in lines {
if line.to_lower().starts_with('sec-websocket-protocol:') {
parts := line.split(':')
if parts.len >= 2 {
return parts[1..].join(':').trim_space()
}
}
}
return ''
}
// Get WebSocket version from raw HTTP request headers
fn get_usockets_ws_version(raw_data string) string {
lines := raw_data.split('\r\n')
for line in lines {
if line.to_lower().starts_with('sec-websocket-version:') {
parts := line.split(':')
if parts.len >= 2 {
return parts[1..].join(':').trim_space()
}
}
}
return '13'
}
// Handle WebSocket upgrade for uSockets
// Returns true if upgrade was successful, false otherwise
fn handle_usockets_ws_upgrade(s usockets.Socket, raw_data string, route_match ContextRouteMatch, hono_ctx Context) bool {
// Validate WebSocket version
ws_version := get_usockets_ws_version(raw_data)
if ws_version != '13' {
s.write_bytes('HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\nContent-Type: text/plain\r\nContent-Length: 28\r\n\r\nUnsupported WebSocket version')
return false
}
// Get WebSocket key
ws_key := get_usockets_ws_key(raw_data)
if ws_key.len == 0 {
s.write_bytes('HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: 30\r\n\r\nMissing Sec-WebSocket-Key header')
return false
}
// Compute accept key
accept_key := compute_accept_key(ws_key)
// Execute the route handler to get WSEvents
mut mutable_ctx := hono_ctx
response := route_match.handler.handle(mut mutable_ctx)
// Check if this is a WebSocket upgrade response
if response.status_code != 101 {
// Not a WebSocket upgrade, send the response as-is
send_usockets_response(s, mutable_ctx, response)
return false
}
// Negotiate subprotocol
mut selected_protocol := ''
if '_ws_protocol' in mutable_ctx.store {
selected_protocol = mutable_ctx.store['_ws_protocol']
}
// Build and send WebSocket handshake response
mut resp := strings.new_builder(256)
resp.write_string('HTTP/1.1 101 Switching Protocols\r\n')
resp.write_string('Upgrade: websocket\r\n')
resp.write_string('Connection: Upgrade\r\n')
resp.write_string('Sec-WebSocket-Accept: ')
resp.write_string(accept_key)
resp.write_string('\r\n')
if selected_protocol.len > 0 {
resp.write_string('Sec-WebSocket-Protocol: ')
resp.write_string(selected_protocol)
resp.write_string('\r\n')
}
resp.write_string('\r\n')
s.write_bytes(resp.str())
// Note: After the handshake, the connection is now in WebSocket mode.
// The uSockets backend will continue to receive data on this socket,
// but it will be WebSocket frames instead of HTTP requests.
// For full WebSocket support, additional frame handling would be needed
// in the usockets_on_data callback.
return true
}
fn usockets_on_data(s usockets.Socket, data &char, length int) usockets.Socket {
if g_usockets_app == unsafe { nil } {
s.write_bytes('HTTP/1.1 500 Internal Server Error\r\nContent-Length: 21\r\n\r\nInternal Server Error')
@@ -99,6 +234,9 @@ fn usockets_on_data(s usockets.Socket, data &char, length int) usockets.Socket {
return s
}
// Check if this is a WebSocket upgrade request
is_ws_upgrade := is_usockets_ws_upgrade(raw_data)
// 路由匹配 - 优先使用快速路由器
mut response_sent := false
@@ -106,6 +244,18 @@ fn usockets_on_data(s usockets.Socket, data &char, length int) usockets.Socket {
if route_match := g_usockets_app.fast_router.match_route(method, path) {
mut hono_ctx := create_usockets_context(method, path, route_match.params, query_map, body)
// Handle WebSocket upgrade if detected
if is_ws_upgrade {
// Parse headers for WebSocket context
hono_ctx = create_usockets_context_with_headers(raw_data, method, path, route_match.params, query_map, body)
if handle_usockets_ws_upgrade(s, raw_data, route_match, hono_ctx) {
// WebSocket upgrade successful
return s
}
// If upgrade failed, response was already sent
return s
}
// 优化1:零中间件快速路径
if !g_usockets_app.has_middlewares {
response := route_match.handler.handle(mut hono_ctx)
@@ -124,6 +274,18 @@ fn usockets_on_data(s usockets.Socket, data &char, length int) usockets.Socket {
if route_match := g_usockets_app.context_hybrid_router.match_route(method, path) {
mut hono_ctx := create_usockets_context(method, path, route_match.params, query_map, body)
// Handle WebSocket upgrade if detected
if is_ws_upgrade {
// Parse headers for WebSocket context
hono_ctx = create_usockets_context_with_headers(raw_data, method, path, route_match.params, query_map, body)
if handle_usockets_ws_upgrade(s, raw_data, route_match, hono_ctx) {
// WebSocket upgrade successful
return s
}
// If upgrade failed, response was already sent
return s
}
// 优化1:零中间件快速路径
if !g_usockets_app.has_middlewares {
response := route_match.handler.handle(mut hono_ctx)
@@ -169,6 +331,7 @@ fn usockets_on_end(s usockets.Socket) usockets.Socket {
return s.close()
}
// 解析 HTTP 请求 - 零分配优化版
// 使用指针遍历避免创建临时数组
fn parse_http_request_usockets(raw string) (string, string, map[string]string, string) {
@@ -295,6 +458,54 @@ fn create_usockets_context(method string, path string, params map[string]string,
}
}
// 创建 uSockets 上下文 - 带 HTTP 头部解析(用于 WebSocket 升级)
fn create_usockets_context_with_headers(raw_data string, method string, path string, params map[string]string, query map[string]string, body string) Context {
// Parse headers from raw HTTP request
mut headers := http.new_header()
lines := raw_data.split('\r\n')
mut in_headers := false
for line in lines {
if !in_headers {
// Skip the request line
if line.contains(' HTTP/') {
in_headers = true
}
continue
}
// Empty line marks end of headers
if line.len == 0 {
break
}
// Parse header
colon_idx := line.index(':') or { continue }
if colon_idx > 0 {
name := line[..colon_idx].trim_space()
value := line[colon_idx + 1..].trim_space()
headers.add_custom(name, value) or { continue }
}
}
return Context{
req: http.Request{
method: parse_http_method_usockets(method)
url: path
data: body
header: headers
}
params: params
query: query
body: body
url: path
path: path
status_code: 200
headers: map[string]string{}
}
}
// 快速 HTTP 方法解析(基于首字符和长度)
@[inline]
fn parse_http_method_usockets(method string) http.Method {
@@ -366,6 +577,13 @@ fn exec_middlewares_usockets(idx int, middlewares []ContextMiddleware, mut ctx C
// 发送 uSockets 响应 - 优化版
fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response) {
// Check if this is a streaming response
if is_streaming_response(ctx) {
// Handle streaming response
handle_usockets_streaming_response(s, ctx)
return
}
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
mut resp := strings.new_builder(512)
@@ -422,6 +640,100 @@ fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response
s.write_bytes(resp.str())
}
// ============================================================================
// Streaming Response Handling for uSockets
// ============================================================================
// handle_usockets_streaming_response - Handle a streaming response using UsocketsStreamWriter
// This function:
// 1. Sends the HTTP headers for streaming
// 2. Creates a UsocketsStreamWriter
// 3. Executes the streaming callback
// 4. Handles errors and cleanup
//
// Requirements: 7.1, 7.2, 7.3, 7.4
fn handle_usockets_streaming_response(s usockets.Socket, ctx Context) {
// Get stream configuration
stream_config := get_stream_config(ctx) or {
// Fallback to error response if stream config not found
s.write_bytes('HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: 32\r\n\r\nStream configuration not found')
return
}
// Determine status code
status_code := if ctx.status_code != 0 { ctx.status_code } else { 200 }
// Build HTTP response headers
mut resp := strings.new_builder(512)
// Status line
resp.write_string('HTTP/1.1 ')
resp.write_string(status_code.str())
resp.write_string(' ')
resp.write_string(get_status_text_usockets(status_code))
resp.write_string('\r\n')
// Send headers from context
mut has_transfer_encoding := false
mut has_connection := false
for key, value in ctx.headers {
key_len := key.len
// Skip Content-Length for streaming (we use chunked encoding)
if key_len == 14 && eq_ignore_case_usockets(key, 'content-length') {
continue
}
resp.write_string(key)
resp.write_string(': ')
resp.write_string(value)
resp.write_string('\r\n')
if key_len == 17 && eq_ignore_case_usockets(key, 'transfer-encoding') {
has_transfer_encoding = true
} else if key_len == 10 && eq_ignore_case_usockets(key, 'connection') {
has_connection = true
}
}
// Ensure Transfer-Encoding: chunked is set (Requirement 7.2)
if !has_transfer_encoding {
resp.write_string('Transfer-Encoding: chunked\r\n')
}
// Ensure Connection header is set for streaming
if !has_connection {
resp.write_string('Connection: keep-alive\r\n')
}
// End headers section
resp.write_string('\r\n')
// Send headers to client
s.write_bytes(resp.str())
// Create UsocketsStreamWriter for streaming (Requirement 7.1)
mut writer := UsocketsStreamWriter.new(s)
// Create StreamContext with the writer
mut stream_ctx := StreamContext.new(writer)
// Execute the streaming callback (Requirement 7.4 - compatibility with callback architecture)
stream_config.callback(mut stream_ctx) or {
// Handle error (Requirement 7.3 - handle connection close events)
if error_handler := stream_config.error_handler {
error_handler(err, mut stream_ctx)
} else {
// Default: log error to console
eprintln('[uSockets Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
stream_ctx.close()
// Cleanup the stored configuration
cleanup_stream_config(ctx)
}
// 大小写不敏感字符串比较(避免分配)
@[inline]
fn eq_ignore_case_usockets(a string, b string) bool {
@@ -444,6 +756,7 @@ fn eq_ignore_case_usockets(a string, b string) bool {
// 获取状态码文本
fn get_status_text_usockets(code int) string {
return match code {
101 { 'Switching Protocols' }
200 { 'OK' }
201 { 'Created' }
204 { 'No Content' }
@@ -455,6 +768,7 @@ fn get_status_text_usockets(code int) string {
403 { 'Forbidden' }
404 { 'Not Found' }
405 { 'Method Not Allowed' }
426 { 'Upgrade Required' }
500 { 'Internal Server Error' }
502 { 'Bad Gateway' }
503 { 'Service Unavailable' }
+1 -1
View File
@@ -1,7 +1,7 @@
Module {
name: 'hono'
description: 'A high-performance V language web framework inspired by Hono.js, featuring hybrid routing, LRU cache, middleware support, and more.'
version: '0.5.0'
version: '0.7.3'
license: 'MIT'
dependencies: []
}
+1061
View File
File diff suppressed because it is too large Load Diff