From 9ae9170bcb9c04e48d1dc66f615ccf54fbad6953 Mon Sep 17 00:00:00 2001 From: meiseayoung Date: Sun, 4 Jan 2026 15:13:28 +0800 Subject: [PATCH] feat: add SSE streaming helper support Add Server-Sent Events (SSE) and streaming response support with three streaming functions: - stream(): Basic binary streaming with chunked transfer encoding - stream_text(): Text streaming with proper Content-Type headers - stream_sse(): SSE event streaming with full event field support Features: - SSEEvent struct with data, event, id, retry fields - StreamContext for write, writeln, write_sse, sleep, pipe operations - Multi-line data handling in SSE events - Custom error handler support - Client disconnect detection via on_abort callback - Integrated with both Picoev and uSockets backends New files: - streaming.v: Core streaming implementation - examples/sse_demo.v: Comprehensive SSE demo with test page - tests/test_stream_*.v: 62 property-based tests Updated: - picoev_server.v: PicoevStreamWriter integration - usockets_server.v: UsocketsStreamWriter integration - README.md: SSE streaming documentation --- .gitignore | 3 +- README.md | 251 ++++- examples/sse_demo.v | 554 ++++++++++ picoev_server.v | 95 ++ streaming.v | 950 +++++++++++++++++ tests/test_picoev_stream_writer_properties.v | 434 ++++++++ tests/test_stream_error_handling_properties.v | 761 ++++++++++++++ tests/test_stream_function_properties.v | 586 +++++++++++ tests/test_stream_sse_properties.v | 988 ++++++++++++++++++ tests/test_stream_text_properties.v | 648 ++++++++++++ .../test_usockets_stream_writer_properties.v | 624 +++++++++++ usockets_server.v | 101 ++ v.mod | 2 +- 13 files changed, 5993 insertions(+), 4 deletions(-) create mode 100644 examples/sse_demo.v create mode 100644 streaming.v create mode 100644 tests/test_picoev_stream_writer_properties.v create mode 100644 tests/test_stream_error_handling_properties.v create mode 100644 tests/test_stream_function_properties.v create mode 100644 tests/test_stream_sse_properties.v create mode 100644 tests/test_stream_text_properties.v create mode 100644 tests/test_usockets_stream_writer_properties.v diff --git a/.gitignore b/.gitignore index 87917cf..1c25fdb 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ temp/ # Chunk upload temp files chunks/ -.kiro \ No newline at end of file +.kiro +*.def \ No newline at end of file diff --git a/README.md b/README.md index dda2e65..e7abb0b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A high-performance V language web framework inspired by [Hono.js](https://hono.d - 📤 **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 @@ -784,6 +785,251 @@ fn main() { | 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 + +``` + ## Route Grouping ```v @@ -889,8 +1135,9 @@ v-hono/ ├── rate_limit.v # Rate limiting middleware ├── validator.v # Request validation ├── websocket.v # WebSocket helper (RFC 6455) -├── picoev_server.v # Picoev backend with WebSocket support -├── usockets_server.v # uSockets backend with WebSocket support +├── 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 diff --git a/examples/sse_demo.v b/examples/sse_demo.v new file mode 100644 index 0000000..17b7bc6 --- /dev/null +++ b/examples/sse_demo.v @@ -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 ' + + +v-hono SSE Streaming Demo + + + +

v-hono SSE Streaming Demo

+

Test Server-Sent Events and streaming responses

+ +
+
+

SSE Event Stream

+
+ /sse + + + Disconnected +
+
+ +
+ +
+

Multi-line SSE

+
+ /sse-multiline + + + Disconnected +
+
+ +
+ +
+

Real-time Counter

+
+ /sse-counter + + + Disconnected +
+
+ +
+ +
+

Error Handling

+
+ /sse-error + + + Disconnected +
+
+ +
+ +
+

Text Stream

+
+ /stream-text + + Ready +
+
+ +
+ +
+

Binary Stream

