Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a57ce2e77a | ||
|
|
7a08c3142a | ||
|
|
362015c1cf | ||
|
|
0969901481 | ||
|
|
6bddfc3731 | ||
|
|
26ac804b1d | ||
|
|
56299b5485 | ||
|
|
d31492fd59 | ||
|
|
e19f799e46 | ||
|
|
f7e753969d | ||
|
|
e9d14a35f6 | ||
|
|
9ae9170bcb | ||
|
|
6ab6d3b99e |
+2
-2
@@ -2,7 +2,6 @@
|
||||
*.exe
|
||||
*.out
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
@@ -46,4 +45,5 @@ temp/
|
||||
# Chunk upload temp files
|
||||
chunks/
|
||||
|
||||
.kiro
|
||||
.kiro
|
||||
*.def
|
||||
@@ -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
|
||||
|
||||
@@ -48,8 +50,13 @@ cd v-hono
|
||||
### Standard Build (picoev backend)
|
||||
|
||||
```bash
|
||||
v your_app.v -o app.exe
|
||||
./app.exe
|
||||
# macOS / Linux
|
||||
v -prod -o app your_app.v
|
||||
./app
|
||||
|
||||
# Windows
|
||||
v -o app.exe your_app.v
|
||||
.\app.exe
|
||||
```
|
||||
|
||||
### High-Performance Build (uSockets backend)
|
||||
@@ -57,25 +64,33 @@ v your_app.v -o app.exe
|
||||
For maximum performance with uSockets backend:
|
||||
|
||||
```bash
|
||||
v -enable-globals -cc gcc -ldflags "-ldbghelp" your_app.v -o app.exe
|
||||
./app.exe
|
||||
# macOS / Linux
|
||||
v -enable-globals -prod -o app your_app.v
|
||||
./app
|
||||
|
||||
# Windows (must use gcc, tcc does not support .a static library format)
|
||||
v -enable-globals -cc gcc -ldflags "-ldbghelp" -o app.exe your_app.v
|
||||
.\app.exe
|
||||
```
|
||||
|
||||
**Note**: The `-enable-globals` flag is required for uSockets backend. The `-ldflags "-ldbghelp"` fixes Windows linking issues.
|
||||
**Important Notes:**
|
||||
- The `-enable-globals` flag is required for uSockets backend
|
||||
- On Windows, you **must** use `-cc gcc` because V's default compiler (tcc) does not support MinGW `.a` static library format
|
||||
- The `-ldflags "-ldbghelp"` is required on Windows for libuv linking
|
||||
|
||||
#### Windows Prerequisites
|
||||
|
||||
To use uSockets backend on Windows, you need:
|
||||
|
||||
1. **GCC (MinGW-w64)** - C compiler
|
||||
1. **GCC (MinGW-w64)** - C compiler (required, tcc is not supported)
|
||||
```powershell
|
||||
# Install via Scoop
|
||||
# Install via Scoop (recommended)
|
||||
scoop install mingw
|
||||
|
||||
# Or via Chocolatey
|
||||
choco install mingw
|
||||
|
||||
# Or download from: https://www.mingw-w64.org/downloads/
|
||||
# Or download w64devkit from: https://github.com/skeeto/w64devkit/releases
|
||||
```
|
||||
|
||||
2. **V Compiler** - Version 0.4.x or later
|
||||
@@ -92,7 +107,11 @@ To use uSockets backend on Windows, you need:
|
||||
v version # Should show V 0.4.x
|
||||
```
|
||||
|
||||
The uSockets library and libuv are pre-compiled and included in the `usockets/lib/` directory.
|
||||
The uSockets library and libuv are pre-compiled and included in the `usockets/lib/{platform}/` directory:
|
||||
- `usockets/lib/windows/` - Windows x64
|
||||
- `usockets/lib/linux/` - Linux x64
|
||||
- `usockets/lib/macos-arm64/` - macOS Apple Silicon
|
||||
- `usockets/lib/macos-x64/` - macOS Intel
|
||||
|
||||
### Using uSockets Backend
|
||||
|
||||
@@ -620,6 +639,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 +1151,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>'
|
||||
}
|
||||
@@ -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>'
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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.
Binary file not shown.
Binary file not shown.
+16
-23
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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.5'
|
||||
license: 'MIT'
|
||||
dependencies: []
|
||||
}
|
||||
|
||||
+1061
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user