diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c468191..fe45c4d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to aimux are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Breaking + +**Rust (aimux-stream)** + +- `SseStream` is now a thin adapter over the `sse-stream` crate (WHATWG + event-stream parsing), mirroring how the AI SDK's `parseJsonEventStream` + wraps `eventsource-parser`: `\n`, `\r` and `\r\n` line endings in any + mix, a leading UTF-8 BOM is stripped, a field line without `:` counts as an + empty value, an empty `event:` is `None`, an `id` containing U+0000 is + ignored, `retry` must be all ASCII digits, and a block is dispatched only + when it had a `data` line. There is no event size limit any more (as + upstream): `with_max_event_size` and `SseError::FrameTooLarge` are gone, + which fixes streams carrying multi-megabyte events (OpenAI Responses and + Gemini image generation). `SseError::Stream` carries the transport error + as its source instead of a `String`; `SseError::Utf8` holds a + `str::Utf8Error`; `SseError::Decode` is added. Decoder errors (invalid + UTF-8, transport failure) now end the stream after being reported. + The response handler propagates terminal SSE decoding failures as + non-retryable `ApiCall` errors, preventing a normal finish or tool-call + finalization after truncated input. Invalid JSON inside an otherwise valid + SSE event remains a recoverable `JsonParse` error. + `SseStream` requires the body error type to implement `std::error::Error + + Send + Sync + 'static`. +- Removed `NdjsonStream` / `NdjsonError`: nothing in the workspace used them + and the AI SDK has no counterpart. `tokio` is now a dev-dependency only and + the unused direct `serde` dependency is dropped. + ## [0.5.0] - 2026-09-27 **Breaking release.** 13 PRs since 0.3.0: the cross-language error model diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce84ddbbc..ab11942a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ to set up a development environment, run the tests, and submit changes. aimux/ ├── aimux-core/ # Core abstractions: LanguageModel / Provider / Message / StreamPart ├── aimux-providers/ # provider implementations + cassettes (counts: docs/api/providers.md) -├── aimux-stream/ # SSE / NDJSON stream parsing +├── aimux-stream/ # SSE parsing, streamed tool-call tracking ├── aimux-provider-utils/ # HTTP utilities: retry, backoff, error parsing, API-key loading ├── aimux-ffi/ # C ABI (opaque handle + JSON + push callback) for non-native bindings ├── bindings/ # Node, Python, Swift, Kotlin, Flutter, Go, C — share one Rust core diff --git a/README.md b/README.md index e636bc1b1..02ee0ed0d 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ middleware, and telemetry per request). aimux/ ├── aimux-core # Core abstractions: LanguageModel / Provider / Message / StreamPart ├── aimux-providers # Provider implementations — registry-backed + typed (docs/api/providers.md) -├── aimux-stream # SSE / NDJSON stream parsing +├── aimux-stream # SSE parsing, streamed tool-call tracking ├── aimux-provider-utils # One-exchange HTTP helpers, response handlers, API-key loading ├── aimux-ffi # C ABI (opaque handles + JSON results + owned aimux_error_t *) for non-native bindings └── tools/ # aimux-cli (cache probe) · aimux-replay · aimux-web (console) @@ -122,7 +122,7 @@ cargo add aimux-core aimux-providers |-------|-------------|-----------| | `aimux-core` | Core abstractions: `LanguageModel` / `Provider` / `Message` / `StreamPart` | [crates.io](https://crates.io/crates/aimux-core) | | `aimux-providers` | Provider implementations — [registry-backed + typed](docs/api/providers.md) | [crates.io](https://crates.io/crates/aimux-providers) | -| `aimux-stream` | SSE / NDJSON stream parsing | [crates.io](https://crates.io/crates/aimux-stream) | +| `aimux-stream` | SSE parsing, streamed tool-call tracking | [crates.io](https://crates.io/crates/aimux-stream) | | `aimux-provider-utils` | One-exchange HTTP helpers and typed response handlers | [crates.io](https://crates.io/crates/aimux-provider-utils) | | `aimux-ffi` | C ABI for non-native bindings | [crates.io](https://crates.io/crates/aimux-ffi) | diff --git a/aimux-provider-utils/src/response_handler.rs b/aimux-provider-utils/src/response_handler.rs index db568ef8b..0d8917bd3 100644 --- a/aimux-provider-utils/src/response_handler.rs +++ b/aimux-provider-utils/src/response_handler.rs @@ -340,6 +340,9 @@ pub fn create_binary_response_handler() -> ResponseHandler { } /// Parse an SSE response and deserialize each `data:` field as `T`. +/// +/// Invalid JSON in a complete event remains a recoverable frame error. SSE +/// decoding failures terminate the byte stream and are non-retryable API errors. #[must_use] pub fn create_event_source_response_handler() -> ResponseHandler>> @@ -392,21 +395,35 @@ where match item { Ok(event) if event.data == "[DONE]" => continue, Ok(event) => yield serde_json::from_str::(&event.data).map_err(AiMuxError::from), - Err(aimux_stream::SseError::Stream(message)) => { + Err(aimux_stream::SseError::Stream(error)) => { // Preserve response transport failures as ApiCallError - // items. Framing/parser failures remain JsonParse below. + // items, eligible for retry before output is emitted. yield Err(AiMuxError::ApiCall(Box::new(ApiCallError { response_headers: Some(stream_headers.clone()), is_retryable: true, ..ApiCallError::new( - message, + error.to_string(), + stream_url.clone(), + stream_request_body_values.clone(), + ) + }))); + break; + } + Err(error) => { + // The SSE decoder ends after this error. Reporting a + // recoverable JsonParse would let providers treat that + // EOF as success and flush incomplete output/tool calls. + yield Err(AiMuxError::ApiCall(Box::new(ApiCallError { + response_headers: Some(stream_headers.clone()), + is_retryable: false, + ..ApiCallError::new( + error.to_string(), stream_url.clone(), stream_request_body_values.clone(), ) }))); break; } - Err(error) => yield Err(AiMuxError::JsonParse(error.to_string())), } } }); diff --git a/aimux-providers/tests/sse_terminal_error_test.rs b/aimux-providers/tests/sse_terminal_error_test.rs new file mode 100644 index 000000000..2d110c389 --- /dev/null +++ b/aimux-providers/tests/sse_terminal_error_test.rs @@ -0,0 +1,98 @@ +//! Integration checks for aimux's strict UTF-8 decoding (unlike upstream's +//! lossy decoder): a terminal byte error must stay terminal through the +//! response handler and provider, without a successful finish or tool flush. + +use aimux_core::error::AiMuxError; +use aimux_core::language_model::LanguageModel; +use aimux_core::options::CallOptions; +use aimux_core::stream_part::StreamPart; +use aimux_providers::{OpenAIConfig, OpenAIProvider}; +use futures::StreamExt; +use serde_json::{Value, json}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +async fn corrupted_stream(prefix: Value) -> Vec> { + let server = MockServer::start().await; + let suffix = json!({ + "choices": [{"delta": {"content": "world"}, "finish_reason": "stop"}] + }); + let mut body = format!("data: {prefix}\n\n").into_bytes(); + body.extend_from_slice(b"data: \xff\n\n"); + body.extend_from_slice(format!("data: {suffix}\n\ndata: [DONE]\n\n").as_bytes()); + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_bytes(body), + ) + .mount(&server) + .await; + let provider = OpenAIProvider::new(OpenAIConfig::new("test-key").with_base_url(server.uri())); + provider + .model("gpt-4o") + .do_stream(&CallOptions::new(vec![])) + .await + .expect("the first event is valid") + .stream + .collect() + .await +} + +fn assert_terminal_decode_error(parts: &[Result]) { + let errors: Vec<_> = parts + .iter() + .filter_map(|part| part.as_ref().err()) + .collect(); + assert_eq!(errors.len(), 1); + assert!(matches!(errors[0], AiMuxError::ApiCall(_))); + assert!(!errors[0].is_recoverable_stream_error()); + assert!(!errors[0].is_retryable()); + assert!(parts.last().is_some_and(Result::is_err)); + assert!( + !parts + .iter() + .any(|part| matches!(part, Ok(StreamPart::Finish { .. }))) + ); +} + +#[tokio::test] +async fn invalid_utf8_after_text_does_not_emit_a_successful_finish() { + let parts = corrupted_stream(json!({ + "choices": [{"delta": {"content": "hello "}}] + })) + .await; + let text: String = parts + .iter() + .filter_map(|part| match part { + Ok(StreamPart::TextDelta { delta, .. }) => Some(delta.as_str()), + _ => None, + }) + .collect(); + assert_eq!(text, "hello "); + assert_terminal_decode_error(&parts); +} + +#[tokio::test] +async fn invalid_utf8_does_not_finalize_an_incomplete_tool_call() { + let parts = corrupted_stream(json!({ + "choices": [{"delta": {"tool_calls": [{ + "index": 0, + "id": "call-1", + "type": "function", + "function": {"name": "lookup", "arguments": "{"} + }]}}] + })) + .await; + assert!( + parts + .iter() + .any(|part| matches!(part, Ok(StreamPart::ToolInputStart { .. }))) + ); + assert!(!parts.iter().any(|part| matches!( + part, + Ok(StreamPart::ToolCall { .. } | StreamPart::ToolInputEnd { .. }) + ))); + assert_terminal_decode_error(&parts); +} diff --git a/aimux-stream/Cargo.toml b/aimux-stream/Cargo.toml index 6ee65c240..85b6bb205 100644 --- a/aimux-stream/Cargo.toml +++ b/aimux-stream/Cargo.toml @@ -4,18 +4,21 @@ version.workspace = true edition.workspace = true license.workspace = true description = "Streaming primitives for aimux" -keywords = ["llm", "streaming", "sse", "ndjson"] +keywords = ["llm", "streaming", "sse", "tool-calls"] categories = ["asynchronous", "parser-implementations"] documentation = "https://docs.rs/aimux-stream" [dependencies] futures = { workspace = true } -tokio = { workspace = true } pin-project-lite = { workspace = true } bytes = { workspace = true } -serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +sse-stream = { version = "0.3.0", default-features = false, features = ["memchr"] } + +[dev-dependencies] +sha2 = "0.10" +tokio = { workspace = true } [lints] workspace = true diff --git a/aimux-stream/src/lib.rs b/aimux-stream/src/lib.rs index 8091dbeb7..e023a3204 100644 --- a/aimux-stream/src/lib.rs +++ b/aimux-stream/src/lib.rs @@ -1,16 +1,15 @@ //! # aimux-stream //! -//! Low-level streaming primitives for SSE (Server-Sent Events) and NDJSON parsing, +//! Low-level streaming primitives for SSE (Server-Sent Events) parsing and +//! streamed tool-call tracking, //! used by provider implementations to decode model API response streams. pub mod lines; -pub mod ndjson; pub mod sse; pub mod streaming_tool_call_tracker; // Re-export the most commonly used items. pub use lines::extract_lines; -pub use ndjson::{NdjsonError, NdjsonStream}; pub use sse::{SseError, SseEvent, SseStream}; pub use streaming_tool_call_tracker::{ StreamingToolCallDelta, StreamingToolCallFunction, StreamingToolCallTracker, diff --git a/aimux-stream/src/ndjson.rs b/aimux-stream/src/ndjson.rs deleted file mode 100644 index ca6801a52..000000000 --- a/aimux-stream/src/ndjson.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! NDJSON (newline-delimited JSON) stream decoder. - -use bytes::Bytes; -use futures::Stream; -use pin_project_lite::pin_project; -use serde::de::DeserializeOwned; -use std::pin::Pin; -use std::task::{Context, Poll}; -use thiserror::Error; - -/// Default upper bound on a single buffered NDJSON line's size (1 MiB). -const DEFAULT_MAX_LINE_SIZE: usize = 1024 * 1024; - -#[derive(Debug, Error)] -pub enum NdjsonError { - #[error("utf-8 error: {0}")] - Utf8(#[from] std::string::FromUtf8Error), - #[error("json error: {0}")] - Json(#[from] serde_json::Error), - #[error("stream error: {0}")] - Stream(String), - #[error("NDJSON line exceeded maximum allowed size")] - LineTooLarge, -} - -pin_project! { - /// Decodes a byte stream into parsed NDJSON values. - /// - /// Bytes are accumulated in a raw `Vec` buffer and split on `\n`. Each - /// complete line is strictly UTF-8 decoded only *after* reassembly, so a - /// multi-byte character split across two network chunks is never corrupted - /// into replacement chars — unlike a per-chunk - /// `String::from_utf8_lossy` decode, which would emit a `U+FFFD` on each - /// side of the split. - pub struct NdjsonStream { - #[pin] - inner: S, - buffer: Vec, - max_line_size: usize, - _marker: std::marker::PhantomData<(T, E)>, - } -} - -impl NdjsonStream -where - S: Stream> + Unpin, - T: DeserializeOwned, -{ - pub fn new(stream: S) -> Self { - Self::with_max_line_size(stream, DEFAULT_MAX_LINE_SIZE) - } - - /// Create an [`NdjsonStream`] with a custom per-line size limit. A line - /// longer than `max_line_size` bytes (excluding the `\n`) yields - /// [`NdjsonError::LineTooLarge`], as does a buffer that grows past the - /// limit while waiting for a newline. - pub fn with_max_line_size(stream: S, max_line_size: usize) -> Self { - Self { - inner: stream, - buffer: Vec::new(), - max_line_size, - _marker: std::marker::PhantomData, - } - } -} - -impl Stream for NdjsonStream -where - S: Stream> + Unpin, - T: DeserializeOwned, - E: std::fmt::Display, -{ - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.as_mut().get_mut(); - - loop { - // Try to parse a complete JSON line. - if let Some(pos) = this.buffer.iter().position(|&b| b == b'\n') { - if pos > this.max_line_size { - // Drop the oversized line so a retried poll makes progress. - this.buffer.drain(..pos + 1); - return Poll::Ready(Some(Err(NdjsonError::LineTooLarge))); - } - // Extract the line bytes (without the newline) and decode - // strictly. Decoding the fully reassembled line (rather than - // `from_utf8_lossy` per chunk) preserves code points split - // across chunks and surfaces invalid UTF-8 as an error. - let line_bytes: Vec = this.buffer.drain(..pos).collect(); - this.buffer.drain(..1); // the newline - let line = match String::from_utf8(line_bytes) { - Ok(s) => s, - Err(e) => return Poll::Ready(Some(Err(NdjsonError::Utf8(e)))), - }; - let line = line.trim(); - if line.is_empty() { - continue; - } - match serde_json::from_str::(line) { - Ok(value) => return Poll::Ready(Some(Ok(value))), - Err(e) => return Poll::Ready(Some(Err(NdjsonError::Json(e)))), - } - } - - // No complete line yet. Guard against unbounded buffer growth when - // a newline never arrives. The partial can never form a valid line - // under the limit, so drop it — a retried poll then makes progress - // instead of looping on the same oversized buffer. - if this.buffer.len() > this.max_line_size { - this.buffer.clear(); - return Poll::Ready(Some(Err(NdjsonError::LineTooLarge))); - } - - // Read more bytes. - match Pin::new(&mut this.inner).poll_next(cx) { - Poll::Ready(Some(Ok(bytes))) => { - this.buffer.extend_from_slice(&bytes); - } - Poll::Ready(Some(Err(e))) => { - return Poll::Ready(Some(Err(NdjsonError::Stream(e.to_string())))); - } - Poll::Ready(None) => { - // Stream ended — try to parse any remaining buffer. - if this.buffer.len() > this.max_line_size { - return Poll::Ready(Some(Err(NdjsonError::LineTooLarge))); - } - let remaining_bytes = std::mem::take(&mut this.buffer); - let remaining = match String::from_utf8(remaining_bytes) { - Ok(s) => s, - Err(e) => return Poll::Ready(Some(Err(NdjsonError::Utf8(e)))), - }; - let remaining = remaining.trim(); - if remaining.is_empty() { - return Poll::Ready(None); - } - match serde_json::from_str::(remaining) { - Ok(value) => return Poll::Ready(Some(Ok(value))), - Err(e) => return Poll::Ready(Some(Err(NdjsonError::Json(e)))), - } - } - Poll::Pending => return Poll::Pending, - } - } - } -} diff --git a/aimux-stream/src/sse.rs b/aimux-stream/src/sse.rs index c53f66e40..f32f436cd 100644 --- a/aimux-stream/src/sse.rs +++ b/aimux-stream/src/sse.rs @@ -1,249 +1,124 @@ -//! SSE (Server-Sent Events) parser for streaming model responses. +//! SSE (Server-Sent Events) decoding for streaming model responses. +//! +//! The parser is the [`sse-stream`](https://crates.io/crates/sse-stream) crate +//! (the WHATWG "parsing an event stream" algorithm: `\n`, `\r` and `\r\n` +//! line endings in any mix, a leading UTF-8 BOM, `field: value` with one +//! optional leading space, lines without `:` as empty-valued fields, +//! `:`-prefixed comments, an `id` containing U+0000 ignored, `retry` only when +//! all ASCII digits, unknown fields ignored, a partial block at end of stream +//! dropped). +//! +//! This module is the thin adapter that gives it the AI SDK's dispatch +//! semantics, the way `parseJsonEventStream` wraps `eventsource-parser`: +//! +//! - a block is dispatched only if it had at least one `data` line; comment- +//! only and metadata-only blocks are dropped; +//! - an empty `event` value means no event type; +//! - `retry` is reported on the event it was parsed with (upstream reports it +//! through `onRetry`), so a `retry` block without `data` is dropped; +//! - there is no buffer size limit, as in `parseJsonEventStream`. +//! +//! Differences from upstream kept on purpose: a field value that is not valid +//! UTF-8 is a strict [`SseError::Utf8`] and ends the stream (upstream decodes +//! lossily), and a transport error ends the stream after it is reported. + +use std::marker::PhantomData; +use std::pin::Pin; +use std::task::{Context, Poll, ready}; use bytes::Bytes; use futures::Stream; use pin_project_lite::pin_project; -use std::pin::Pin; -use std::task::{Context, Poll}; +use sse_stream::SseByteStream; use thiserror::Error; -/// Default upper bound on a single buffered SSE event's size (1 MiB). -const DEFAULT_MAX_EVENT_SIZE: usize = 1024 * 1024; - +/// A terminal decoding error: after yielding one, the stream ends. #[derive(Debug, Error)] pub enum SseError { + /// A recognized field value (`data`, `event`, `id`, `retry`) is not valid + /// UTF-8. #[error("utf-8 decode error: {0}")] - Utf8(#[from] std::string::FromUtf8Error), + Utf8(#[source] std::str::Utf8Error), + /// The underlying byte stream failed; the source is the transport error. #[error("stream error: {0}")] - Stream(String), - #[error("SSE frame exceeded maximum allowed size")] - FrameTooLarge, + Stream(#[source] Box), + /// Any other decoder error. + #[error("SSE decode error: {0}")] + Decode(#[source] sse_stream::Error), +} + +impl From for SseError { + fn from(error: sse_stream::Error) -> Self { + match error { + sse_stream::Error::Body(source) => Self::Stream(source), + sse_stream::Error::Utf8Parse(source) => Self::Utf8(source), + other => Self::Decode(other), + } + } } /// A parsed SSE event. #[derive(Debug, Clone, Default)] pub struct SseEvent { - /// The `event:` field (optional). + /// The `event:` field (`None` when absent or empty). pub event: Option, - /// The `data:` field. + /// The `data:` field; multiple `data` lines are joined with `\n`. pub data: String, /// The `id:` field (optional). pub id: Option, - /// The `retry:` field (optional). + /// The `retry:` field (optional; all-digit values only). pub retry: Option, } pin_project! { /// An adapter that decodes a byte stream into SSE events. - /// - /// Bytes are accumulated in a raw `Vec` buffer and split on the SSE - /// event terminator (a blank line). Each complete frame is strictly - /// UTF-8 decoded only *after* reassembly, so a multi-byte character split - /// across two network chunks is never corrupted into replacement chars — - /// unlike a per-chunk `String::from_utf8_lossy` decode, which would emit a - /// `U+FFFD` on each side of the split. - pub struct SseStream { + pub struct SseStream + where + S: Stream>, + { #[pin] - inner: S, - buffer: Vec, - max_event_size: usize, - done: bool, - _err: std::marker::PhantomData, + inner: SseByteStream, + _err: PhantomData, } } impl SseStream where - S: Stream> + Unpin, + S: Stream>, { pub fn new(stream: S) -> Self { - Self::with_max_event_size(stream, DEFAULT_MAX_EVENT_SIZE) - } - - /// Create an [`SseStream`] with a custom per-event size limit. A single - /// event frame (the bytes between two terminators, excluding the - /// terminator itself) larger than `max_event_size` bytes yields - /// [`SseError::FrameTooLarge`], as does a buffer that grows past the limit - /// while waiting for a terminator. - pub fn with_max_event_size(stream: S, max_event_size: usize) -> Self { Self { - inner: stream, - buffer: Vec::new(), - max_event_size, - done: false, - _err: std::marker::PhantomData, + inner: SseByteStream::new(stream), + _err: PhantomData, } } } impl Stream for SseStream where - S: Stream> + Unpin, - E: std::fmt::Display, + S: Stream>, + E: std::error::Error + Send + Sync + 'static, { type Item = Result; - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.as_mut().get_mut(); - + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); loop { - // Try to emit a complete event from the buffer. - if let Some((frame_len, sep_len)) = find_separator(&this.buffer) { - if frame_len > this.max_event_size { - // Drop the oversized frame so a retried poll makes progress. - this.buffer.drain(..frame_len + sep_len); - return Poll::Ready(Some(Err(SseError::FrameTooLarge))); - } - // Extract the frame bytes and drop the terminator. Decoding the - // fully reassembled frame with `String::from_utf8` (rather than - // `from_utf8_lossy` per chunk) preserves code points split - // across chunks and surfaces invalid UTF-8 as an error. - let frame_bytes: Vec = this.buffer.drain(..frame_len).collect(); - this.buffer.drain(..sep_len); - let frame = match String::from_utf8(frame_bytes) { - Ok(s) => s, - Err(e) => return Poll::Ready(Some(Err(SseError::Utf8(e)))), - }; - let (event, has_data_line) = try_parse_event(&frame); - // Per the SSE spec / eventsource-parser, an event is dispatched - // only if it had at least one `data:` line (dataLines > 0). - // This covers comment-only, `event:`/`id:`/`retry:`-only, and - // blank-line keep-alives (all dispatched as nothing), while an - // explicit empty data line (`data:\n\n`) is still dispatched. - if !has_data_line { - continue; + match ready!(this.inner.as_mut().poll_next(cx)) { + None => return Poll::Ready(None), + Some(Err(error)) => return Poll::Ready(Some(Err(error.into()))), + Some(Ok(block)) => { + // Dispatch only blocks that carried at least one `data` + // line (`dataLines > 0` upstream). + let Some(data) = block.data else { continue }; + return Poll::Ready(Some(Ok(SseEvent { + event: block.event.filter(|event| !event.is_empty()), + data, + id: block.id, + retry: block.retry, + }))); } - return Poll::Ready(Some(Ok(event))); - } - - // No complete event yet. Guard against unbounded buffer growth when - // a terminator never arrives. The partial can never form a valid - // frame under the limit, so drop it — a retried poll then makes - // progress instead of looping on the same oversized buffer. - if this.buffer.len() > this.max_event_size { - this.buffer.clear(); - return Poll::Ready(Some(Err(SseError::FrameTooLarge))); - } - - if this.done { - // No terminating blank line: the buffered partial is not a - // complete event and is dropped. This matches eventsource-parser's - // `EventSourceParserStream`, which has no flush handler — a - // partial event at EOF is simply not dispatched. - return Poll::Ready(None); - } - - // Read more data. - match Pin::new(&mut this.inner).poll_next(cx) { - Poll::Ready(Some(Ok(bytes))) => { - this.buffer.extend_from_slice(&bytes); - } - Poll::Ready(Some(Err(e))) => { - return Poll::Ready(Some(Err(SseError::Stream(e.to_string())))); - } - Poll::Ready(None) => { - this.done = true; - } - Poll::Pending => return Poll::Pending, } } } } - -/// Locate the next SSE event terminator (a blank line) in `buf`. -/// -/// Returns `(frame_len, sep_len)` where `frame_len` is the number of bytes -/// *before* the terminator and `sep_len` is the terminator's length. Prefers -/// `\n\n` (so a pure-CRLF stream — which contains no bare `\n\n` — falls -/// through to `\r\n\r\n`), mirroring the str-based -/// `find("\n\n").or_else(|| find("\r\n\r\n"))`. -fn find_separator(buf: &[u8]) -> Option<(usize, usize)> { - if let Some(pos) = find_subsequence(buf, b"\n\n") { - return Some((pos, 2)); - } - find_subsequence(buf, b"\r\n\r\n").map(|pos| (pos, 4)) -} - -/// First index of `needle` in `haystack`, comparing raw bytes. -fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { - haystack.windows(needle.len()).position(|w| w == needle) -} - -/// Parse a single complete SSE event frame (the bytes between two -/// terminators, already strictly UTF-8 decoded) into an [`SseEvent`] plus a -/// flag indicating whether the event contained at least one `data:` line. -/// -/// A complete event is terminated by a blank line: `\n\n` or `\r\n\r\n` (the -/// terminator is consumed by the caller before this runs). Per the SSE spec, -/// exactly one leading U+0020 SPACE after the `:` is removed from each field -/// value (not all leading whitespace). Comment lines (starting with `:`) and -/// unknown fields are ignored. -fn try_parse_event(frame: &str) -> (SseEvent, bool) { - let mut event = SseEvent::default(); - let mut has_data_line = false; - for line in frame.lines() { - if let Some(value) = field_value(line, "data:") { - has_data_line = true; - if event.data.is_empty() { - event.data = value.to_string(); - } else { - event.data.push('\n'); - event.data.push_str(value); - } - } else if let Some(value) = field_value(line, "event:") { - event.event = Some(value.to_string()); - } else if let Some(value) = field_value(line, "id:") { - event.id = Some(value.to_string()); - } else if let Some(value) = field_value(line, "retry:") { - event.retry = value.parse().ok(); - } - // Comment lines (`:` prefix) and unknown fields are ignored. - } - - (event, has_data_line) -} - -/// Strip `prefix` from `line` and remove exactly one leading U+0020 SPACE from -/// the remainder (per the SSE spec). Returns `None` if `line` does not start -/// with `prefix`. -fn field_value<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { - let rest = line.strip_prefix(prefix)?; - // Remove exactly one leading space, if present. - Some(rest.strip_prefix(' ').unwrap_or(rest)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_single_event() { - let (event, has_data_line) = try_parse_event("data: hello world"); - assert_eq!(event.data, "hello world"); - assert!(has_data_line); - } - - #[test] - fn parse_multi_line_data() { - let (event, has_data_line) = try_parse_event("data: line1\ndata: line2"); - assert_eq!(event.data, "line1\nline2"); - assert!(has_data_line); - } - - #[test] - fn parse_event_with_type() { - let (event, has_data_line) = try_parse_event("event: message\ndata: payload"); - assert_eq!(event.event.as_deref(), Some("message")); - assert_eq!(event.data, "payload"); - assert!(has_data_line); - } - - #[test] - fn parse_event_without_data_has_no_data_line() { - // An `event:`-only event has no data line -> not dispatched. - let (event, has_data_line) = try_parse_event("event: ping"); - assert_eq!(event.event.as_deref(), Some("ping")); - assert!(event.data.is_empty()); - assert!(!has_data_line); - } -} diff --git a/aimux-stream/tests/fixtures/eventsource_parser_multibyte.json b/aimux-stream/tests/fixtures/eventsource_parser_multibyte.json new file mode 100644 index 000000000..f427553c5 --- /dev/null +++ b/aimux-stream/tests/fixtures/eventsource_parser_multibyte.json @@ -0,0 +1 @@ +{"lines":["Blåbærsyltetøy må da være mulig å oppdrive?","በእርግጥ አንድ ሰው ብሉቤሪ ጃምን መግዛት መቻል አለበት?","بالتأكيد يجب أن يكون المرء قادرا على شراء مربى التوت؟","一定能买到蓝莓果酱吗?","ચોક્કસ એક બ્લુબેરી જામ મેળવવા માટે સમર્થ હોવા જ જોઈએ?","בטח אפשר להשיג ריבת אוכמניות?","निश्चित रूप से किसी को ब्लूबेरी जैम खरीदने में सक्षम होना चाहिए?","Bê guman pêdivî ye ku meriv karibe jama şînê peyda bike?","ប្រាកដណាស់ មនុស្សម្នាក់ត្រូវតែអាចទិញយៈសាពូនមី blueberry បានទេ?","ಖಂಡಿತವಾಗಿಯೂ ಒಬ್ಬರು ಬ್ಲೂಬೆರ್ರಿ ಜಾಮ್ ಅನ್ನು ಸಂಗ್ರಹಿಸಲು ಶಕ್ತರಾಗಿರಬೇಕು?","確かにブルーベリージャムを調達できなければなりませんか?","Невже треба вміти добути варення з чорниці?","แน่นอนว่าจะต้องสามารถจัดหาแยมบลูเบอร์รี่ได้?","Сигурно некој мора да може да набави џем од боровинки?"],"emojis":["😶‍🌫️","😮‍💨","😵‍💫","❤️‍🔥","❤️‍🩹","👁️‍🗨️","🫱🏻‍🫲🏼","🫱🏻‍🫲🏽","🫱🏻‍🫲🏾","🫱🏻‍🫲🏿","🫱🏼‍🫲🏻","🫱🏼‍🫲🏽","🫱🏼‍🫲🏾","🫱🏼‍🫲🏿","🫱🏽‍🫲🏻","🫱🏽‍🫲🏼","🫱🏽‍🫲🏾","🫱🏽‍🫲🏿","🫱🏾‍🫲🏻","🫱🏾‍🫲🏼","🫱🏾‍🫲🏽","🫱🏾‍🫲🏿","🫱🏿‍🫲🏻","🫱🏿‍🫲🏼","🫱🏿‍🫲🏽","🫱🏿‍🫲🏾","🧔‍♂️","🧔🏻‍♂️","🧔🏼‍♂️","🧔🏽‍♂️","🧔🏾‍♂️","🧔🏿‍♂️","🧔‍♀️","🧔🏻‍♀️","🧔🏼‍♀️","🧔🏽‍♀️","🧔🏾‍♀️","🧔🏿‍♀️","👨‍🦰","👨🏻‍🦰","👨🏼‍🦰","👨🏽‍🦰","👨🏾‍🦰","👨🏿‍🦰","👨‍🦱","👨🏻‍🦱","👨🏼‍🦱","👨🏽‍🦱","👨🏾‍🦱","👨🏿‍🦱","👨‍🦳","👨🏻‍🦳","👨🏼‍🦳","👨🏽‍🦳","👨🏾‍🦳","👨🏿‍🦳","👨‍🦲","👨🏻‍🦲","👨🏼‍🦲","👨🏽‍🦲","👨🏾‍🦲","👨🏿‍🦲","👩‍🦰","👩🏻‍🦰","👩🏼‍🦰","👩🏽‍🦰","👩🏾‍🦰","👩🏿‍🦰","🧑‍🦰","🧑🏻‍🦰","🧑🏼‍🦰","🧑🏽‍🦰","🧑🏾‍🦰","🧑🏿‍🦰","👩‍🦱","👩🏻‍🦱","👩🏼‍🦱","👩🏽‍🦱","👩🏾‍🦱","👩🏿‍🦱","🧑‍🦱","🧑🏻‍🦱","🧑🏼‍🦱","🧑🏽‍🦱","🧑🏾‍🦱","🧑🏿‍🦱","👩‍🦳","👩🏻‍🦳","👩🏼‍🦳","👩🏽‍🦳","👩🏾‍🦳","👩🏿‍🦳","🧑‍🦳","🧑🏻‍🦳","🧑🏼‍🦳","🧑🏽‍🦳","🧑🏾‍🦳","🧑🏿‍🦳","👩‍🦲","👩🏻‍🦲","👩🏼‍🦲","👩🏽‍🦲","👩🏾‍🦲","👩🏿‍🦲","🧑‍🦲","🧑🏻‍🦲","🧑🏼‍🦲","🧑🏽‍🦲","🧑🏾‍🦲","🧑🏿‍🦲","👱‍♀️","👱🏻‍♀️","👱🏼‍♀️","👱🏽‍♀️","👱🏾‍♀️","👱🏿‍♀️","👱‍♂️","👱🏻‍♂️","👱🏼‍♂️","👱🏽‍♂️","👱🏾‍♂️","👱🏿‍♂️","🙍‍♂️","🙍🏻‍♂️","🙍🏼‍♂️","🙍🏽‍♂️","🙍🏾‍♂️","🙍🏿‍♂️","🙍‍♀️","🙍🏻‍♀️","🙍🏼‍♀️","🙍🏽‍♀️","🙍🏾‍♀️","🙍🏿‍♀️","🙎‍♂️","🙎🏻‍♂️","🙎🏼‍♂️","🙎🏽‍♂️","🙎🏾‍♂️","🙎🏿‍♂️","🙎‍♀️","🙎🏻‍♀️","🙎🏼‍♀️","🙎🏽‍♀️","🙎🏾‍♀️","🙎🏿‍♀️","🙅‍♂️","🙅🏻‍♂️","🙅🏼‍♂️","🙅🏽‍♂️","🙅🏾‍♂️","🙅🏿‍♂️","🙅‍♀️","🙅🏻‍♀️","🙅🏼‍♀️","🙅🏽‍♀️","🙅🏾‍♀️","🙅🏿‍♀️","🙆‍♂️","🙆🏻‍♂️","🙆🏼‍♂️","🙆🏽‍♂️","🙆🏾‍♂️","🙆🏿‍♂️","🙆‍♀️","🙆🏻‍♀️","🙆🏼‍♀️","🙆🏽‍♀️","🙆🏾‍♀️","🙆🏿‍♀️","💁‍♂️","💁🏻‍♂️","💁🏼‍♂️","💁🏽‍♂️","💁🏾‍♂️","💁🏿‍♂️","💁‍♀️","💁🏻‍♀️","💁🏼‍♀️","💁🏽‍♀️","💁🏾‍♀️","💁🏿‍♀️","🙋‍♂️","🙋🏻‍♂️","🙋🏼‍♂️","🙋🏽‍♂️","🙋🏾‍♂️","🙋🏿‍♂️","🙋‍♀️","🙋🏻‍♀️","🙋🏼‍♀️","🙋🏽‍♀️","🙋🏾‍♀️","🙋🏿‍♀️","🧏‍♂️","🧏🏻‍♂️","🧏🏼‍♂️","🧏🏽‍♂️","🧏🏾‍♂️","🧏🏿‍♂️","🧏‍♀️","🧏🏻‍♀️","🧏🏼‍♀️","🧏🏽‍♀️","🧏🏾‍♀️","🧏🏿‍♀️","🙇‍♂️","🙇🏻‍♂️","🙇🏼‍♂️","🙇🏽‍♂️","🙇🏾‍♂️","🙇🏿‍♂️","🙇‍♀️","🙇🏻‍♀️","🙇🏼‍♀️","🙇🏽‍♀️","🙇🏾‍♀️","🙇🏿‍♀️","🤦‍♂️","🤦🏻‍♂️","🤦🏼‍♂️","🤦🏽‍♂️","🤦🏾‍♂️","🤦🏿‍♂️","🤦‍♀️","🤦🏻‍♀️","🤦🏼‍♀️","🤦🏽‍♀️","🤦🏾‍♀️","🤦🏿‍♀️","🤷‍♂️","🤷🏻‍♂️","🤷🏼‍♂️","🤷🏽‍♂️","🤷🏾‍♂️","🤷🏿‍♂️","🤷‍♀️","🤷🏻‍♀️","🤷🏼‍♀️","🤷🏽‍♀️","🤷🏾‍♀️","🤷🏿‍♀️","🧑‍⚕️","🧑🏻‍⚕️","🧑🏼‍⚕️","🧑🏽‍⚕️","🧑🏾‍⚕️","🧑🏿‍⚕️","👨‍⚕️","👨🏻‍⚕️","👨🏼‍⚕️","👨🏽‍⚕️","👨🏾‍⚕️","👨🏿‍⚕️","👩‍⚕️","👩🏻‍⚕️","👩🏼‍⚕️","👩🏽‍⚕️","👩🏾‍⚕️","👩🏿‍⚕️","🧑‍🎓","🧑🏻‍🎓","🧑🏼‍🎓","🧑🏽‍🎓","🧑🏾‍🎓","🧑🏿‍🎓","👨‍🎓","👨🏻‍🎓","👨🏼‍🎓","👨🏽‍🎓","👨🏾‍🎓","👨🏿‍🎓","👩‍🎓","👩🏻‍🎓","👩🏼‍🎓","👩🏽‍🎓","👩🏾‍🎓","👩🏿‍🎓","🧑‍🏫","🧑🏻‍🏫","🧑🏼‍🏫","🧑🏽‍🏫","🧑🏾‍🏫","🧑🏿‍🏫","👨‍🏫","👨🏻‍🏫","👨🏼‍🏫","👨🏽‍🏫","👨🏾‍🏫","👨🏿‍🏫","👩‍🏫","👩🏻‍🏫","👩🏼‍🏫","👩🏽‍🏫","👩🏾‍🏫","👩🏿‍🏫","🧑‍⚖️","🧑🏻‍⚖️","🧑🏼‍⚖️","🧑🏽‍⚖️","🧑🏾‍⚖️","🧑🏿‍⚖️","👨‍⚖️","👨🏻‍⚖️","👨🏼‍⚖️","👨🏽‍⚖️","👨🏾‍⚖️","👨🏿‍⚖️","👩‍⚖️","👩🏻‍⚖️","👩🏼‍⚖️","👩🏽‍⚖️","👩🏾‍⚖️","👩🏿‍⚖️","🧑‍🌾","🧑🏻‍🌾","🧑🏼‍🌾","🧑🏽‍🌾","🧑🏾‍🌾","🧑🏿‍🌾","👨‍🌾","👨🏻‍🌾","👨🏼‍🌾","👨🏽‍🌾","👨🏾‍🌾","👨🏿‍🌾","👩‍🌾","👩🏻‍🌾","👩🏼‍🌾","👩🏽‍🌾","👩🏾‍🌾","👩🏿‍🌾","🧑‍🍳","🧑🏻‍🍳","🧑🏼‍🍳","🧑🏽‍🍳","🧑🏾‍🍳","🧑🏿‍🍳","👨‍🍳","👨🏻‍🍳","👨🏼‍🍳","👨🏽‍🍳","👨🏾‍🍳","👨🏿‍🍳","👩‍🍳","👩🏻‍🍳","👩🏼‍🍳","👩🏽‍🍳","👩🏾‍🍳","👩🏿‍🍳","🧑‍🔧","🧑🏻‍🔧","🧑🏼‍🔧","🧑🏽‍🔧","🧑🏾‍🔧","🧑🏿‍🔧","👨‍🔧","👨🏻‍🔧","👨🏼‍🔧","👨🏽‍🔧","👨🏾‍🔧","👨🏿‍🔧","👩‍🔧","👩🏻‍🔧","👩🏼‍🔧","👩🏽‍🔧","👩🏾‍🔧","👩🏿‍🔧","🧑‍🏭","🧑🏻‍🏭","🧑🏼‍🏭","🧑🏽‍🏭","🧑🏾‍🏭","🧑🏿‍🏭","👨‍🏭","👨🏻‍🏭","👨🏼‍🏭","👨🏽‍🏭","👨🏾‍🏭","👨🏿‍🏭","👩‍🏭","👩🏻‍🏭","👩🏼‍🏭","👩🏽‍🏭","👩🏾‍🏭","👩🏿‍🏭","🧑‍💼","🧑🏻‍💼","🧑🏼‍💼","🧑🏽‍💼","🧑🏾‍💼","🧑🏿‍💼","👨‍💼","👨🏻‍💼","👨🏼‍💼","👨🏽‍💼","👨🏾‍💼","👨🏿‍💼","👩‍💼","👩🏻‍💼","👩🏼‍💼","👩🏽‍💼","👩🏾‍💼","👩🏿‍💼","🧑‍🔬","🧑🏻‍🔬","🧑🏼‍🔬","🧑🏽‍🔬","🧑🏾‍🔬","🧑🏿‍🔬","👨‍🔬","👨🏻‍🔬","👨🏼‍🔬","👨🏽‍🔬","👨🏾‍🔬","👨🏿‍🔬","👩‍🔬","👩🏻‍🔬","👩🏼‍🔬","👩🏽‍🔬","👩🏾‍🔬","👩🏿‍🔬","🧑‍💻","🧑🏻‍💻","🧑🏼‍💻","🧑🏽‍💻","🧑🏾‍💻","🧑🏿‍💻","👨‍💻","👨🏻‍💻","👨🏼‍💻","👨🏽‍💻","👨🏾‍💻","👨🏿‍💻","👩‍💻","👩🏻‍💻","👩🏼‍💻","👩🏽‍💻","👩🏾‍💻","👩🏿‍💻","🧑‍🎤","🧑🏻‍🎤","🧑🏼‍🎤","🧑🏽‍🎤","🧑🏾‍🎤","🧑🏿‍🎤","👨‍🎤","👨🏻‍🎤","👨🏼‍🎤","👨🏽‍🎤","👨🏾‍🎤","👨🏿‍🎤","👩‍🎤","👩🏻‍🎤","👩🏼‍🎤","👩🏽‍🎤","👩🏾‍🎤","👩🏿‍🎤","🧑‍🎨","🧑🏻‍🎨","🧑🏼‍🎨","🧑🏽‍🎨","🧑🏾‍🎨","🧑🏿‍🎨","👨‍🎨","👨🏻‍🎨","👨🏼‍🎨","👨🏽‍🎨","👨🏾‍🎨","👨🏿‍🎨","👩‍🎨","👩🏻‍🎨","👩🏼‍🎨","👩🏽‍🎨","👩🏾‍🎨","👩🏿‍🎨","🧑‍✈️","🧑🏻‍✈️","🧑🏼‍✈️","🧑🏽‍✈️","🧑🏾‍✈️","🧑🏿‍✈️","👨‍✈️","👨🏻‍✈️","👨🏼‍✈️","👨🏽‍✈️","👨🏾‍✈️","👨🏿‍✈️","👩‍✈️","👩🏻‍✈️","👩🏼‍✈️","👩🏽‍✈️","👩🏾‍✈️","👩🏿‍✈️","🧑‍🚀","🧑🏻‍🚀","🧑🏼‍🚀","🧑🏽‍🚀","🧑🏾‍🚀","🧑🏿‍🚀","👨‍🚀","👨🏻‍🚀","👨🏼‍🚀","👨🏽‍🚀","👨🏾‍🚀","👨🏿‍🚀","👩‍🚀","👩🏻‍🚀","👩🏼‍🚀","👩🏽‍🚀","👩🏾‍🚀","👩🏿‍🚀","🧑‍🚒","🧑🏻‍🚒","🧑🏼‍🚒","🧑🏽‍🚒","🧑🏾‍🚒","🧑🏿‍🚒","👨‍🚒","👨🏻‍🚒","👨🏼‍🚒","👨🏽‍🚒","👨🏾‍🚒","👨🏿‍🚒","👩‍🚒","👩🏻‍🚒","👩🏼‍🚒","👩🏽‍🚒","👩🏾‍🚒","👩🏿‍🚒","👮‍♂️","👮🏻‍♂️","👮🏼‍♂️","👮🏽‍♂️","👮🏾‍♂️","👮🏿‍♂️","👮‍♀️","👮🏻‍♀️","👮🏼‍♀️","👮🏽‍♀️","👮🏾‍♀️","👮🏿‍♀️","🕵️‍♂️","🕵🏻‍♂️","🕵🏼‍♂️","🕵🏽‍♂️","🕵🏾‍♂️","🕵🏿‍♂️","🕵️‍♀️","🕵🏻‍♀️","🕵🏼‍♀️","🕵🏽‍♀️","🕵🏾‍♀️","🕵🏿‍♀️","💂‍♂️","💂🏻‍♂️","💂🏼‍♂️","💂🏽‍♂️","💂🏾‍♂️","💂🏿‍♂️","💂‍♀️","💂🏻‍♀️","💂🏼‍♀️","💂🏽‍♀️","💂🏾‍♀️","💂🏿‍♀️","👷‍♂️","👷🏻‍♂️","👷🏼‍♂️","👷🏽‍♂️","👷🏾‍♂️","👷🏿‍♂️","👷‍♀️","👷🏻‍♀️","👷🏼‍♀️","👷🏽‍♀️","👷🏾‍♀️","👷🏿‍♀️","👳‍♂️","👳🏻‍♂️","👳🏼‍♂️","👳🏽‍♂️","👳🏾‍♂️","👳🏿‍♂️","👳‍♀️","👳🏻‍♀️","👳🏼‍♀️","👳🏽‍♀️","👳🏾‍♀️","👳🏿‍♀️","🤵‍♂️","🤵🏻‍♂️","🤵🏼‍♂️","🤵🏽‍♂️","🤵🏾‍♂️","🤵🏿‍♂️","🤵‍♀️","🤵🏻‍♀️","🤵🏼‍♀️","🤵🏽‍♀️","🤵🏾‍♀️","🤵🏿‍♀️","👰‍♂️","👰🏻‍♂️","👰🏼‍♂️","👰🏽‍♂️","👰🏾‍♂️","👰🏿‍♂️","👰‍♀️","👰🏻‍♀️","👰🏼‍♀️","👰🏽‍♀️","👰🏾‍♀️","👰🏿‍♀️","👩‍🍼","👩🏻‍🍼","👩🏼‍🍼","👩🏽‍🍼","👩🏾‍🍼","👩🏿‍🍼","👨‍🍼","👨🏻‍🍼","👨🏼‍🍼","👨🏽‍🍼","👨🏾‍🍼","👨🏿‍🍼","🧑‍🍼","🧑🏻‍🍼","🧑🏼‍🍼","🧑🏽‍🍼","🧑🏾‍🍼","🧑🏿‍🍼","🧑‍🎄","🧑🏻‍🎄","🧑🏼‍🎄","🧑🏽‍🎄","🧑🏾‍🎄","🧑🏿‍🎄","🦸‍♂️","🦸🏻‍♂️","🦸🏼‍♂️","🦸🏽‍♂️","🦸🏾‍♂️","🦸🏿‍♂️","🦸‍♀️","🦸🏻‍♀️","🦸🏼‍♀️","🦸🏽‍♀️","🦸🏾‍♀️","🦸🏿‍♀️","🦹‍♂️","🦹🏻‍♂️","🦹🏼‍♂️","🦹🏽‍♂️","🦹🏾‍♂️","🦹🏿‍♂️","🦹‍♀️","🦹🏻‍♀️","🦹🏼‍♀️","🦹🏽‍♀️","🦹🏾‍♀️","🦹🏿‍♀️","🧙‍♂️","🧙🏻‍♂️","🧙🏼‍♂️","🧙🏽‍♂️","🧙🏾‍♂️","🧙🏿‍♂️","🧙‍♀️","🧙🏻‍♀️","🧙🏼‍♀️","🧙🏽‍♀️","🧙🏾‍♀️","🧙🏿‍♀️","🧚‍♂️","🧚🏻‍♂️","🧚🏼‍♂️","🧚🏽‍♂️","🧚🏾‍♂️","🧚🏿‍♂️","🧚‍♀️","🧚🏻‍♀️","🧚🏼‍♀️","🧚🏽‍♀️","🧚🏾‍♀️","🧚🏿‍♀️","🧛‍♂️","🧛🏻‍♂️","🧛🏼‍♂️","🧛🏽‍♂️","🧛🏾‍♂️","🧛🏿‍♂️","🧛‍♀️","🧛🏻‍♀️","🧛🏼‍♀️","🧛🏽‍♀️","🧛🏾‍♀️","🧛🏿‍♀️","🧜‍♂️","🧜🏻‍♂️","🧜🏼‍♂️","🧜🏽‍♂️","🧜🏾‍♂️","🧜🏿‍♂️","🧜‍♀️","🧜🏻‍♀️","🧜🏼‍♀️","🧜🏽‍♀️","🧜🏾‍♀️","🧜🏿‍♀️","🧝‍♂️","🧝🏻‍♂️","🧝🏼‍♂️","🧝🏽‍♂️","🧝🏾‍♂️","🧝🏿‍♂️","🧝‍♀️","🧝🏻‍♀️","🧝🏼‍♀️","🧝🏽‍♀️","🧝🏾‍♀️","🧝🏿‍♀️","🧞‍♂️","🧞‍♀️","🧟‍♂️","🧟‍♀️","💆‍♂️","💆🏻‍♂️","💆🏼‍♂️","💆🏽‍♂️","💆🏾‍♂️","💆🏿‍♂️","💆‍♀️","💆🏻‍♀️","💆🏼‍♀️","💆🏽‍♀️","💆🏾‍♀️","💆🏿‍♀️","💇‍♂️","💇🏻‍♂️","💇🏼‍♂️","💇🏽‍♂️","💇🏾‍♂️","💇🏿‍♂️","💇‍♀️","💇🏻‍♀️","💇🏼‍♀️","💇🏽‍♀️","💇🏾‍♀️","💇🏿‍♀️","🚶‍♂️","🚶🏻‍♂️","🚶🏼‍♂️","🚶🏽‍♂️","🚶🏾‍♂️","🚶🏿‍♂️","🚶‍♀️","🚶🏻‍♀️","🚶🏼‍♀️","🚶🏽‍♀️","🚶🏾‍♀️","🚶🏿‍♀️","🧍‍♂️","🧍🏻‍♂️","🧍🏼‍♂️","🧍🏽‍♂️","🧍🏾‍♂️","🧍🏿‍♂️","🧍‍♀️","🧍🏻‍♀️","🧍🏼‍♀️","🧍🏽‍♀️","🧍🏾‍♀️","🧍🏿‍♀️","🧎‍♂️","🧎🏻‍♂️","🧎🏼‍♂️","🧎🏽‍♂️","🧎🏾‍♂️","🧎🏿‍♂️","🧎‍♀️","🧎🏻‍♀️","🧎🏼‍♀️","🧎🏽‍♀️","🧎🏾‍♀️","🧎🏿‍♀️","🧑‍🦯","🧑🏻‍🦯","🧑🏼‍🦯","🧑🏽‍🦯","🧑🏾‍🦯","🧑🏿‍🦯","👨‍🦯","👨🏻‍🦯","👨🏼‍🦯","👨🏽‍🦯","👨🏾‍🦯","👨🏿‍🦯","👩‍🦯","👩🏻‍🦯","👩🏼‍🦯","👩🏽‍🦯","👩🏾‍🦯","👩🏿‍🦯","🧑‍🦼","🧑🏻‍🦼","🧑🏼‍🦼","🧑🏽‍🦼","🧑🏾‍🦼","🧑🏿‍🦼","👨‍🦼","👨🏻‍🦼","👨🏼‍🦼","👨🏽‍🦼","👨🏾‍🦼","👨🏿‍🦼","👩‍🦼","👩🏻‍🦼","👩🏼‍🦼","👩🏽‍🦼","👩🏾‍🦼","👩🏿‍🦼","🧑‍🦽","🧑🏻‍🦽","🧑🏼‍🦽","🧑🏽‍🦽","🧑🏾‍🦽","🧑🏿‍🦽","👨‍🦽","👨🏻‍🦽","👨🏼‍🦽","👨🏽‍🦽","👨🏾‍🦽","👨🏿‍🦽","👩‍🦽","👩🏻‍🦽","👩🏼‍🦽","👩🏽‍🦽","👩🏾‍🦽","👩🏿‍🦽","🏃‍♂️","🏃🏻‍♂️","🏃🏼‍♂️","🏃🏽‍♂️","🏃🏾‍♂️","🏃🏿‍♂️","🏃‍♀️","🏃🏻‍♀️","🏃🏼‍♀️","🏃🏽‍♀️","🏃🏾‍♀️","🏃🏿‍♀️","👯‍♂️","👯‍♀️","🧖‍♂️","🧖🏻‍♂️","🧖🏼‍♂️","🧖🏽‍♂️","🧖🏾‍♂️","🧖🏿‍♂️","🧖‍♀️","🧖🏻‍♀️","🧖🏼‍♀️","🧖🏽‍♀️","🧖🏾‍♀️","🧖🏿‍♀️","🧗‍♂️","🧗🏻‍♂️","🧗🏼‍♂️","🧗🏽‍♂️","🧗🏾‍♂️","🧗🏿‍♂️","🧗‍♀️","🧗🏻‍♀️","🧗🏼‍♀️","🧗🏽‍♀️","🧗🏾‍♀️","🧗🏿‍♀️","🏌️‍♂️","🏌🏻‍♂️","🏌🏼‍♂️","🏌🏽‍♂️","🏌🏾‍♂️","🏌🏿‍♂️","🏌️‍♀️","🏌🏻‍♀️","🏌🏼‍♀️","🏌🏽‍♀️","🏌🏾‍♀️","🏌🏿‍♀️","🏄‍♂️","🏄🏻‍♂️","🏄🏼‍♂️","🏄🏽‍♂️","🏄🏾‍♂️","🏄🏿‍♂️","🏄‍♀️","🏄🏻‍♀️","🏄🏼‍♀️","🏄🏽‍♀️","🏄🏾‍♀️","🏄🏿‍♀️","🚣‍♂️","🚣🏻‍♂️","🚣🏼‍♂️","🚣🏽‍♂️","🚣🏾‍♂️","🚣🏿‍♂️","🚣‍♀️","🚣🏻‍♀️","🚣🏼‍♀️","🚣🏽‍♀️","🚣🏾‍♀️","🚣🏿‍♀️","🏊‍♂️","🏊🏻‍♂️","🏊🏼‍♂️","🏊🏽‍♂️","🏊🏾‍♂️","🏊🏿‍♂️","🏊‍♀️","🏊🏻‍♀️","🏊🏼‍♀️","🏊🏽‍♀️","🏊🏾‍♀️","🏊🏿‍♀️","⛹️‍♂️","⛹🏻‍♂️","⛹🏼‍♂️","⛹🏽‍♂️","⛹🏾‍♂️","⛹🏿‍♂️","⛹️‍♀️","⛹🏻‍♀️","⛹🏼‍♀️","⛹🏽‍♀️","⛹🏾‍♀️","⛹🏿‍♀️","🏋️‍♂️","🏋🏻‍♂️","🏋🏼‍♂️","🏋🏽‍♂️","🏋🏾‍♂️","🏋🏿‍♂️","🏋️‍♀️","🏋🏻‍♀️","🏋🏼‍♀️","🏋🏽‍♀️","🏋🏾‍♀️","🏋🏿‍♀️","🚴‍♂️","🚴🏻‍♂️","🚴🏼‍♂️","🚴🏽‍♂️","🚴🏾‍♂️","🚴🏿‍♂️","🚴‍♀️","🚴🏻‍♀️","🚴🏼‍♀️","🚴🏽‍♀️","🚴🏾‍♀️","🚴🏿‍♀️","🚵‍♂️","🚵🏻‍♂️","🚵🏼‍♂️","🚵🏽‍♂️","🚵🏾‍♂️","🚵🏿‍♂️","🚵‍♀️","🚵🏻‍♀️","🚵🏼‍♀️","🚵🏽‍♀️","🚵🏾‍♀️","🚵🏿‍♀️","🤸‍♂️","🤸🏻‍♂️","🤸🏼‍♂️","🤸🏽‍♂️","🤸🏾‍♂️","🤸🏿‍♂️","🤸‍♀️","🤸🏻‍♀️","🤸🏼‍♀️","🤸🏽‍♀️","🤸🏾‍♀️","🤸🏿‍♀️","🤼‍♂️","🤼‍♀️","🤽‍♂️","🤽🏻‍♂️","🤽🏼‍♂️","🤽🏽‍♂️","🤽🏾‍♂️","🤽🏿‍♂️","🤽‍♀️","🤽🏻‍♀️","🤽🏼‍♀️","🤽🏽‍♀️","🤽🏾‍♀️","🤽🏿‍♀️","🤾‍♂️","🤾🏻‍♂️","🤾🏼‍♂️","🤾🏽‍♂️","🤾🏾‍♂️","🤾🏿‍♂️","🤾‍♀️","🤾🏻‍♀️","🤾🏼‍♀️","🤾🏽‍♀️","🤾🏾‍♀️","🤾🏿‍♀️","🤹‍♂️","🤹🏻‍♂️","🤹🏼‍♂️","🤹🏽‍♂️","🤹🏾‍♂️","🤹🏿‍♂️","🤹‍♀️","🤹🏻‍♀️","🤹🏼‍♀️","🤹🏽‍♀️","🤹🏾‍♀️","🤹🏿‍♀️","🧘‍♂️","🧘🏻‍♂️","🧘🏼‍♂️","🧘🏽‍♂️","🧘🏾‍♂️","🧘🏿‍♂️","🧘‍♀️","🧘🏻‍♀️","🧘🏼‍♀️","🧘🏽‍♀️","🧘🏾‍♀️","🧘🏿‍♀️","🧑‍🤝‍🧑","🧑🏻‍🤝‍🧑🏻","🧑🏻‍🤝‍🧑🏼","🧑🏻‍🤝‍🧑🏽","🧑🏻‍🤝‍🧑🏾","🧑🏻‍🤝‍🧑🏿","🧑🏼‍🤝‍🧑🏻","🧑🏼‍🤝‍🧑🏼","🧑🏼‍🤝‍🧑🏽","🧑🏼‍🤝‍🧑🏾","🧑🏼‍🤝‍🧑🏿","🧑🏽‍🤝‍🧑🏻","🧑🏽‍🤝‍🧑🏼","🧑🏽‍🤝‍🧑🏽","🧑🏽‍🤝‍🧑🏾","🧑🏽‍🤝‍🧑🏿","🧑🏾‍🤝‍🧑🏻","🧑🏾‍🤝‍🧑🏼","🧑🏾‍🤝‍🧑🏽","🧑🏾‍🤝‍🧑🏾","🧑🏾‍🤝‍🧑🏿","🧑🏿‍🤝‍🧑🏻","🧑🏿‍🤝‍🧑🏼","🧑🏿‍🤝‍🧑🏽","🧑🏿‍🤝‍🧑🏾","🧑🏿‍🤝‍🧑🏿","👩🏻‍🤝‍👩🏼","👩🏻‍🤝‍👩🏽","👩🏻‍🤝‍👩🏾","👩🏻‍🤝‍👩🏿","👩🏼‍🤝‍👩🏻","👩🏼‍🤝‍👩🏽","👩🏼‍🤝‍👩🏾","👩🏼‍🤝‍👩🏿","👩🏽‍🤝‍👩🏻","👩🏽‍🤝‍👩🏼","👩🏽‍🤝‍👩🏾","👩🏽‍🤝‍👩🏿","👩🏾‍🤝‍👩🏻","👩🏾‍🤝‍👩🏼","👩🏾‍🤝‍👩🏽","👩🏾‍🤝‍👩🏿","👩🏿‍🤝‍👩🏻","👩🏿‍🤝‍👩🏼","👩🏿‍🤝‍👩🏽","👩🏿‍🤝‍👩🏾","👩🏻‍🤝‍👨🏼","👩🏻‍🤝‍👨🏽","👩🏻‍🤝‍👨🏾","👩🏻‍🤝‍👨🏿","👩🏼‍🤝‍👨🏻","👩🏼‍🤝‍👨🏽","👩🏼‍🤝‍👨🏾","👩🏼‍🤝‍👨🏿","👩🏽‍🤝‍👨🏻","👩🏽‍🤝‍👨🏼","👩🏽‍🤝‍👨🏾","👩🏽‍🤝‍👨🏿","👩🏾‍🤝‍👨🏻","👩🏾‍🤝‍👨🏼","👩🏾‍🤝‍👨🏽","👩🏾‍🤝‍👨🏿","👩🏿‍🤝‍👨🏻","👩🏿‍🤝‍👨🏼","👩🏿‍🤝‍👨🏽","👩🏿‍🤝‍👨🏾","👨🏻‍🤝‍👨🏼","👨🏻‍🤝‍👨🏽","👨🏻‍🤝‍👨🏾","👨🏻‍🤝‍👨🏿","👨🏼‍🤝‍👨🏻","👨🏼‍🤝‍👨🏽","👨🏼‍🤝‍👨🏾","👨🏼‍🤝‍👨🏿","👨🏽‍🤝‍👨🏻","👨🏽‍🤝‍👨🏼","👨🏽‍🤝‍👨🏾","👨🏽‍🤝‍👨🏿","👨🏾‍🤝‍👨🏻","👨🏾‍🤝‍👨🏼","👨🏾‍🤝‍👨🏽","👨🏾‍🤝‍👨🏿","👨🏿‍🤝‍👨🏻","👨🏿‍🤝‍👨🏼","👨🏿‍🤝‍👨🏽","👨🏿‍🤝‍👨🏾","💏","🧑🏻‍❤️‍💋‍🧑🏼","🧑🏻‍❤️‍💋‍🧑🏽","🧑🏻‍❤️‍💋‍🧑🏾","🧑🏻‍❤️‍💋‍🧑🏿","🧑🏼‍❤️‍💋‍🧑🏻","🧑🏼‍❤️‍💋‍🧑🏽","🧑🏼‍❤️‍💋‍🧑🏾","🧑🏼‍❤️‍💋‍🧑🏿","🧑🏽‍❤️‍💋‍🧑🏻","🧑🏽‍❤️‍💋‍🧑🏼","🧑🏽‍❤️‍💋‍🧑🏾","🧑🏽‍❤️‍💋‍🧑🏿","🧑🏾‍❤️‍💋‍🧑🏻","🧑🏾‍❤️‍💋‍🧑🏼","🧑🏾‍❤️‍💋‍🧑🏽","🧑🏾‍❤️‍💋‍🧑🏿","🧑🏿‍❤️‍💋‍🧑🏻","🧑🏿‍❤️‍💋‍🧑🏼","🧑🏿‍❤️‍💋‍🧑🏽","🧑🏿‍❤️‍💋‍🧑🏾","👩‍❤️‍💋‍👨","👩🏻‍❤️‍💋‍👨🏻","👩🏻‍❤️‍💋‍👨🏼","👩🏻‍❤️‍💋‍👨🏽","👩🏻‍❤️‍💋‍👨🏾","👩🏻‍❤️‍💋‍👨🏿","👩🏼‍❤️‍💋‍👨🏻","👩🏼‍❤️‍💋‍👨🏼","👩🏼‍❤️‍💋‍👨🏽","👩🏼‍❤️‍💋‍👨🏾","👩🏼‍❤️‍💋‍👨🏿","👩🏽‍❤️‍💋‍👨🏻","👩🏽‍❤️‍💋‍👨🏼","👩🏽‍❤️‍💋‍👨🏽","👩🏽‍❤️‍💋‍👨🏾","👩🏽‍❤️‍💋‍👨🏿","👩🏾‍❤️‍💋‍👨🏻","👩🏾‍❤️‍💋‍👨🏼","👩🏾‍❤️‍💋‍👨🏽","👩🏾‍❤️‍💋‍👨🏾","👩🏾‍❤️‍💋‍👨🏿","👩🏿‍❤️‍💋‍👨🏻","👩🏿‍❤️‍💋‍👨🏼","👩🏿‍❤️‍💋‍👨🏽","👩🏿‍❤️‍💋‍👨🏾","👩🏿‍❤️‍💋‍👨🏿","👨‍❤️‍💋‍👨","👨🏻‍❤️‍💋‍👨🏻","👨🏻‍❤️‍💋‍👨🏼","👨🏻‍❤️‍💋‍👨🏽","👨🏻‍❤️‍💋‍👨🏾","👨🏻‍❤️‍💋‍👨🏿","👨🏼‍❤️‍💋‍👨🏻","👨🏼‍❤️‍💋‍👨🏼","👨🏼‍❤️‍💋‍👨🏽","👨🏼‍❤️‍💋‍👨🏾","👨🏼‍❤️‍💋‍👨🏿","👨🏽‍❤️‍💋‍👨🏻","👨🏽‍❤️‍💋‍👨🏼","👨🏽‍❤️‍💋‍👨🏽","👨🏽‍❤️‍💋‍👨🏾","👨🏽‍❤️‍💋‍👨🏿","👨🏾‍❤️‍💋‍👨🏻","👨🏾‍❤️‍💋‍👨🏼","👨🏾‍❤️‍💋‍👨🏽","👨🏾‍❤️‍💋‍👨🏾","👨🏾‍❤️‍💋‍👨🏿","👨🏿‍❤️‍💋‍👨🏻","👨🏿‍❤️‍💋‍👨🏼","👨🏿‍❤️‍💋‍👨🏽","👨🏿‍❤️‍💋‍👨🏾","👨🏿‍❤️‍💋‍👨🏿","👩‍❤️‍💋‍👩","👩🏻‍❤️‍💋‍👩🏻","👩🏻‍❤️‍💋‍👩🏼","👩🏻‍❤️‍💋‍👩🏽","👩🏻‍❤️‍💋‍👩🏾","👩🏻‍❤️‍💋‍👩🏿","👩🏼‍❤️‍💋‍👩🏻","👩🏼‍❤️‍💋‍👩🏼","👩🏼‍❤️‍💋‍👩🏽","👩🏼‍❤️‍💋‍👩🏾","👩🏼‍❤️‍💋‍👩🏿","👩🏽‍❤️‍💋‍👩🏻","👩🏽‍❤️‍💋‍👩🏼","👩🏽‍❤️‍💋‍👩🏽","👩🏽‍❤️‍💋‍👩🏾","👩🏽‍❤️‍💋‍👩🏿","👩🏾‍❤️‍💋‍👩🏻","👩🏾‍❤️‍💋‍👩🏼","👩🏾‍❤️‍💋‍👩🏽","👩🏾‍❤️‍💋‍👩🏾","👩🏾‍❤️‍💋‍👩🏿","👩🏿‍❤️‍💋‍👩🏻","👩🏿‍❤️‍💋‍👩🏼","👩🏿‍❤️‍💋‍👩🏽","👩🏿‍❤️‍💋‍👩🏾","👩🏿‍❤️‍💋‍👩🏿","💑","🧑🏻‍❤️‍🧑🏼","🧑🏻‍❤️‍🧑🏽","🧑🏻‍❤️‍🧑🏾","🧑🏻‍❤️‍🧑🏿","🧑🏼‍❤️‍🧑🏻","🧑🏼‍❤️‍🧑🏽","🧑🏼‍❤️‍🧑🏾","🧑🏼‍❤️‍🧑🏿","🧑🏽‍❤️‍🧑🏻","🧑🏽‍❤️‍🧑🏼","🧑🏽‍❤️‍🧑🏾","🧑🏽‍❤️‍🧑🏿","🧑🏾‍❤️‍🧑🏻","🧑🏾‍❤️‍🧑🏼","🧑🏾‍❤️‍🧑🏽","🧑🏾‍❤️‍🧑🏿","🧑🏿‍❤️‍🧑🏻","🧑🏿‍❤️‍🧑🏼","🧑🏿‍❤️‍🧑🏽","🧑🏿‍❤️‍🧑🏾","👩‍❤️‍👨","👩🏻‍❤️‍👨🏻","👩🏻‍❤️‍👨🏼","👩🏻‍❤️‍👨🏽","👩🏻‍❤️‍👨🏾","👩🏻‍❤️‍👨🏿","👩🏼‍❤️‍👨🏻","👩🏼‍❤️‍👨🏼","👩🏼‍❤️‍👨🏽","👩🏼‍❤️‍👨🏾","👩🏼‍❤️‍👨🏿","👩🏽‍❤️‍👨🏻","👩🏽‍❤️‍👨🏼","👩🏽‍❤️‍👨🏽","👩🏽‍❤️‍👨🏾","👩🏽‍❤️‍👨🏿","👩🏾‍❤️‍👨🏻","👩🏾‍❤️‍👨🏼","👩🏾‍❤️‍👨🏽","👩🏾‍❤️‍👨🏾","👩🏾‍❤️‍👨🏿","👩🏿‍❤️‍👨🏻","👩🏿‍❤️‍👨🏼","👩🏿‍❤️‍👨🏽","👩🏿‍❤️‍👨🏾","👩🏿‍❤️‍👨🏿","👨‍❤️‍👨","👨🏻‍❤️‍👨🏻","👨🏻‍❤️‍👨🏼","👨🏻‍❤️‍👨🏽","👨🏻‍❤️‍👨🏾","👨🏻‍❤️‍👨🏿","👨🏼‍❤️‍👨🏻","👨🏼‍❤️‍👨🏼","👨🏼‍❤️‍👨🏽","👨🏼‍❤️‍👨🏾","👨🏼‍❤️‍👨🏿","👨🏽‍❤️‍👨🏻","👨🏽‍❤️‍👨🏼","👨🏽‍❤️‍👨🏽","👨🏽‍❤️‍👨🏾","👨🏽‍❤️‍👨🏿","👨🏾‍❤️‍👨🏻","👨🏾‍❤️‍👨🏼","👨🏾‍❤️‍👨🏽","👨🏾‍❤️‍👨🏾","👨🏾‍❤️‍👨🏿","👨🏿‍❤️‍👨🏻","👨🏿‍❤️‍👨🏼","👨🏿‍❤️‍👨🏽","👨🏿‍❤️‍👨🏾","👨🏿‍❤️‍👨🏿","👩‍❤️‍👩","👩🏻‍❤️‍👩🏻","👩🏻‍❤️‍👩🏼","👩🏻‍❤️‍👩🏽","👩🏻‍❤️‍👩🏾","👩🏻‍❤️‍👩🏿","👩🏼‍❤️‍👩🏻","👩🏼‍❤️‍👩🏼","👩🏼‍❤️‍👩🏽","👩🏼‍❤️‍👩🏾","👩🏼‍❤️‍👩🏿","👩🏽‍❤️‍👩🏻","👩🏽‍❤️‍👩🏼","👩🏽‍❤️‍👩🏽","👩🏽‍❤️‍👩🏾","👩🏽‍❤️‍👩🏿","👩🏾‍❤️‍👩🏻","👩🏾‍❤️‍👩🏼","👩🏾‍❤️‍👩🏽","👩🏾‍❤️‍👩🏾","👩🏾‍❤️‍👩🏿","👩🏿‍❤️‍👩🏻","👩🏿‍❤️‍👩🏼","👩🏿‍❤️‍👩🏽","👩🏿‍❤️‍👩🏾","👩🏿‍❤️‍👩🏿","👪","👨‍👩‍👦","👨‍👩‍👧","👨‍👩‍👧‍👦","👨‍👩‍👦‍👦","👨‍👩‍👧‍👧","👨‍👨‍👦","👨‍👨‍👧","👨‍👨‍👧‍👦","👨‍👨‍👦‍👦","👨‍👨‍👧‍👧","👩‍👩‍👦","👩‍👩‍👧","👩‍👩‍👧‍👦","👩‍👩‍👦‍👦","👩‍👩‍👧‍👧","👨‍👦","👨‍👦‍👦","👨‍👧","👨‍👧‍👦","👨‍👧‍👧","👩‍👦","👩‍👦‍👦","👩‍👧","👩‍👧‍👦","👩‍👧‍👧","🐕‍🦺","🐈‍⬛","🐻‍❄️","🏳️‍🌈","🏳️‍⚧️","🏴‍☠️"],"expected":[{"id":"0","event":null,"data":"Blåbæ\nrsyltetøy må da være mulig å oppdrive? 😶‍🌫️ 😮‍💨 😵‍💫 ❤️‍🔥 ❤️‍🩹 👁️‍🗨️ 🫱🏻‍🫲🏼 🫱🏻‍🫲🏽 🫱🏻‍🫲🏾 🫱🏻‍🫲🏿 🫱🏼‍🫲🏻 🫱🏼‍🫲🏽 🫱🏼‍🫲🏾 🫱🏼‍🫲🏿 🫱🏽‍🫲🏻 🫱🏽‍🫲🏼 🫱🏽‍🫲🏾 🫱🏽‍🫲🏿 🫱🏾‍🫲🏻 🫱🏾‍🫲🏼 🫱🏾‍🫲🏽 🫱🏾‍🫲🏿 🫱🏿‍🫲🏻 🫱🏿‍🫲🏼 🫱🏿‍🫲🏽 🫱🏿‍🫲🏾 🧔‍♂️ 🧔🏻‍♂️ 🧔🏼‍♂️ 🧔🏽‍♂️ 🧔🏾‍♂️ 🧔🏿‍♂️ 🧔‍♀️ 🧔🏻‍♀️ 🧔🏼‍♀️ 🧔🏽‍♀️ 🧔🏾‍♀️ 🧔🏿‍♀️ 👨‍🦰 👨🏻‍🦰 👨🏼‍🦰 👨🏽‍🦰 👨🏾‍🦰 👨🏿‍🦰 👨‍🦱 👨🏻‍🦱 👨🏼‍🦱 👨🏽‍🦱 👨🏾‍🦱 👨🏿‍🦱 👨‍🦳 👨🏻‍🦳 👨🏼‍🦳 👨🏽‍🦳 👨🏾‍🦳 👨🏿‍🦳 👨‍🦲 👨🏻‍🦲 👨🏼‍🦲 👨🏽‍🦲 👨🏾‍🦲 👨🏿‍🦲 👩‍🦰 👩🏻‍🦰 👩🏼‍🦰 👩🏽‍🦰 👩🏾‍🦰 👩🏿‍🦰 🧑‍🦰 🧑🏻‍🦰 🧑🏼‍🦰 🧑🏽‍🦰 🧑🏾‍🦰 🧑🏿‍🦰 👩‍🦱 👩🏻‍🦱 👩🏼‍🦱 👩🏽‍🦱 👩🏾‍🦱 👩🏿‍🦱 🧑‍🦱 🧑🏻‍🦱 🧑🏼‍🦱 🧑🏽‍🦱 🧑🏾‍🦱 🧑🏿‍🦱 👩‍🦳 👩🏻‍🦳 👩🏼‍🦳 👩🏽‍🦳 👩🏾‍🦳 👩🏿‍🦳 🧑‍🦳 🧑🏻‍🦳 🧑🏼‍🦳 🧑🏽‍🦳 🧑🏾‍🦳"},{"id":"1","event":null,"data":"በእርግጥ አንድ ሰው ብሉቤሪ ጃምን መግዛት መቻል አለበት? 🧑🏿‍🦳 👩‍🦲 👩🏻‍🦲 👩🏼‍🦲 👩🏽‍🦲 👩🏾‍🦲 👩🏿‍🦲 🧑‍🦲 🧑🏻‍🦲 🧑🏼‍🦲 🧑🏽‍🦲 🧑🏾‍🦲 🧑🏿‍🦲 👱‍♀️ 👱🏻‍♀️ 👱🏼‍♀️ 👱🏽‍♀️ 👱🏾‍♀️ 👱🏿‍♀️ 👱‍♂️ 👱🏻‍♂️ 👱🏼‍♂️ 👱🏽‍♂️ 👱🏾‍♂️ 👱🏿‍♂️ 🙍‍♂️ 🙍🏻‍♂️ 🙍🏼‍♂️ 🙍🏽‍♂️ 🙍🏾‍♂️ 🙍🏿‍♂️ 🙍‍♀️ 🙍🏻‍♀️ 🙍🏼‍♀️ 🙍🏽‍♀️ 🙍🏾‍♀️ 🙍🏿‍♀️ 🙎‍♂️ 🙎🏻‍♂️ 🙎🏼‍♂️ 🙎🏽‍♂️ 🙎🏾‍♂️ 🙎🏿‍♂️ 🙎‍♀️ 🙎🏻‍♀️ 🙎🏼‍♀️ 🙎🏽‍♀️ 🙎🏾‍♀️ 🙎🏿‍♀️ 🙅‍♂️ 🙅🏻‍♂️ 🙅🏼‍♂️ 🙅🏽‍♂️ 🙅🏾‍♂️ 🙅🏿‍♂️ 🙅‍♀️ 🙅🏻‍♀️ 🙅🏼‍♀️ 🙅🏽‍♀️ 🙅🏾‍♀️ 🙅🏿‍♀️ 🙆‍♂️ 🙆🏻‍♂️ 🙆🏼‍♂️ 🙆🏽‍♂️ 🙆🏾‍♂️ 🙆🏿‍♂️ 🙆‍♀️ 🙆🏻‍♀️ 🙆🏼‍♀️ 🙆🏽‍♀️ 🙆🏾‍♀️ 🙆🏿‍♀️ 💁‍♂️ 💁🏻‍♂️ 💁🏼‍♂️ 💁🏽‍♂️ 💁🏾‍♂️ 💁🏿‍♂️ 💁‍♀️ 💁🏻‍♀️ 💁🏼‍♀️ 💁🏽‍♀️ 💁🏾‍♀️ 💁🏿‍♀️ 🙋‍♂️ 🙋🏻‍♂️ 🙋🏼‍♂️ 🙋🏽‍♂️ 🙋🏾‍♂️ 🙋🏿‍♂️ 🙋‍♀️ 🙋🏻‍♀️ 🙋🏼‍♀️ 🙋🏽‍♀️ 🙋🏾‍♀️ 🙋🏿‍♀️"},{"id":"2","event":null,"data":"بالتأ\nكيد يجب أن يكون المرء قادرا على شراء مربى التوت؟ 🧏‍♂️ 🧏🏻‍♂️ 🧏🏼‍♂️ 🧏🏽‍♂️ 🧏🏾‍♂️ 🧏🏿‍♂️ 🧏‍♀️ 🧏🏻‍♀️ 🧏🏼‍♀️ 🧏🏽‍♀️ 🧏🏾‍♀️ 🧏🏿‍♀️ 🙇‍♂️ 🙇🏻‍♂️ 🙇🏼‍♂️ 🙇🏽‍♂️ 🙇🏾‍♂️ 🙇🏿‍♂️ 🙇‍♀️ 🙇🏻‍♀️ 🙇🏼‍♀️ 🙇🏽‍♀️ 🙇🏾‍♀️ 🙇🏿‍♀️ 🤦‍♂️ 🤦🏻‍♂️ 🤦🏼‍♂️ 🤦🏽‍♂️ 🤦🏾‍♂️ 🤦🏿‍♂️ 🤦‍♀️ 🤦🏻‍♀️ 🤦🏼‍♀️ 🤦🏽‍♀️ 🤦🏾‍♀️ 🤦🏿‍♀️ 🤷‍♂️ 🤷🏻‍♂️ 🤷🏼‍♂️ 🤷🏽‍♂️ 🤷🏾‍♂️ 🤷🏿‍♂️ 🤷‍♀️ 🤷🏻‍♀️ 🤷🏼‍♀️ 🤷🏽‍♀️ 🤷🏾‍♀️ 🤷🏿‍♀️ 🧑‍⚕️ 🧑🏻‍⚕️ 🧑🏼‍⚕️ 🧑🏽‍⚕️ 🧑🏾‍⚕️ 🧑🏿‍⚕️ 👨‍⚕️ 👨🏻‍⚕️ 👨🏼‍⚕️ 👨🏽‍⚕️ 👨🏾‍⚕️ 👨🏿‍⚕️ 👩‍⚕️ 👩🏻‍⚕️ 👩🏼‍⚕️ 👩🏽‍⚕️ 👩🏾‍⚕️ 👩🏿‍⚕️ 🧑‍🎓 🧑🏻‍🎓 🧑🏼‍🎓 🧑🏽‍🎓 🧑🏾‍🎓 🧑🏿‍🎓 👨‍🎓 👨🏻‍🎓 👨🏼‍🎓 👨🏽‍🎓 👨🏾‍🎓 👨🏿‍🎓 👩‍🎓 👩🏻‍🎓 👩🏼‍🎓 👩🏽‍🎓 👩🏾‍🎓 👩🏿‍🎓 🧑‍🏫 🧑🏻‍🏫 🧑🏼‍🏫 🧑🏽‍🏫 🧑🏾‍🏫 🧑🏿‍🏫 👨‍🏫 👨🏻‍🏫 👨🏼‍🏫 👨🏽‍🏫 👨🏾‍🏫 👨🏿‍🏫 👩‍🏫"},{"id":"3","event":null,"data":"一定能买到蓝莓果酱吗? 👩🏻‍🏫 👩🏼‍🏫 👩🏽‍🏫 👩🏾‍🏫 👩🏿‍🏫 🧑‍⚖️ 🧑🏻‍⚖️ 🧑🏼‍⚖️ 🧑🏽‍⚖️ 🧑🏾‍⚖️ 🧑🏿‍⚖️ 👨‍⚖️ 👨🏻‍⚖️ 👨🏼‍⚖️ 👨🏽‍⚖️ 👨🏾‍⚖️ 👨🏿‍⚖️ 👩‍⚖️ 👩🏻‍⚖️ 👩🏼‍⚖️ 👩🏽‍⚖️ 👩🏾‍⚖️ 👩🏿‍⚖️ 🧑‍🌾 🧑🏻‍🌾 🧑🏼‍🌾 🧑🏽‍🌾 🧑🏾‍🌾 🧑🏿‍🌾 👨‍🌾 👨🏻‍🌾 👨🏼‍🌾 👨🏽‍🌾 👨🏾‍🌾 👨🏿‍🌾 👩‍🌾 👩🏻‍🌾 👩🏼‍🌾 👩🏽‍🌾 👩🏾‍🌾 👩🏿‍🌾 🧑‍🍳 🧑🏻‍🍳 🧑🏼‍🍳 🧑🏽‍🍳 🧑🏾‍🍳 🧑🏿‍🍳 👨‍🍳 👨🏻‍🍳 👨🏼‍🍳 👨🏽‍🍳 👨🏾‍🍳 👨🏿‍🍳 👩‍🍳 👩🏻‍🍳 👩🏼‍🍳 👩🏽‍🍳 👩🏾‍🍳 👩🏿‍🍳 🧑‍🔧 🧑🏻‍🔧 🧑🏼‍🔧 🧑🏽‍🔧 🧑🏾‍🔧 🧑🏿‍🔧 👨‍🔧 👨🏻‍🔧 👨🏼‍🔧 👨🏽‍🔧 👨🏾‍🔧 👨🏿‍🔧 👩‍🔧 👩🏻‍🔧 👩🏼‍🔧 👩🏽‍🔧 👩🏾‍🔧 👩🏿‍🔧 🧑‍🏭 🧑🏻‍🏭 🧑🏼‍🏭 🧑🏽‍🏭 🧑🏾‍🏭 🧑🏿‍🏭 👨‍🏭 👨🏻‍🏭 👨🏼‍🏭 👨🏽‍🏭 👨🏾‍🏭 👨🏿‍🏭 👩‍🏭 👩🏻‍🏭 👩🏼‍🏭 👩🏽‍🏭 👩🏾‍🏭 👩🏿‍🏭 🧑‍💼 🧑🏻‍💼"},{"id":"4","event":null,"data":"ચોક્ક\nસ એક બ્લુબેરી જામ મેળવવા માટે સમર્થ હોવા જ જોઈએ? 🧑🏼‍💼 🧑🏽‍💼 🧑🏾‍💼 🧑🏿‍💼 👨‍💼 👨🏻‍💼 👨🏼‍💼 👨🏽‍💼 👨🏾‍💼 👨🏿‍💼 👩‍💼 👩🏻‍💼 👩🏼‍💼 👩🏽‍💼 👩🏾‍💼 👩🏿‍💼 🧑‍🔬 🧑🏻‍🔬 🧑🏼‍🔬 🧑🏽‍🔬 🧑🏾‍🔬 🧑🏿‍🔬 👨‍🔬 👨🏻‍🔬 👨🏼‍🔬 👨🏽‍🔬 👨🏾‍🔬 👨🏿‍🔬 👩‍🔬 👩🏻‍🔬 👩🏼‍🔬 👩🏽‍🔬 👩🏾‍🔬 👩🏿‍🔬 🧑‍💻 🧑🏻‍💻 🧑🏼‍💻 🧑🏽‍💻 🧑🏾‍💻 🧑🏿‍💻 👨‍💻 👨🏻‍💻 👨🏼‍💻 👨🏽‍💻 👨🏾‍💻 👨🏿‍💻 👩‍💻 👩🏻‍💻 👩🏼‍💻 👩🏽‍💻 👩🏾‍💻 👩🏿‍💻 🧑‍🎤 🧑🏻‍🎤 🧑🏼‍🎤 🧑🏽‍🎤 🧑🏾‍🎤 🧑🏿‍🎤 👨‍🎤 👨🏻‍🎤 👨🏼‍🎤 👨🏽‍🎤 👨🏾‍🎤 👨🏿‍🎤 👩‍🎤 👩🏻‍🎤 👩🏼‍🎤 👩🏽‍🎤 👩🏾‍🎤 👩🏿‍🎤 🧑‍🎨 🧑🏻‍🎨 🧑🏼‍🎨 🧑🏽‍🎨 🧑🏾‍🎨 🧑🏿‍🎨 👨‍🎨 👨🏻‍🎨 👨🏼‍🎨 👨🏽‍🎨 👨🏾‍🎨 👨🏿‍🎨 👩‍🎨 👩🏻‍🎨 👩🏼‍🎨 👩🏽‍🎨 👩🏾‍🎨 👩🏿‍🎨 🧑‍✈️ 🧑🏻‍✈️ 🧑🏼‍✈️ 🧑🏽‍✈️ 🧑🏾‍✈️ 🧑🏿‍✈️ 👨‍✈️ 👨🏻‍✈️ 👨🏼‍✈️"},{"id":"5","event":null,"data":"בטח אפשר להשיג ריבת אוכמניות? 👨🏽‍✈️ 👨🏾‍✈️ 👨🏿‍✈️ 👩‍✈️ 👩🏻‍✈️ 👩🏼‍✈️ 👩🏽‍✈️ 👩🏾‍✈️ 👩🏿‍✈️ 🧑‍🚀 🧑🏻‍🚀 🧑🏼‍🚀 🧑🏽‍🚀 🧑🏾‍🚀 🧑🏿‍🚀 👨‍🚀 👨🏻‍🚀 👨🏼‍🚀 👨🏽‍🚀 👨🏾‍🚀 👨🏿‍🚀 👩‍🚀 👩🏻‍🚀 👩🏼‍🚀 👩🏽‍🚀 👩🏾‍🚀 👩🏿‍🚀 🧑‍🚒 🧑🏻‍🚒 🧑🏼‍🚒 🧑🏽‍🚒 🧑🏾‍🚒 🧑🏿‍🚒 👨‍🚒 👨🏻‍🚒 👨🏼‍🚒 👨🏽‍🚒 👨🏾‍🚒 👨🏿‍🚒 👩‍🚒 👩🏻‍🚒 👩🏼‍🚒 👩🏽‍🚒 👩🏾‍🚒 👩🏿‍🚒 👮‍♂️ 👮🏻‍♂️ 👮🏼‍♂️ 👮🏽‍♂️ 👮🏾‍♂️ 👮🏿‍♂️ 👮‍♀️ 👮🏻‍♀️ 👮🏼‍♀️ 👮🏽‍♀️ 👮🏾‍♀️ 👮🏿‍♀️ 🕵️‍♂️ 🕵🏻‍♂️ 🕵🏼‍♂️ 🕵🏽‍♂️ 🕵🏾‍♂️ 🕵🏿‍♂️ 🕵️‍♀️ 🕵🏻‍♀️ 🕵🏼‍♀️ 🕵🏽‍♀️ 🕵🏾‍♀️ 🕵🏿‍♀️ 💂‍♂️ 💂🏻‍♂️ 💂🏼‍♂️ 💂🏽‍♂️ 💂🏾‍♂️ 💂🏿‍♂️ 💂‍♀️ 💂🏻‍♀️ 💂🏼‍♀️ 💂🏽‍♀️ 💂🏾‍♀️ 💂🏿‍♀️ 👷‍♂️ 👷🏻‍♂️ 👷🏼‍♂️ 👷🏽‍♂️ 👷🏾‍♂️ 👷🏿‍♂️ 👷‍♀️ 👷🏻‍♀️ 👷🏼‍♀️ 👷🏽‍♀️ 👷🏾‍♀️ 👷🏿‍♀️ 👳‍♂️ 👳🏻‍♂️ 👳🏼‍♂️ 👳🏽‍♂️"},{"id":"6","event":null,"data":"निश्च\nित रूप से किसी को ब्लूबेरी जैम खरीदने में सक्षम होना चाहिए? 👳🏾‍♂️ 👳🏿‍♂️ 👳‍♀️ 👳🏻‍♀️ 👳🏼‍♀️ 👳🏽‍♀️ 👳🏾‍♀️ 👳🏿‍♀️ 🤵‍♂️ 🤵🏻‍♂️ 🤵🏼‍♂️ 🤵🏽‍♂️ 🤵🏾‍♂️ 🤵🏿‍♂️ 🤵‍♀️ 🤵🏻‍♀️ 🤵🏼‍♀️ 🤵🏽‍♀️ 🤵🏾‍♀️ 🤵🏿‍♀️ 👰‍♂️ 👰🏻‍♂️ 👰🏼‍♂️ 👰🏽‍♂️ 👰🏾‍♂️ 👰🏿‍♂️ 👰‍♀️ 👰🏻‍♀️ 👰🏼‍♀️ 👰🏽‍♀️ 👰🏾‍♀️ 👰🏿‍♀️ 👩‍🍼 👩🏻‍🍼 👩🏼‍🍼 👩🏽‍🍼 👩🏾‍🍼 👩🏿‍🍼 👨‍🍼 👨🏻‍🍼 👨🏼‍🍼 👨🏽‍🍼 👨🏾‍🍼 👨🏿‍🍼 🧑‍🍼 🧑🏻‍🍼 🧑🏼‍🍼 🧑🏽‍🍼 🧑🏾‍🍼 🧑🏿‍🍼 🧑‍🎄 🧑🏻‍🎄 🧑🏼‍🎄 🧑🏽‍🎄 🧑🏾‍🎄 🧑🏿‍🎄 🦸‍♂️ 🦸🏻‍♂️ 🦸🏼‍♂️ 🦸🏽‍♂️ 🦸🏾‍♂️ 🦸🏿‍♂️ 🦸‍♀️ 🦸🏻‍♀️ 🦸🏼‍♀️ 🦸🏽‍♀️ 🦸🏾‍♀️ 🦸🏿‍♀️ 🦹‍♂️ 🦹🏻‍♂️ 🦹🏼‍♂️ 🦹🏽‍♂️ 🦹🏾‍♂️ 🦹🏿‍♂️ 🦹‍♀️ 🦹🏻‍♀️ 🦹🏼‍♀️ 🦹🏽‍♀️ 🦹🏾‍♀️ 🦹🏿‍♀️ 🧙‍♂️ 🧙🏻‍♂️ 🧙🏼‍♂️ 🧙🏽‍♂️ 🧙🏾‍♂️ 🧙🏿‍♂️ 🧙‍♀️ 🧙🏻‍♀️ 🧙🏼‍♀️ 🧙🏽‍♀️ 🧙🏾‍♀️ 🧙🏿‍♀️ 🧚‍♂️ 🧚🏻‍♂️ 🧚🏼‍♂️ 🧚🏽‍♂️ 🧚🏾‍♂️"},{"id":"7","event":null,"data":"Bê guman pêdivî ye ku meriv karibe jama şînê peyda bike? 🧚🏿‍♂️ 🧚‍♀️ 🧚🏻‍♀️ 🧚🏼‍♀️ 🧚🏽‍♀️ 🧚🏾‍♀️ 🧚🏿‍♀️ 🧛‍♂️ 🧛🏻‍♂️ 🧛🏼‍♂️ 🧛🏽‍♂️ 🧛🏾‍♂️ 🧛🏿‍♂️ 🧛‍♀️ 🧛🏻‍♀️ 🧛🏼‍♀️ 🧛🏽‍♀️ 🧛🏾‍♀️ 🧛🏿‍♀️ 🧜‍♂️ 🧜🏻‍♂️ 🧜🏼‍♂️ 🧜🏽‍♂️ 🧜🏾‍♂️ 🧜🏿‍♂️ 🧜‍♀️ 🧜🏻‍♀️ 🧜🏼‍♀️ 🧜🏽‍♀️ 🧜🏾‍♀️ 🧜🏿‍♀️ 🧝‍♂️ 🧝🏻‍♂️ 🧝🏼‍♂️ 🧝🏽‍♂️ 🧝🏾‍♂️ 🧝🏿‍♂️ 🧝‍♀️ 🧝🏻‍♀️ 🧝🏼‍♀️ 🧝🏽‍♀️ 🧝🏾‍♀️ 🧝🏿‍♀️ 🧞‍♂️ 🧞‍♀️ 🧟‍♂️ 🧟‍♀️ 💆‍♂️ 💆🏻‍♂️ 💆🏼‍♂️ 💆🏽‍♂️ 💆🏾‍♂️ 💆🏿‍♂️ 💆‍♀️ 💆🏻‍♀️ 💆🏼‍♀️ 💆🏽‍♀️ 💆🏾‍♀️ 💆🏿‍♀️ 💇‍♂️ 💇🏻‍♂️ 💇🏼‍♂️ 💇🏽‍♂️ 💇🏾‍♂️ 💇🏿‍♂️ 💇‍♀️ 💇🏻‍♀️ 💇🏼‍♀️ 💇🏽‍♀️ 💇🏾‍♀️ 💇🏿‍♀️ 🚶‍♂️ 🚶🏻‍♂️ 🚶🏼‍♂️ 🚶🏽‍♂️ 🚶🏾‍♂️ 🚶🏿‍♂️ 🚶‍♀️ 🚶🏻‍♀️ 🚶🏼‍♀️ 🚶🏽‍♀️ 🚶🏾‍♀️ 🚶🏿‍♀️ 🧍‍♂️ 🧍🏻‍♂️ 🧍🏼‍♂️ 🧍🏽‍♂️ 🧍🏾‍♂️ 🧍🏿‍♂️ 🧍‍♀️ 🧍🏻‍♀️ 🧍🏼‍♀️ 🧍🏽‍♀️ 🧍🏾‍♀️ 🧍🏿‍♀️ 🧎‍♂️ 🧎🏻‍♂️"},{"id":"8","event":null,"data":"ប្រាក\nដណាស់ មនុស្សម្នាក់ត្រូវតែអាចទិញយៈសាពូនមី blueberry បានទេ? 🧎🏼‍♂️ 🧎🏽‍♂️ 🧎🏾‍♂️ 🧎🏿‍♂️ 🧎‍♀️ 🧎🏻‍♀️ 🧎🏼‍♀️ 🧎🏽‍♀️ 🧎🏾‍♀️ 🧎🏿‍♀️ 🧑‍🦯 🧑🏻‍🦯 🧑🏼‍🦯 🧑🏽‍🦯 🧑🏾‍🦯 🧑🏿‍🦯 👨‍🦯 👨🏻‍🦯 👨🏼‍🦯 👨🏽‍🦯 👨🏾‍🦯 👨🏿‍🦯 👩‍🦯 👩🏻‍🦯 👩🏼‍🦯 👩🏽‍🦯 👩🏾‍🦯 👩🏿‍🦯 🧑‍🦼 🧑🏻‍🦼 🧑🏼‍🦼 🧑🏽‍🦼 🧑🏾‍🦼 🧑🏿‍🦼 👨‍🦼 👨🏻‍🦼 👨🏼‍🦼 👨🏽‍🦼 👨🏾‍🦼 👨🏿‍🦼 👩‍🦼 👩🏻‍🦼 👩🏼‍🦼 👩🏽‍🦼 👩🏾‍🦼 👩🏿‍🦼 🧑‍🦽 🧑🏻‍🦽 🧑🏼‍🦽 🧑🏽‍🦽 🧑🏾‍🦽 🧑🏿‍🦽 👨‍🦽 👨🏻‍🦽 👨🏼‍🦽 👨🏽‍🦽 👨🏾‍🦽 👨🏿‍🦽 👩‍🦽 👩🏻‍🦽 👩🏼‍🦽 👩🏽‍🦽 👩🏾‍🦽 👩🏿‍🦽 🏃‍♂️ 🏃🏻‍♂️ 🏃🏼‍♂️ 🏃🏽‍♂️ 🏃🏾‍♂️ 🏃🏿‍♂️ 🏃‍♀️ 🏃🏻‍♀️ 🏃🏼‍♀️ 🏃🏽‍♀️ 🏃🏾‍♀️ 🏃🏿‍♀️ 👯‍♂️ 👯‍♀️ 🧖‍♂️ 🧖🏻‍♂️ 🧖🏼‍♂️ 🧖🏽‍♂️ 🧖🏾‍♂️ 🧖🏿‍♂️ 🧖‍♀️ 🧖🏻‍♀️ 🧖🏼‍♀️ 🧖🏽‍♀️ 🧖🏾‍♀️ 🧖🏿‍♀️ 🧗‍♂️ 🧗🏻‍♂️ 🧗🏼‍♂️ 🧗🏽‍♂️ 🧗🏾‍♂️ 🧗🏿‍♂️ 🧗‍♀️"},{"id":"9","event":null,"data":"ಖಂಡಿತವಾಗಿಯೂ ಒಬ್ಬರು ಬ್ಲೂಬೆರ್ರಿ ಜಾಮ್ ಅನ್ನು ಸಂಗ್ರಹಿಸಲು ಶಕ್ತರಾಗಿರಬೇಕು? 🧗🏻‍♀️ 🧗🏼‍♀️ 🧗🏽‍♀️ 🧗🏾‍♀️ 🧗🏿‍♀️ 🏌️‍♂️ 🏌🏻‍♂️ 🏌🏼‍♂️ 🏌🏽‍♂️ 🏌🏾‍♂️ 🏌🏿‍♂️ 🏌️‍♀️ 🏌🏻‍♀️ 🏌🏼‍♀️ 🏌🏽‍♀️ 🏌🏾‍♀️ 🏌🏿‍♀️ 🏄‍♂️ 🏄🏻‍♂️ 🏄🏼‍♂️ 🏄🏽‍♂️ 🏄🏾‍♂️ 🏄🏿‍♂️ 🏄‍♀️ 🏄🏻‍♀️ 🏄🏼‍♀️ 🏄🏽‍♀️ 🏄🏾‍♀️ 🏄🏿‍♀️ 🚣‍♂️ 🚣🏻‍♂️ 🚣🏼‍♂️ 🚣🏽‍♂️ 🚣🏾‍♂️ 🚣🏿‍♂️ 🚣‍♀️ 🚣🏻‍♀️ 🚣🏼‍♀️ 🚣🏽‍♀️ 🚣🏾‍♀️ 🚣🏿‍♀️ 🏊‍♂️ 🏊🏻‍♂️ 🏊🏼‍♂️ 🏊🏽‍♂️ 🏊🏾‍♂️ 🏊🏿‍♂️ 🏊‍♀️ 🏊🏻‍♀️ 🏊🏼‍♀️ 🏊🏽‍♀️ 🏊🏾‍♀️ 🏊🏿‍♀️ ⛹️‍♂️ ⛹🏻‍♂️ ⛹🏼‍♂️ ⛹🏽‍♂️ ⛹🏾‍♂️ ⛹🏿‍♂️ ⛹️‍♀️ ⛹🏻‍♀️ ⛹🏼‍♀️ ⛹🏽‍♀️ ⛹🏾‍♀️ ⛹🏿‍♀️ 🏋️‍♂️ 🏋🏻‍♂️ 🏋🏼‍♂️ 🏋🏽‍♂️ 🏋🏾‍♂️ 🏋🏿‍♂️ 🏋️‍♀️ 🏋🏻‍♀️ 🏋🏼‍♀️ 🏋🏽‍♀️ 🏋🏾‍♀️ 🏋🏿‍♀️ 🚴‍♂️ 🚴🏻‍♂️ 🚴🏼‍♂️ 🚴🏽‍♂️ 🚴🏾‍♂️ 🚴🏿‍♂️ 🚴‍♀️ 🚴🏻‍♀️ 🚴🏼‍♀️ 🚴🏽‍♀️ 🚴🏾‍♀️ 🚴🏿‍♀️ 🚵‍♂️ 🚵🏻‍♂️ 🚵🏼‍♂️ 🚵🏽‍♂️ 🚵🏾‍♂️ 🚵🏿‍♂️ 🚵‍♀️ 🚵🏻‍♀️"},{"id":"10","event":null,"data":"確かにブル\nーベリージャムを調達できなければなりませんか? 🚵🏼‍♀️ 🚵🏽‍♀️ 🚵🏾‍♀️ 🚵🏿‍♀️ 🤸‍♂️ 🤸🏻‍♂️ 🤸🏼‍♂️ 🤸🏽‍♂️ 🤸🏾‍♂️ 🤸🏿‍♂️ 🤸‍♀️ 🤸🏻‍♀️ 🤸🏼‍♀️ 🤸🏽‍♀️ 🤸🏾‍♀️ 🤸🏿‍♀️ 🤼‍♂️ 🤼‍♀️ 🤽‍♂️ 🤽🏻‍♂️ 🤽🏼‍♂️ 🤽🏽‍♂️ 🤽🏾‍♂️ 🤽🏿‍♂️ 🤽‍♀️ 🤽🏻‍♀️ 🤽🏼‍♀️ 🤽🏽‍♀️ 🤽🏾‍♀️ 🤽🏿‍♀️ 🤾‍♂️ 🤾🏻‍♂️ 🤾🏼‍♂️ 🤾🏽‍♂️ 🤾🏾‍♂️ 🤾🏿‍♂️ 🤾‍♀️ 🤾🏻‍♀️ 🤾🏼‍♀️ 🤾🏽‍♀️ 🤾🏾‍♀️ 🤾🏿‍♀️ 🤹‍♂️ 🤹🏻‍♂️ 🤹🏼‍♂️ 🤹🏽‍♂️ 🤹🏾‍♂️ 🤹🏿‍♂️ 🤹‍♀️ 🤹🏻‍♀️ 🤹🏼‍♀️ 🤹🏽‍♀️ 🤹🏾‍♀️ 🤹🏿‍♀️ 🧘‍♂️ 🧘🏻‍♂️ 🧘🏼‍♂️ 🧘🏽‍♂️ 🧘🏾‍♂️ 🧘🏿‍♂️ 🧘‍♀️ 🧘🏻‍♀️ 🧘🏼‍♀️ 🧘🏽‍♀️ 🧘🏾‍♀️ 🧘🏿‍♀️ 🧑‍🤝‍🧑 🧑🏻‍🤝‍🧑🏻 🧑🏻‍🤝‍🧑🏼 🧑🏻‍🤝‍🧑🏽 🧑🏻‍🤝‍🧑🏾 🧑🏻‍🤝‍🧑🏿 🧑🏼‍🤝‍🧑🏻 🧑🏼‍🤝‍🧑🏼 🧑🏼‍🤝‍🧑🏽 🧑🏼‍🤝‍🧑🏾 🧑🏼‍🤝‍🧑🏿 🧑🏽‍🤝‍🧑🏻 🧑🏽‍🤝‍🧑🏼 🧑🏽‍🤝‍🧑🏽 🧑🏽‍🤝‍🧑🏾 🧑🏽‍🤝‍🧑🏿 🧑🏾‍🤝‍🧑🏻 🧑🏾‍🤝‍🧑🏼 🧑🏾‍🤝‍🧑🏽 🧑🏾‍🤝‍🧑🏾 🧑🏾‍🤝‍🧑🏿 🧑🏿‍🤝‍🧑🏻 🧑🏿‍🤝‍🧑🏼 🧑🏿‍🤝‍🧑🏽 🧑🏿‍🤝‍🧑🏾 🧑🏿‍🤝‍🧑🏿 👩🏻‍🤝‍👩🏼 👩🏻‍🤝‍👩🏽 👩🏻‍🤝‍👩🏾 👩🏻‍🤝‍👩🏿 👩🏼‍🤝‍👩🏻"},{"id":"11","event":null,"data":"Невже треба вміти добути варення з чорниці? 👩🏼‍🤝‍👩🏽 👩🏼‍🤝‍👩🏾 👩🏼‍🤝‍👩🏿 👩🏽‍🤝‍👩🏻 👩🏽‍🤝‍👩🏼 👩🏽‍🤝‍👩🏾 👩🏽‍🤝‍👩🏿 👩🏾‍🤝‍👩🏻 👩🏾‍🤝‍👩🏼 👩🏾‍🤝‍👩🏽 👩🏾‍🤝‍👩🏿 👩🏿‍🤝‍👩🏻 👩🏿‍🤝‍👩🏼 👩🏿‍🤝‍👩🏽 👩🏿‍🤝‍👩🏾 👩🏻‍🤝‍👨🏼 👩🏻‍🤝‍👨🏽 👩🏻‍🤝‍👨🏾 👩🏻‍🤝‍👨🏿 👩🏼‍🤝‍👨🏻 👩🏼‍🤝‍👨🏽 👩🏼‍🤝‍👨🏾 👩🏼‍🤝‍👨🏿 👩🏽‍🤝‍👨🏻 👩🏽‍🤝‍👨🏼 👩🏽‍🤝‍👨🏾 👩🏽‍🤝‍👨🏿 👩🏾‍🤝‍👨🏻 👩🏾‍🤝‍👨🏼 👩🏾‍🤝‍👨🏽 👩🏾‍🤝‍👨🏿 👩🏿‍🤝‍👨🏻 👩🏿‍🤝‍👨🏼 👩🏿‍🤝‍👨🏽 👩🏿‍🤝‍👨🏾 👨🏻‍🤝‍👨🏼 👨🏻‍🤝‍👨🏽 👨🏻‍🤝‍👨🏾 👨🏻‍🤝‍👨🏿 👨🏼‍🤝‍👨🏻 👨🏼‍🤝‍👨🏽 👨🏼‍🤝‍👨🏾 👨🏼‍🤝‍👨🏿 👨🏽‍🤝‍👨🏻 👨🏽‍🤝‍👨🏼 👨🏽‍🤝‍👨🏾 👨🏽‍🤝‍👨🏿 👨🏾‍🤝‍👨🏻 👨🏾‍🤝‍👨🏼 👨🏾‍🤝‍👨🏽 👨🏾‍🤝‍👨🏿 👨🏿‍🤝‍👨🏻 👨🏿‍🤝‍👨🏼 👨🏿‍🤝‍👨🏽 👨🏿‍🤝‍👨🏾 💏 🧑🏻‍❤️‍💋‍🧑🏼 🧑🏻‍❤️‍💋‍🧑🏽 🧑🏻‍❤️‍💋‍🧑🏾 🧑🏻‍❤️‍💋‍🧑🏿 🧑🏼‍❤️‍💋‍🧑🏻 🧑🏼‍❤️‍💋‍🧑🏽 🧑🏼‍❤️‍💋‍🧑🏾 🧑🏼‍❤️‍💋‍🧑🏿 🧑🏽‍❤️‍💋‍🧑🏻 🧑🏽‍❤️‍💋‍🧑🏼 🧑🏽‍❤️‍💋‍🧑🏾 🧑🏽‍❤️‍💋‍🧑🏿 🧑🏾‍❤️‍💋‍🧑🏻 🧑🏾‍❤️‍💋‍🧑🏼 🧑🏾‍❤️‍💋‍🧑🏽 🧑🏾‍❤️‍💋‍🧑🏿 🧑🏿‍❤️‍💋‍🧑🏻 🧑🏿‍❤️‍💋‍🧑🏼 🧑🏿‍❤️‍💋‍🧑🏽 🧑🏿‍❤️‍💋‍🧑🏾 👩‍❤️‍💋‍👨 👩🏻‍❤️‍💋‍👨🏻 👩🏻‍❤️‍💋‍👨🏼 👩🏻‍❤️‍💋‍👨🏽 👩🏻‍❤️‍💋‍👨🏾 👩🏻‍❤️‍💋‍👨🏿 👩🏼‍❤️‍💋‍👨🏻 👩🏼‍❤️‍💋‍👨🏼 👩🏼‍❤️‍💋‍👨🏽 👩🏼‍❤️‍💋‍👨🏾 👩🏼‍❤️‍💋‍👨🏿 👩🏽‍❤️‍💋‍👨🏻 👩🏽‍❤️‍💋‍👨🏼 👩🏽‍❤️‍💋‍👨🏽 👩🏽‍❤️‍💋‍👨🏾 👩🏽‍❤️‍💋‍👨🏿 👩🏾‍❤️‍💋‍👨🏻 👩🏾‍❤️‍💋‍👨🏼 👩🏾‍❤️‍💋‍👨🏽 👩🏾‍❤️‍💋‍👨🏾 👩🏾‍❤️‍💋‍👨🏿"},{"id":"12","event":null,"data":"แน่นอ\nนว่าจะต้องสามารถจัดหาแยมบลูเบอร์รี่ได้? 👩🏿‍❤️‍💋‍👨🏻 👩🏿‍❤️‍💋‍👨🏼 👩🏿‍❤️‍💋‍👨🏽 👩🏿‍❤️‍💋‍👨🏾 👩🏿‍❤️‍💋‍👨🏿 👨‍❤️‍💋‍👨 👨🏻‍❤️‍💋‍👨🏻 👨🏻‍❤️‍💋‍👨🏼 👨🏻‍❤️‍💋‍👨🏽 👨🏻‍❤️‍💋‍👨🏾 👨🏻‍❤️‍💋‍👨🏿 👨🏼‍❤️‍💋‍👨🏻 👨🏼‍❤️‍💋‍👨🏼 👨🏼‍❤️‍💋‍👨🏽 👨🏼‍❤️‍💋‍👨🏾 👨🏼‍❤️‍💋‍👨🏿 👨🏽‍❤️‍💋‍👨🏻 👨🏽‍❤️‍💋‍👨🏼 👨🏽‍❤️‍💋‍👨🏽 👨🏽‍❤️‍💋‍👨🏾 👨🏽‍❤️‍💋‍👨🏿 👨🏾‍❤️‍💋‍👨🏻 👨🏾‍❤️‍💋‍👨🏼 👨🏾‍❤️‍💋‍👨🏽 👨🏾‍❤️‍💋‍👨🏾 👨🏾‍❤️‍💋‍👨🏿 👨🏿‍❤️‍💋‍👨🏻 👨🏿‍❤️‍💋‍👨🏼 👨🏿‍❤️‍💋‍👨🏽 👨🏿‍❤️‍💋‍👨🏾 👨🏿‍❤️‍💋‍👨🏿 👩‍❤️‍💋‍👩 👩🏻‍❤️‍💋‍👩🏻 👩🏻‍❤️‍💋‍👩🏼 👩🏻‍❤️‍💋‍👩🏽 👩🏻‍❤️‍💋‍👩🏾 👩🏻‍❤️‍💋‍👩🏿 👩🏼‍❤️‍💋‍👩🏻 👩🏼‍❤️‍💋‍👩🏼 👩🏼‍❤️‍💋‍👩🏽 👩🏼‍❤️‍💋‍👩🏾 👩🏼‍❤️‍💋‍👩🏿 👩🏽‍❤️‍💋‍👩🏻 👩🏽‍❤️‍💋‍👩🏼 👩🏽‍❤️‍💋‍👩🏽 👩🏽‍❤️‍💋‍👩🏾 👩🏽‍❤️‍💋‍👩🏿 👩🏾‍❤️‍💋‍👩🏻 👩🏾‍❤️‍💋‍👩🏼 👩🏾‍❤️‍💋‍👩🏽 👩🏾‍❤️‍💋‍👩🏾 👩🏾‍❤️‍💋‍👩🏿 👩🏿‍❤️‍💋‍👩🏻 👩🏿‍❤️‍💋‍👩🏼 👩🏿‍❤️‍💋‍👩🏽 👩🏿‍❤️‍💋‍👩🏾 👩🏿‍❤️‍💋‍👩🏿 💑 🧑🏻‍❤️‍🧑🏼 🧑🏻‍❤️‍🧑🏽 🧑🏻‍❤️‍🧑🏾 🧑🏻‍❤️‍🧑🏿 🧑🏼‍❤️‍🧑🏻 🧑🏼‍❤️‍🧑🏽 🧑🏼‍❤️‍🧑🏾 🧑🏼‍❤️‍🧑🏿 🧑🏽‍❤️‍🧑🏻 🧑🏽‍❤️‍🧑🏼 🧑🏽‍❤️‍🧑🏾 🧑🏽‍❤️‍🧑🏿 🧑🏾‍❤️‍🧑🏻 🧑🏾‍❤️‍🧑🏼 🧑🏾‍❤️‍🧑🏽 🧑🏾‍❤️‍🧑🏿 🧑🏿‍❤️‍🧑🏻 🧑🏿‍❤️‍🧑🏼 🧑🏿‍❤️‍🧑🏽 🧑🏿‍❤️‍🧑🏾 👩‍❤️‍👨 👩🏻‍❤️‍👨🏻 👩🏻‍❤️‍👨🏼 👩🏻‍❤️‍👨🏽 👩🏻‍❤️‍👨🏾 👩🏻‍❤️‍👨🏿 👩🏼‍❤️‍👨🏻 👩🏼‍❤️‍👨🏼 👩🏼‍❤️‍👨🏽 👩🏼‍❤️‍👨🏾 👩🏼‍❤️‍👨🏿 👩🏽‍❤️‍👨🏻 👩🏽‍❤️‍👨🏼 👩🏽‍❤️‍👨🏽 👩🏽‍❤️‍👨🏾 👩🏽‍❤️‍👨🏿 👩🏾‍❤️‍👨🏻 👩🏾‍❤️‍👨🏼 👩🏾‍❤️‍👨🏽"},{"id":"13","event":null,"data":"Сигурно некој мора да може да набави џем од боровинки? 👩🏾‍❤️‍👨🏾 👩🏾‍❤️‍👨🏿 👩🏿‍❤️‍👨🏻 👩🏿‍❤️‍👨🏼 👩🏿‍❤️‍👨🏽 👩🏿‍❤️‍👨🏾 👩🏿‍❤️‍👨🏿 👨‍❤️‍👨 👨🏻‍❤️‍👨🏻 👨🏻‍❤️‍👨🏼 👨🏻‍❤️‍👨🏽 👨🏻‍❤️‍👨🏾 👨🏻‍❤️‍👨🏿 👨🏼‍❤️‍👨🏻 👨🏼‍❤️‍👨🏼 👨🏼‍❤️‍👨🏽 👨🏼‍❤️‍👨🏾 👨🏼‍❤️‍👨🏿 👨🏽‍❤️‍👨🏻 👨🏽‍❤️‍👨🏼 👨🏽‍❤️‍👨🏽 👨🏽‍❤️‍👨🏾 👨🏽‍❤️‍👨🏿 👨🏾‍❤️‍👨🏻 👨🏾‍❤️‍👨🏼 👨🏾‍❤️‍👨🏽 👨🏾‍❤️‍👨🏾 👨🏾‍❤️‍👨🏿 👨🏿‍❤️‍👨🏻 👨🏿‍❤️‍👨🏼 👨🏿‍❤️‍👨🏽 👨🏿‍❤️‍👨🏾 👨🏿‍❤️‍👨🏿 👩‍❤️‍👩 👩🏻‍❤️‍👩🏻 👩🏻‍❤️‍👩🏼 👩🏻‍❤️‍👩🏽 👩🏻‍❤️‍👩🏾 👩🏻‍❤️‍👩🏿 👩🏼‍❤️‍👩🏻 👩🏼‍❤️‍👩🏼 👩🏼‍❤️‍👩🏽 👩🏼‍❤️‍👩🏾 👩🏼‍❤️‍👩🏿 👩🏽‍❤️‍👩🏻 👩🏽‍❤️‍👩🏼 👩🏽‍❤️‍👩🏽 👩🏽‍❤️‍👩🏾 👩🏽‍❤️‍👩🏿 👩🏾‍❤️‍👩🏻 👩🏾‍❤️‍👩🏼 👩🏾‍❤️‍👩🏽 👩🏾‍❤️‍👩🏾 👩🏾‍❤️‍👩🏿 👩🏿‍❤️‍👩🏻 👩🏿‍❤️‍👩🏼 👩🏿‍❤️‍👩🏽 👩🏿‍❤️‍👩🏾 👩🏿‍❤️‍👩🏿 👪 👨‍👩‍👦 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 👨‍👩‍👧‍👧 👨‍👨‍👦 👨‍👨‍👧 👨‍👨‍👧‍👦 👨‍👨‍👦‍👦 👨‍👨‍👧‍👧 👩‍👩‍👦 👩‍👩‍👧 👩‍👩‍👧‍👦 👩‍👩‍👦‍👦 👩‍👩‍👧‍👧 👨‍👦 👨‍👦‍👦 👨‍👧 👨‍👧‍👦 👨‍👧‍👧 👩‍👦 👩‍👦‍👦 👩‍👧 👩‍👧‍👦 👩‍👧‍👧 🐕‍🦺 🐈‍⬛ 🐻‍❄️ 🏳️‍🌈 🏳️‍⚧️ 🏴‍☠️"},{"id":null,"event":"done","data":"✔"}]} \ No newline at end of file diff --git a/aimux-stream/tests/ndjson_test.rs b/aimux-stream/tests/ndjson_test.rs deleted file mode 100644 index 88e75e1ab..000000000 --- a/aimux-stream/tests/ndjson_test.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! NDJSON stream decoder tests — cross-chunk UTF-8 reassembly, invalid-UTF-8 -//! rejection, and bounded-buffer behavior. - -use aimux_stream::{NdjsonError, NdjsonStream}; -use bytes::Bytes; -use futures::stream::{self, StreamExt}; -use serde::Deserialize; - -#[derive(Debug, Deserialize, PartialEq)] -struct Line { - text: String, -} - -/// Feed raw byte chunks into an [`NdjsonStream`] and collect the results. -async fn collect_lines(chunks: Vec>) -> Vec> { - let items: Vec> = - chunks.into_iter().map(|c| Ok(Bytes::from(c))).collect(); - let stream = NdjsonStream::new(stream::iter(items)); - stream.collect::>().await -} - -// ── cross-chunk UTF-8 reassembly (P0-03) ───────────────────────────────── - -#[tokio::test] -async fn emoji_ndjson_line_split_at_every_byte_boundary() { - // An NDJSON line whose value contains multi-byte emoji. Splitting the full - // line at *every* byte position must still reassemble to the intact text. - // The old per-chunk `String::from_utf8_lossy` decoder would corrupt a code - // point split across two chunks into two U+FFFDs. - let payload = "{\"text\":\"😀👋\"}\n"; - let bytes = payload.as_bytes(); - for split in 1..bytes.len() { - let (a, b) = bytes.split_at(split); - let lines = collect_lines(vec![a.to_vec(), b.to_vec()]).await; - assert_eq!( - lines.len(), - 1, - "split at byte {} produced {} results", - split, - lines.len() - ); - let line = lines[0] - .as_ref() - .unwrap_or_else(|e| panic!("split at byte {split} errored: {e:?}")); - assert_eq!( - line.text, "😀👋", - "split at byte {split} corrupted the text" - ); - } -} - -#[tokio::test] -async fn invalid_utf8_line_returns_utf8_error() { - // `{"text":"` followed by 0xF0 0x9F — the first two bytes of 😀 - // (U+1F600 = F0 9F 98 80) without the trailing bytes — is an incomplete - // 4-byte sequence. Strict decoding of the complete line surfaces a `Utf8` - // error instead of silently producing replacement chars. - let chunk = vec![ - b'{', b'"', b't', b'e', b'x', b't', b'"', b':', b'"', 0xF0, 0x9F, b'\n', - ]; - let lines = collect_lines(vec![chunk]).await; - assert_eq!(lines.len(), 1, "expected exactly one result"); - match &lines[0] { - Err(NdjsonError::Utf8(_)) => {} - other => panic!("expected NdjsonError::Utf8, got {other:?}"), - } -} - -// ── bounded buffers (P1-10) ───────────────────────────────────────────── - -#[tokio::test] -async fn oversized_line_returns_line_too_large() { - // A complete line longer than the 5-byte limit is rejected. - let stream = NdjsonStream::with_max_line_size( - stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from_static( - b"{\"text\":\"hi\"}\n", - ))]), - 5, - ); - let results: Vec> = stream.collect().await; - assert_eq!(results.len(), 1); - assert!(matches!(results[0], Err(NdjsonError::LineTooLarge))); -} - -#[tokio::test] -async fn buffer_growing_past_limit_without_newline_returns_line_too_large() { - // No newline ever arrives, so the buffer would grow unboundedly; the limit - // trips instead (previously this allocated forever). - let stream = NdjsonStream::with_max_line_size( - stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from_static( - b"{\"text\":\"no newline here\"", - ))]), - 5, - ); - let results: Vec> = stream.collect().await; - assert_eq!(results.len(), 1); - assert!(matches!(results[0], Err(NdjsonError::LineTooLarge))); -} diff --git a/aimux-stream/tests/sse_test.rs b/aimux-stream/tests/sse_test.rs index 65e8f3440..2672a962d 100644 --- a/aimux-stream/tests/sse_test.rs +++ b/aimux-stream/tests/sse_test.rs @@ -1,355 +1,778 @@ -//! Independent SSE parsing tests. +//! Port of the `eventsource-parser` test suite against [`aimux_stream::SseStream`]. //! -//! The TS SDK has no standalone SSE parser tests — `parseJsonEventStream` is -//! only exercised indirectly through provider tests. These tests fill that gap, -//! covering the edge cases listed in the task against [`aimux_stream::SseStream`]. +//! `eventsource-parser` is the parser behind the AI SDK's `parseJsonEventStream`. +//! This file ports `test/parse.test.ts` and `test/stream.test.ts` from v3.1.1 +//! (MIT), driven by the same fixtures (`test/fixtures.ts`; `test/multibyte.ts` +//! is checked in as `tests/fixtures/eventsource_parser_multibyte.json`). Test +//! names follow the upstream titles. //! -//! The expected behavior mirrors `eventsource-parser`'s `EventSourceParserStream` -//! (which the TS `parseJsonEventStream` pipes through): -//! - an event is dispatched only on a terminating blank line (`\n\n`/`\r\n\r\n`); -//! - an event with no `data:` line is NOT dispatched (covers comment-only, -//! `event:`/`id:`/`retry:`-only, and blank-line keep-alives); -//! - exactly one leading U+0020 SPACE after the `:` is stripped from a field -//! value (per the SSE spec); -//! - a partial event at EOF (no terminating blank line) is dropped. +//! Adaptations forced by the Rust API (`SseStream` yields whole events, has no +//! callbacks, and works on bytes): +//! - `retry` is reported on the dispatched event instead of through `onRetry`, +//! so a `retry:` block with no `data:` is dropped, and upstream's +//! `reconnect-interval` events become `SseEvent::retry`. +//! - Upstream's `onError` for an invalid `retry` value has no counterpart: the +//! value is ignored, which is what is asserted. +//! - There is no `maxBufferSize`: like `parseJsonEventStream`, the stream is +//! unbounded, so the upstream overflow cases are not ported. +//! - A leading U+FEFF is the UTF-8 BOM at byte level. Upstream's +//! "invalid byte-order mark" case feeds a decoded U+FEFF that the JS +//! `TextDecoderStream` would already have stripped, so only the "multiple +//! places" case is ported. +//! +//! Not ported (no Rust counterpart): `onComment` call counts, `reset()` +//! (3 tests), `onError` `ParseError` payloads (3 tests), the "function passed +//! to `createParser`" guard, and the `maxBufferSize` / `onError: 'terminate'` +//! stream tests (no size limit here). +//! +//! The `aimux` module holds the few tests for behaviour upstream does not have. use aimux_stream::{SseError, SseEvent, SseStream}; use bytes::Bytes; use futures::stream::{self, StreamExt}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +// ── harness ────────────────────────────────────────────────────────────── -/// Feed `chunks` (in arrival order) into a [`SseStream`] and collect all -/// emitted events. Each chunk simulates one `Result` item from a -/// real byte stream, so splitting a single event across chunks exercises the -/// cross-chunk buffering path. -async fn collect_events(chunks: Vec<&str>) -> Vec> { - let items: Vec> = chunks +type Item = Result; +type Triple<'a> = (Option<&'a str>, Option<&'a str>, &'a str); + +async fn run_bytes(chunks: Vec>) -> Vec { + let items: Vec> = + chunks.into_iter().map(|c| Ok(Bytes::from(c))).collect(); + SseStream::new(stream::iter(items)) + .collect::>() + .await +} + +async fn run(chunks: Vec) -> Vec { + run_bytes(chunks.into_iter().map(String::into_bytes).collect()).await +} + +/// Feed `chunks` and return the events; any error item fails the test. +async fn events(chunks: Vec) -> Vec { + run(chunks) + .await .into_iter() - .map(|s| Ok(Bytes::copy_from_slice(s.as_bytes()))) - .collect(); - let stream = SseStream::new(stream::iter(items)); - stream.collect::>().await + .map(|item| item.unwrap_or_else(|e| panic!("unexpected error item: {e:?}"))) + .collect() } -fn data(event: &SseEvent) -> &str { - &event.data +fn s(chunks: &[&str]) -> Vec { + chunks.iter().map(|c| (*c).to_string()).collect() } -// ── the 12 required edge cases ──────────────────────────────────────────── +/// `(id, event, data)` of each event. +fn triples(events: &[SseEvent]) -> Vec> { + events + .iter() + .map(|e| (e.id.as_deref(), e.event.as_deref(), e.data.as_str())) + .collect() +} -#[tokio::test] -async fn single_complete_event() { - let events = collect_events(vec!["data: hello\n\n"]).await; - assert_eq!(events.len(), 1); - let event = events[0].as_ref().unwrap(); - assert_eq!(data(event), "hello"); - assert!(event.event.is_none()); - assert!(event.id.is_none()); - assert!(event.retry.is_none()); +// ── `eventsource-encoder` (used by upstream's fixtures) ────────────────── + +#[derive(Default)] +struct Msg<'a> { + event: Option<&'a str>, + retry: Option, + id: Option<&'a str>, + data: Option<&'a str>, } -#[tokio::test] -async fn multiple_consecutive_events() { - let events = collect_events(vec!["data: first\n\ndata: second\n\n"]).await; - assert_eq!(events.len(), 2); - assert_eq!(data(events[0].as_ref().unwrap()), "first"); - assert_eq!(data(events[1].as_ref().unwrap()), "second"); +fn encode_data(text: &str) -> String { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + let lines: Vec<&str> = normalized.split('\n').collect(); + let mut out = String::new(); + for (i, line) in lines.iter().enumerate() { + out.push_str("data: "); + out.push_str(line); + out.push_str(if i + 1 == lines.len() { "\n\n" } else { "\n" }); + } + out } -#[tokio::test] -async fn event_spanning_two_chunks_half_line_split() { - // A single event whose data line arrives in two pieces. - let events = collect_events(vec!["data: hel", "lo\n\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), "hello"); +fn encode_comment(comment: &str) -> String { + let normalized = comment.replace("\r\n", "\n").replace('\r', "\n"); + format!(": {}\n\n", normalized.replace('\n', "\n: ")) } -#[tokio::test] -async fn event_spanning_many_tiny_chunks() { - // The same event byte-split across many tiny chunks. - let events = collect_events(vec!["da", "ta: ", "wor", "ld", "\n", "\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), "world"); +fn encode(msg: &Msg<'_>) -> String { + let mut out = String::new(); + if let Some(event) = msg.event.filter(|e| !e.is_empty()) { + out.push_str(&format!("event: {event}\n")); + } + if let Some(retry) = msg.retry { + out.push_str(&format!("retry: {retry}\n")); + } + if let Some(id) = msg.id { + out.push_str(&format!("id: {id}\n")); + } + if let Some(data) = msg.data { + out.push_str(&encode_data(data)); + } else if !out.is_empty() { + out.push_str("\n\n"); + } + out +} + +fn done() -> String { + encode(&Msg { + event: Some("done"), + data: Some("✔"), + ..Msg::default() + }) +} + +fn data_only(data: &str) -> String { + encode(&Msg { + data: Some(data), + ..Msg::default() + }) +} + +// ── fixtures (`test/fixtures.ts`) ──────────────────────────────────────── + +struct Multibyte { + lines: Vec, + emojis: Vec, + expected: Vec<(Option, Option, String)>, +} + +fn multibyte() -> Multibyte { + let root: Value = + serde_json::from_str(include_str!("fixtures/eventsource_parser_multibyte.json")).unwrap(); + let strings = |key: &str| -> Vec { + root[key] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect() + }; + let optional = |v: &Value| v.as_str().map(str::to_string); + Multibyte { + lines: strings("lines"), + emojis: strings("emojis"), + expected: root["expected"] + .as_array() + .unwrap() + .iter() + .map(|e| { + ( + optional(&e["id"]), + optional(&e["event"]), + e["data"].as_str().unwrap().to_string(), + ) + }) + .collect(), + } } -#[tokio::test] -async fn multi_line_data_field() { - let events = collect_events(vec!["data: line1\ndata: line2\n\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), "line1\nline2"); +/// Split `text` after `units` UTF-16 code units (JS `slice(0, n)` / `slice(n)`). +fn split_utf16(text: &str, units: usize) -> (&str, &str) { + let mut seen = 0; + for (index, ch) in text.char_indices() { + if seen == units { + return text.split_at(index); + } + seen += ch.len_utf16(); + assert!(seen <= units, "UTF-16 cut inside a surrogate pair"); + } + (text, "") +} + +fn multibyte_chunks(mb: &Multibyte) -> Vec { + let per_message = mb.emojis.len().div_ceil(mb.lines.len()); + let mut chunks = Vec::new(); + for (i, line) in mb.lines.iter().enumerate() { + let start = (per_message * i).min(mb.emojis.len()); + let end = (per_message * i + per_message).min(mb.emojis.len()); + let line = format!("{line} {}", mb.emojis[start..end].join(" ")); + chunks.push(format!("id: {i}\n")); + if i % 2 == 0 { + // Even lines are split into two `data:` lines. + let (head, tail) = split_utf16(&line, 5); + chunks.push(format!("data:{head}\n")); + chunks.push(format!("data:{tail}\n\n")); + } else { + chunks.push(format!("data:{line}\n\n")); + } + } + chunks.push(done()); + chunks } -#[tokio::test] -async fn event_field() { - let events = collect_events(vec!["event: message\ndata: payload\n\n"]).await; - assert_eq!(events.len(), 1); - let event = events[0].as_ref().unwrap(); - assert_eq!(event.event.as_deref(), Some("message")); - assert_eq!(data(event), "payload"); +fn iso_timestamp(i: usize) -> String { + format!("2026-09-29T05:51:{:02}.{:03}Z", i % 60, i) } -#[tokio::test] -async fn id_field() { - let events = collect_events(vec!["id: 42\ndata: payload\n\n"]).await; - assert_eq!(events.len(), 1); - let event = events[0].as_ref().unwrap(); - assert_eq!(event.id.as_deref(), Some("42")); - assert_eq!(data(event), "payload"); +/// `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$` +fn is_iso_timestamp(value: &str) -> bool { + const SHAPE: &[u8] = b"dddd-dd-ddTdd:dd:dd.dddZ"; + value.len() == SHAPE.len() + && value.bytes().zip(SHAPE).all(|(b, shape)| match shape { + b'd' => b.is_ascii_digit(), + other => b == *other, + }) } +// ── parse.test.ts ──────────────────────────────────────────────────────── + #[tokio::test] -async fn retry_field() { - let events = collect_events(vec!["retry: 5000\ndata: payload\n\n"]).await; - assert_eq!(events.len(), 1); - let event = events[0].as_ref().unwrap(); - assert_eq!(event.retry, Some(5000)); - assert_eq!(data(event), "payload"); +async fn basic_unnamed_events_stream() { + let mut chunks: Vec = (0..5).map(|i| data_only(&i.to_string())).collect(); + chunks.push(done()); + let got = events(chunks).await; + assert_eq!( + triples(&got), + vec![ + (None, None, "0"), + (None, None, "1"), + (None, None, "2"), + (None, None, "3"), + (None, None, "4"), + (None, Some("done"), "✔"), + ] + ); } #[tokio::test] -async fn comment_lines_starting_with_colon_are_ignored() { - // A comment line within an event block does not affect the event. - let events = collect_events(vec![": this is a comment\ndata: hello\n\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), "hello"); +async fn stream_of_time_event_name() { + let mut chunks: Vec = (0..5) + .map(|i| { + encode(&Msg { + event: Some("time"), + data: Some(&iso_timestamp(i)), + ..Msg::default() + }) + }) + .collect(); + chunks.push(done()); + let got = events(chunks).await; + assert_eq!(got.len(), 6); + for event in &got[..5] { + assert_eq!(event.event.as_deref(), Some("time")); + assert!(is_iso_timestamp(&event.data), "{}", event.data); + } } #[tokio::test] -async fn comment_only_event_is_not_emitted() { - // An event consisting solely of a comment has no data line → not dispatched. - let events = collect_events(vec![": just a comment\n\n"]).await; - assert!(events.is_empty()); +async fn stream_of_time_event_names_unbalanced_chunks() { + let mut chunks = Vec::new(); + let mut ids = Vec::new(); + for i in 0..30 { + let id = (100_000_000_000_u64 + i as u64 * 7919).to_string(); + let message = encode(&Msg { + id: Some(&id), + event: Some("time"), + data: Some(&iso_timestamp(i)), + ..Msg::default() + }); + // Upstream splits at a random offset; the offset is varied + // deterministically here (the message is ASCII). + let split = (i * 11 + 3) % message.len(); + chunks.push(message[..split].to_string()); + chunks.push(message[split..].to_string()); + ids.push(id); + } + let got = events(chunks).await; + assert_eq!(got.len(), 30); + for (event, id) in got.iter().zip(&ids) { + assert_eq!(event.event.as_deref(), Some("time")); + assert_eq!(event.id.as_deref(), Some(id.as_str())); + assert!(is_iso_timestamp(&event.data), "{}", event.data); + } } #[tokio::test] -async fn done_sentinel_is_emitted_as_data() { - // The parser emits `data: [DONE]` like any other event; higher layers - // (e.g. the OpenAI provider) filter the sentinel. This mirrors the TS - // `parseJsonEventStream`, which drops `[DONE]` after parsing — the SSE - // parser itself does not special-case it. - let events = collect_events(vec!["data: [DONE]\n\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), "[DONE]"); +async fn stream_of_identified_messages_and_retry_interval() { + let chunks = (1337..1339) + .map(|id| { + let id = id.to_string(); + encode(&Msg { + event: Some("tick"), + data: Some(&id), + id: Some(&id), + retry: Some(50), + }) + }) + .collect(); + let got = events(chunks).await; + assert_eq!( + triples(&got), + vec![ + (Some("1337"), Some("tick"), "1337"), + (Some("1338"), Some("tick"), "1338"), + ] + ); + assert!(got.iter().all(|e| e.retry == Some(50))); } #[tokio::test] -async fn blank_lines_and_heartbeat_emit_nothing() { - // Bare blank lines are keep-alives / heartbeats with no data → not emitted. - let events = collect_events(vec!["\n\n\n\n"]).await; - assert!(events.is_empty()); +async fn stream_of_heartbeat_comments_unnamed_events() { + let mut chunks = Vec::new(); + for letter in ['A', 'B', 'C', 'D', 'E'] { + chunks.push(encode_comment(" ♥")); + chunks.push(data_only(&letter.to_string())); + } + chunks.push(done()); + let got = events(chunks).await; + assert_eq!( + triples(&got), + vec![ + (None, None, "A"), + (None, None, "B"), + (None, None, "C"), + (None, None, "D"), + (None, None, "E"), + (None, Some("done"), "✔"), + ] + ); } #[tokio::test] -async fn crlf_vs_lf_line_endings() { - // CRLF-terminated single event. - let events = collect_events(vec!["data: hello\r\n\r\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), "hello"); +async fn stream_of_multi_line_data_events() { + let mut chunks = s(&[ + "event: stock\n", + "data: YHOO\n", + "data: +2\n", + "data: 10\n\n", + "event: stock\n", + "data: GOOG\n", + "data: -8\n", + "data: 1881\n\n", + ]); + chunks.push(done()); + let got = events(chunks).await; + assert_eq!( + triples(&got)[..2], + [ + (None, Some("stock"), "YHOO\n+2\n10"), + (None, Some("stock"), "GOOG\n-8\n1881"), + ] + ); } #[tokio::test] -async fn crlf_multi_line_data() { - // CRLF-terminated multi-line data joins with a single `\n`. - let events = collect_events(vec!["data: line1\r\ndata: line2\r\n\r\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), "line1\nline2"); +async fn stream_of_multi_byte_events() { + let mb = multibyte(); + let got = events(multibyte_chunks(&mb)).await; + let got: Vec<_> = got.into_iter().map(|e| (e.id, e.event, e.data)).collect(); + assert_eq!(got, mb.expected); } #[tokio::test] -async fn incomplete_event_at_eof_without_blank_line_is_dropped() { - // No terminating blank line → the buffered partial is not a complete event - // and is dropped (matches eventsource-parser's EventSourceParserStream, - // which has no flush handler). - let events = collect_events(vec!["data: hello"]).await; - assert!(events.is_empty()); +async fn stream_of_multi_byte_events_with_some_empty_lines_thrown_in() { + let got = events(vec![ + "\n\n\n\nid: 1\ndata: 我現在都看實況不玩遊戲\n\n".to_string(), + done(), + ]) + .await; + assert_eq!( + triples(&got), + vec![ + (Some("1"), None, "我現在都看實況不玩遊戲"), + (None, Some("done"), "✔"), + ] + ); } #[tokio::test] -async fn incomplete_multi_line_event_at_eof_is_dropped() { - // A buffered data line that never receives its terminating blank line is - // dropped, even if the data line itself looks complete. - let events = collect_events(vec!["data: hello\n"]).await; - assert!(events.is_empty()); +async fn stream_of_leading_bom() { + let got = events(vec![ + "\u{FEFF}data: bomful 1\n\n".to_string(), + "data: bomless 2\n\n".to_string(), + done(), + ]) + .await; + assert_eq!( + triples(&got), + vec![ + (None, None, "bomful 1"), + (None, None, "bomless 2"), + (None, Some("done"), "✔"), + ] + ); } -// ── additional spec-faithfulness coverage ──────────────────────────────── - #[tokio::test] -async fn data_without_space_after_colon() { - // `data:hello` (no space) → value is `hello`. - let events = collect_events(vec!["data:hello\n\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), "hello"); +async fn stream_containing_byte_order_mark_multiple_places() { + let got = events(vec![ + "\u{FEFF}data: bomful 1\n\n".to_string(), + "\u{FEFF}data: bomful 2\n\n".to_string(), + "data: bomless 3\n\n".to_string(), + done(), + ]) + .await; + // Only the BOM at the start of the stream is stripped; the second one + // turns the field name into an unknown field, so that event has no data. + assert_eq!( + triples(&got), + vec![ + (None, None, "bomful 1"), + (None, None, "bomless 3"), + (None, Some("done"), "✔"), + ] + ); } #[tokio::test] -async fn only_one_leading_space_is_removed() { - // Per the SSE spec exactly ONE leading U+0020 SPACE is removed; the rest - // of the value is preserved verbatim. - let events = collect_events(vec!["data: two leading spaces\n\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), " two leading spaces"); +async fn stream_using_carriage_returns() { + let mut chunks = s(&[ + "data: dog\r", + "data: bark\r\r", + "data: cat\r", + "data: meow\r\r", + ]); + chunks.push(done()); + let got = events(chunks).await; + assert_eq!( + triples(&got), + vec![ + (None, None, "dog\nbark"), + (None, None, "cat\nmeow"), + (None, Some("done"), "✔"), + ] + ); } #[tokio::test] -async fn empty_data_value() { - // `data:` with nothing after it contributes an empty data line. - let events = collect_events(vec!["data:\n\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), ""); +async fn stream_using_line_feeds() { + let mut chunks = s(&[ + "data: cow\n", + "data: moo\n\n", + "data: horse\n", + "data: neigh\n\n", + ]); + chunks.push(done()); + let got = events(chunks).await; + assert_eq!( + triples(&got), + vec![ + (None, None, "cow\nmoo"), + (None, None, "horse\nneigh"), + (None, Some("done"), "✔"), + ] + ); } #[tokio::test] -async fn event_field_without_data_is_not_emitted() { - // `event:` without a `data:` line → not dispatched. - let events = collect_events(vec!["event: ping\n\n"]).await; - assert!(events.is_empty()); +async fn stream_using_carriage_returns_and_line_feeds() { + let mut chunks = s(&[ + "data: sheep\r\n", + "data: bleat\r\n\r\n", + "data: pig\r\n", + "data: oink\r\n\r\n", + ]); + chunks.push(done()); + let got = events(chunks).await; + assert_eq!( + triples(&got), + vec![ + (None, None, "sheep\nbleat"), + (None, None, "pig\noink"), + (None, Some("done"), "✔"), + ] + ); } #[tokio::test] -async fn id_field_without_data_is_not_emitted() { - let events = collect_events(vec!["id: 99\n\n"]).await; - assert!(events.is_empty()); +async fn stream_with_varying_odd_uses_of_comments() { + let mb = multibyte(); + let mut chunks = s(&[": Hello\n\n"]); + chunks.push(":".repeat(300)); + chunks.extend(s(&[ + "\n", + "data: First\n\n", + ": Первый", + ": 第二", + "\n", + "data: Second\n\n", + ])); + chunks.extend(std::iter::repeat_n(": Moop \n".to_string(), 10)); + chunks.extend(s(&[ + ": ثالث", + "\n", + "data: Third\n\n", + ":നാലാമത്തെ", + "\n", + "data: Fourth\n\n", + ])); + chunks.push(format!(": {} :", mb.emojis[..100].join(" "))); + chunks.extend(s(&["\n", "data: Fifth\n\n"])); + chunks.push(done()); + let got = events(chunks).await; + assert_eq!( + triples(&got), + vec![ + (None, None, "First"), + (None, None, "Second"), + (None, None, "Third"), + (None, None, "Fourth"), + (None, None, "Fifth"), + (None, Some("done"), "✔"), + ] + ); } #[tokio::test] -async fn retry_field_without_data_is_not_emitted() { - let events = collect_events(vec!["retry: 1000\n\n"]).await; - assert!(events.is_empty()); +async fn stream_with_even_more_odd_uses_of_comments() { + let long = "x".repeat(2 * 1024 + 1); + let chunks = vec![ + "data:1\r\r:\0\n:\r\ndata:2\n\n:".to_string(), + long.clone(), + "\rdata:3\n\n:data:fail\r:".to_string(), + long, + "\ndata:4\n\n".to_string(), + "data:5".to_string(), + ]; + let got = events(chunks).await; + // No newline after the last message, thus not emitted. + assert_eq!( + triples(&got), + vec![ + (None, None, "1"), + (None, None, "2"), + (None, None, "3"), + (None, None, "4"), + ] + ); } #[tokio::test] -async fn event_with_all_fields_together() { - let events = collect_events(vec!["event: update\nid: 7\nretry: 3000\ndata: payload\n\n"]).await; - assert_eq!(events.len(), 1); - let e = events[0].as_ref().unwrap(); - assert_eq!(e.event.as_deref(), Some("update")); - assert_eq!(e.id.as_deref(), Some("7")); - assert_eq!(e.retry, Some(3000)); - assert_eq!(data(e), "payload"); +async fn stream_with_empty_event_field() { + let got = events(vec![ + "event:\ndata: Hello 1\n\n".to_string(), + "event:\n\n".to_string(), + done(), + ]) + .await; + assert_eq!( + triples(&got), + vec![(None, None, "Hello 1"), (None, Some("done"), "✔")] + ); } #[tokio::test] -async fn intermixed_comments_between_events() { - let events = collect_events(vec![ - ": heartbeat\ndata: first\n\n", - ": another comment\ndata: second\n\n", +async fn stream_with_empty_retry_field() { + let got = events(vec![ + encode(&Msg { + id: Some("1"), + retry: Some(500), + data: Some("🥌"), + ..Msg::default() + }), + "id:2\nretry:\ndata:🧹\n\n".to_string(), + encode(&Msg { + id: Some("3"), + data: Some("✅"), + ..Msg::default() + }), ]) .await; - assert_eq!(events.len(), 2); - assert_eq!(data(events[0].as_ref().unwrap()), "first"); - assert_eq!(data(events[1].as_ref().unwrap()), "second"); + assert_eq!( + triples(&got), + vec![ + (Some("1"), None, "🥌"), + (Some("2"), None, "🧹"), + (Some("3"), None, "✅"), + ] + ); + // The empty `retry` is ignored; `retry: 500` belongs to the first event. + assert_eq!( + got.iter().map(|e| e.retry).collect::>(), + vec![Some(500), None, None] + ); } #[tokio::test] -async fn invalid_retry_value_is_ignored() { - // A non-numeric `retry:` value is ignored; the event is still dispatched - // because it has a data line. - let events = collect_events(vec!["retry: not-a-number\ndata: payload\n\n"]).await; - assert_eq!(events.len(), 1); - let e = events[0].as_ref().unwrap(); - assert_eq!(e.retry, None); - assert_eq!(data(e), "payload"); +async fn stream_with_oddly_shaped_data_field() { + let got = events(vec![ + "data:\n\ndata\ndata\n\ndata:test\n\n".to_string(), + done(), + ]) + .await; + // `data:\n\n` dispatches an event with empty data; `data\ndata\n\n` is two + // empty data lines, joined by a newline. + assert_eq!( + triples(&got), + vec![ + (None, None, ""), + (None, None, "\n"), + (None, None, "test"), + (None, Some("done"), "✔"), + ] + ); } #[tokio::test] -async fn unknown_field_is_ignored() { - let events = collect_events(vec!["foo: bar\ndata: payload\n\n"]).await; - assert_eq!(events.len(), 1); - assert_eq!(data(events[0].as_ref().unwrap()), "payload"); +async fn stream_with_cr_separating_chunks_of_same_event() { + // A CR at the end of a chunk may be half of a CRLF, so it must not end + // the line yet: otherwise `A\nB` and `C` would be two events. + // https://github.com/rexxars/eventsource-parser/issues/17 + let got = events(s(&["data: A\r\n", "data: B\r", "\n", "data: C\r\n", "\n"])).await; + assert_eq!(triples(&got), vec![(None, None, "A\nB\nC")]); } #[tokio::test] -async fn mixed_lf_and_crlf_events() { - // An LF event followed by a CRLF event in the same stream. - let events = collect_events(vec!["data: lf\n\ndata: crlf\r\n\r\n"]).await; - assert_eq!(events.len(), 2); - assert_eq!(data(events[0].as_ref().unwrap()), "lf"); - assert_eq!(data(events[1].as_ref().unwrap()), "crlf"); +async fn stream_with_partially_incorrect_retry_fields() { + let got = events(s(&["retry:1000\nretry:2000x\ndata:x\n\n"])).await; + // `2000x` is not all ASCII digits and is ignored; `1000` is kept. + assert_eq!(triples(&got), vec![(None, None, "x")]); + assert_eq!(got[0].retry, Some(1000)); } -// ── cross-chunk UTF-8 reassembly (P0-03) ───────────────────────────────── +#[tokio::test] +async fn stream_with_id_field_containing_a_null_character() { + // An `id` containing U+0000 is ignored, so the earlier `123` survives. + let got = events(s(&["id: 123\nid: bad\0id\ndata: hello\n\n"])).await; + assert_eq!(triples(&got), vec![(Some("123"), None, "hello")]); +} -/// Like [`collect_events`] but takes raw byte chunks, so a chunk that ends in -/// the middle of a multi-byte UTF-8 sequence (not representable as a `&str`) -/// can be fed in. -async fn collect_events_bytes(chunks: Vec>) -> Vec> { - let items: Vec> = - chunks.into_iter().map(|c| Ok(Bytes::from(c))).collect(); - let stream = SseStream::new(stream::iter(items)); - stream.collect::>().await +#[tokio::test] +async fn stream_with_incorrect_retry_fields() { + let got = events(s(&[ + "\nretry: 500\n\ndata: first\n\nretry: 50x\n\ndata: second\n\n", + ])) + .await; + assert_eq!( + triples(&got), + vec![(None, None, "first"), (None, None, "second")] + ); } #[tokio::test] -async fn chinese_sse_event_split_at_every_byte_boundary() { - // A Chinese SSE event whose data contains multi-byte UTF-8. Splitting the - // full event at *every* byte position must still reassemble to the intact - // text. The old per-chunk `String::from_utf8_lossy` decoder would corrupt - // a code point split across two chunks into two U+FFFDs. - let payload = "data: 你好世界\n\n"; - let bytes = payload.as_bytes(); - for split in 1..bytes.len() { - let (a, b) = bytes.split_at(split); - let events = collect_events_bytes(vec![a.to_vec(), b.to_vec()]).await; - assert_eq!( - events.len(), - 1, - "split at byte {} produced {} results", - split, - events.len() - ); - let event = events[0] - .as_ref() - .unwrap_or_else(|e| panic!("split at byte {split} errored: {e:?}")); - assert_eq!( - event.data, "你好世界", - "split at byte {split} corrupted the data" - ); - } +async fn stream_with_unknown_fields_in_the_stream() { + let got = events(vec![ + "data:abc\n data\ndata\nfoobar:xxx\njustsometext\n:thisisacommentyay\ndata:123\n\n" + .to_string(), + done(), + ]) + .await; + assert_eq!( + triples(&got), + vec![(None, None, "abc\n\n123"), (None, Some("done"), "✔")] + ); } #[tokio::test] -async fn invalid_utf8_frame_returns_utf8_error() { - // `data: ` followed by 0xE4 0xBD — the first two bytes of `你` (U+4F60 = - // E4 BD A0) without the trailing byte — is an incomplete UTF-8 sequence. - // Strict decoding of the complete frame surfaces a `Utf8` error instead - // of silently producing a replacement char. - let chunk = vec![b'd', b'a', b't', b'a', b':', b' ', 0xE4, 0xBD, b'\n', b'\n']; - let events = collect_events_bytes(vec![chunk]).await; - assert_eq!(events.len(), 1, "expected exactly one result"); - match &events[0] { - Err(SseError::Utf8(_)) => {} - other => panic!("expected SseError::Utf8, got {other:?}"), +async fn stream_with_huge_data_chunks() { + const TEN_MEGABYTES: usize = 1024 * 1024 * 10; + const EXPECTED_SHA256: &str = + "e094a44a2436226ea9feb04e413a28de012b406012ec0eb6b37ad0a19d403660"; + + let mb = multibyte(); + let data_chunk = encode_data(&format!( + "{}\n{}", + mb.lines.join("\n\n"), + mb.emojis.join(" ") + )) + .trim() + .to_string(); + let mut chunks = s(&[": hello\n\n"]); + let mut written = 0; + while written < TEN_MEGABYTES { + chunks.push(data_chunk.clone()); + written += data_chunk.len(); } + chunks.extend(s(&["\n\n", ": END-OF-STREAM\n\n"])); + chunks.push(encode(&Msg { + event: Some("done"), + data: Some(EXPECTED_SHA256), + ..Msg::default() + })); + + let got: Vec = run(chunks).await.into_iter().map(Result::unwrap).collect(); + assert_eq!(got.len(), 2); + // JS `String.length` counts UTF-16 code units. + assert_eq!(got[0].data.encode_utf16().count(), 4_808_512); + assert_eq!( + format!("{:x}", Sha256::digest(got[0].data.as_bytes())), + got[1].data + ); } -// ── bounded buffers (P1-10) ───────────────────────────────────────────── - #[tokio::test] -async fn oversized_frame_returns_frame_too_large() { - // A complete event whose frame (`data: hello world`) exceeds the 10-byte - // limit is rejected; the frame+terminator is dropped so the stream ends. - let stream = SseStream::with_max_event_size( - stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from_static( - b"data: hello world\n\n", - ))]), - 10, - ); - let results: Vec<_> = stream.collect().await; - assert_eq!(results.len(), 1); - assert!(matches!(results[0], Err(SseError::FrameTooLarge))); +async fn large_input_without_a_terminator_is_not_an_error() { + // Unbounded, as upstream: a partial block is simply never dispatched. + assert!(run(vec!["x".repeat(4 * 1024 * 1024)]).await.is_empty()); } +// ── stream.test.ts ─────────────────────────────────────────────────────── + #[tokio::test] -async fn buffer_growing_past_limit_without_terminator_returns_frame_too_large() { - // No terminator ever arrives, so the buffer would grow unboundedly; the - // limit trips instead (previously this allocated forever). - let stream = SseStream::with_max_event_size( - stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from_static( - b"data: no terminator here", - ))]), - 10, - ); - let results: Vec<_> = stream.collect().await; - assert_eq!(results.len(), 1); - assert!(matches!(results[0], Err(SseError::FrameTooLarge))); +async fn can_use_event_source_parser_stream() { + let chunks = (0..10) + .map(|i| { + encode(&Msg { + event: Some("foo"), + id: Some(&format!("evt-{i}")), + data: Some(&format!("Hello {i}")), + ..Msg::default() + }) + }) + .collect::>() + .concat(); + let got = events(vec![chunks]).await; + assert_eq!(got.len(), 10); + assert_eq!(triples(&got)[0], (Some("evt-0"), Some("foo"), "Hello 0")); + assert_eq!(triples(&got)[9], (Some("evt-9"), Some("foo"), "Hello 9")); +} + +// ── aimux-specific behaviour (not covered upstream) ────────────────────── + +mod aimux { + use super::*; + + #[tokio::test] + async fn invalid_utf8_is_a_terminal_error() { + // Upstream decodes lossily (`TextDecoder`); SseStream decodes strictly + // and, like every decoder error, ends the stream after reporting it. + // `E4 BD` is the start of `你` without its last byte. + let mut bytes = b"data: ".to_vec(); + bytes.extend_from_slice(&[0xE4, 0xBD]); + bytes.extend_from_slice(b"\ndata: tail\n\ndata: next\n\n"); + let results = run_bytes(vec![bytes]).await; + assert_eq!(results.len(), 1); + assert!(matches!(results[0], Err(SseError::Utf8(_)))); + } + + #[tokio::test] + async fn multibyte_fixture_split_at_arbitrary_byte_sizes() { + // Chunks can end inside a code point or inside the BOM; the parser + // works on bytes, so the result must not depend on where they end. + let mb = multibyte(); + let mut stream_bytes = "\u{FEFF}".as_bytes().to_vec(); + stream_bytes.extend(multibyte_chunks(&mb).concat().into_bytes()); + for size in [1, 2, 3, 5, 8, 13] { + let chunks = stream_bytes.chunks(size).map(<[u8]>::to_vec).collect(); + let got: Vec<_> = run_bytes(chunks) + .await + .into_iter() + .map(|item| { + let e = item.unwrap_or_else(|err| panic!("chunk size {size}: {err:?}")); + (e.id, e.event, e.data) + }) + .collect(); + assert_eq!(got, mb.expected, "chunk size {size}"); + } + } + + #[tokio::test] + async fn transport_error_is_reported_as_a_stream_error() { + let items: Vec> = vec![ + Ok(Bytes::from_static(b"data: a\n\n")), + Err(std::io::Error::other("boom")), + Ok(Bytes::from_static(b"data: b\n\n")), + ]; + let results = SseStream::new(stream::iter(items)) + .collect::>() + .await; + // The transport error is terminal: nothing after it is read. + assert_eq!(results.len(), 2); + assert_eq!(results[0].as_ref().unwrap().data, "a"); + assert!(matches!(&results[1], Err(SseError::Stream(e)) if e.to_string() == "boom")); + } } diff --git a/docs/PROJECT-OVERVIEW.md b/docs/PROJECT-OVERVIEW.md index a5442aca3..a53a672eb 100644 --- a/docs/PROJECT-OVERVIEW.md +++ b/docs/PROJECT-OVERVIEW.md @@ -165,7 +165,7 @@ aimux/ │ ├── native protocols # standalone model + convert, handles provider-specific differences │ ├── OpenAI compatible # registry-backed: provider-registry.json + provider(name, ...) entry (RFC-0017 phase 4) │ └── modalities/search # voice / image / video / search implementations -├── aimux-stream # SSE / NDJSON streaming parsing +├── aimux-stream # SSE parsing, streamed tool-call tracking ├── aimux-provider-utils # One-exchange HTTP helpers, response handlers, API-key loading ├── aimux-ffi # C ABI (FFI infrastructure, shared by all bindings) └── bindings/ # 6 language bindings