+
+ /stream + + Ready +
+
+ +
+
+ + + +' +} diff --git a/picoev_server.v b/picoev_server.v index 2977e93..f655caf 100644 --- a/picoev_server.v +++ b/picoev_server.v @@ -521,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 { @@ -570,6 +577,94 @@ 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 +// 2. Creates a PicoevStreamWriter +// 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 } + + // Send HTTP response line + if status_code == 200 { + res.http_ok() + } else { + res.raw('HTTP/1.1 ${status_code} ${get_status_text(status_code)}\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(key, 'content-length') { + continue + } + res.header(key, value) + 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 { + res.header('Transfer-Encoding', 'chunked') + } + + // Ensure Connection header is set for streaming + if !has_connection { + res.header('Connection', 'keep-alive') + res.header('Keep-Alive', 'timeout=${config.timeout_secs}') + } + + // End headers section (empty body to signal headers are complete) + // Note: We don't call res.body() or res.end() here because we're streaming + res.raw('\r\n') + + // Create PicoevStreamWriter for streaming + 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) +} + // 获取状态码文本 fn get_status_text(code int) string { return match code { diff --git a/streaming.v b/streaming.v new file mode 100644 index 0000000..a1eac14 --- /dev/null +++ b/streaming.v @@ -0,0 +1,950 @@ +// 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 + +// ============================================================================ +// 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 +@[heap] +pub struct PicoevStreamWriter { +mut: + res &picohttpparser.Response + connected bool +} + +// PicoevStreamWriter.new - Create a new PicoevStreamWriter with the given response +pub fn PicoevStreamWriter.new(res &picohttpparser.Response) &PicoevStreamWriter { + return &PicoevStreamWriter{ + res: unsafe { res } + 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 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) + w.res.raw(chunk_header) + w.res.raw(data.bytestr()) + w.res.raw('\r\n') +} + +// 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) + w.res.raw(chunk_header) + w.res.raw(data) + w.res.raw('\r\n') +} + +// flush - Flush the buffer (picoev handles this automatically) +pub fn (mut w PicoevStreamWriter) flush() ! { + // picoev 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 PicoevStreamWriter) close() ! { + if w.connected { + // Write the final chunk (zero-length chunk) to signal end of stream + w.res.raw('0\r\n\r\n') + w.res.end() + w.connected = false + } +} + +// 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 +} diff --git a/tests/test_picoev_stream_writer_properties.v b/tests/test_picoev_stream_writer_properties.v new file mode 100644 index 0000000..8229159 --- /dev/null +++ b/tests/test_picoev_stream_writer_properties.v @@ -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() +} diff --git a/tests/test_stream_error_handling_properties.v b/tests/test_stream_error_handling_properties.v new file mode 100644 index 0000000..7f2b57b --- /dev/null +++ b/tests/test_stream_error_handling_properties.v @@ -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() +} diff --git a/tests/test_stream_function_properties.v b/tests/test_stream_function_properties.v new file mode 100644 index 0000000..26d6d3b --- /dev/null +++ b/tests/test_stream_function_properties.v @@ -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() +} diff --git a/tests/test_stream_sse_properties.v b/tests/test_stream_sse_properties.v new file mode 100644 index 0000000..0791761 --- /dev/null +++ b/tests/test_stream_sse_properties.v @@ -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() +} diff --git a/tests/test_stream_text_properties.v b/tests/test_stream_text_properties.v new file mode 100644 index 0000000..2aefa94 --- /dev/null +++ b/tests/test_stream_text_properties.v @@ -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() +} diff --git a/tests/test_usockets_stream_writer_properties.v b/tests/test_usockets_stream_writer_properties.v new file mode 100644 index 0000000..b0d09c2 --- /dev/null +++ b/tests/test_usockets_stream_writer_properties.v @@ -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() +} diff --git a/usockets_server.v b/usockets_server.v index cdf9094..9996f7f 100644 --- a/usockets_server.v +++ b/usockets_server.v @@ -577,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) @@ -633,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 { diff --git a/v.mod b/v.mod index b3350a2..b6f4cfe 100644 --- a/v.mod +++ b/v.mod @@ -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.6.0' + version: '0.7.0' license: 'MIT' dependencies: [] }