Skip to content

Commit 04e1a44

Browse files
Add utilities for feeding partial frames and fragments (tests, docs, re-exports) (#493)
* feat(testing): add utilities for feeding partial frames and fragments into in-process app - Introduced chunked-write drivers to write codec-encoded payloads in configurable byte-sized chunks, simulating partial-frame network reads. - Added fragment-feeding drivers that fragment raw payloads, encode fragments, and feed them through WireframeApp. - Provided multiple driver variants: owned app, mutable app, with capacity, returning raw frames or payloads. - Established comprehensive BDD test suite and unit tests covering chunked and fragmented feeding scenarios. - Updated docs/users-guide.md with new "Feeding partial frames and fragments" section including usage examples. - Marked roadmap item 8.5.1 as done in docs/roadmap.md. - Registered modules and re-exported public APIs in wireframe_testing crate. These utilities enable testing realistic network conditions where frames arrive across multiple reads or are fragmented, improving codec and application robustness validation. Co-authored-by: devboxerhub[bot] <devboxerhub[bot]@users.noreply.github.com> * refactor(helpers): introduce ChunkConfig and FragmentRequest structs for config - Added ChunkConfig to combine chunk size and duplex buffer capacity in partial_frame. - Added FragmentRequest to bundle fragmenter, payload, and capacity in fragment_drive. - Updated relevant functions to use these structs for clearer and more flexible configuration. - Refactored partial frame tests for reusability using a helper test function. Co-authored-by: devboxerhub[bot] <devboxerhub[bot]@users.noreply.github.com> * feat(wireframe_testing): wrap fragment payloads in serialized Envelopes Fragment payloads are now wrapped inside serialized `Envelope` packets before being fed through the codec and application pipeline. This ensures the application's deserializer accepts these fragment frames instead of accumulating deserialization failures and closing connections. - Updated fragment_and_encode to wrap `FRAG`-prefixed fragment bytes into `Envelope` packets serialized with `BincodeSerializer`. - Adjusted fragment feeding tests to verify envelope deserialization and responses. - Improved documentation and examples in wireframe_testing helpers to reflect the new envelope wrapping. - Added default route ID for wrapped fragments matching typical app route registrations. This enhancement makes fragment feeding tests more accurate by fully simulating the expected codec and application message framing. Co-authored-by: devboxerhub[bot] <devboxerhub[bot]@users.noreply.github.com> * test(partial_frame_feeding): update test terminology and assertions for fragment feeding - Corrected spelling of 'parameterised' to 'parameterized' in comments. - Updated test scenario assertions from 'app receives fragment frames' to 'fragment feeding completed'. - Replaced `response_frames` field with `fragment_feeding_completed` boolean in test fixture. - Changed assertion helpers to check for fragment feeding completion instead of received fragments. These changes improve clarity and accuracy of partial frame feeding tests. Co-authored-by: devboxerhub[bot] <devboxerhub[bot]@users.noreply.github.com> --------- Co-authored-by: devboxerhub[bot] <devboxerhub[bot]@users.noreply.github.com>
1 parent 6327c67 commit 04e1a44

15 files changed

+2014
-1
lines changed

docs/execplans/8-5-1-utilities-for-feeding-partial-frames-into-in-process-app.md

Lines changed: 639 additions & 0 deletions
Large diffs are not rendered by default.

docs/roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ and standardized per-connection memory budgets.
313313

314314
### 8.5. Testkit utilities
315315

316-
- [ ] 8.5.1. Add utilities for feeding partial frames or fragments into an
316+
- [x] 8.5.1. Add utilities for feeding partial frames or fragments into an
317317
in-process app.
318318
- [ ] 8.5.2. Add slow reader and writer simulation for back-pressure testing.
319319
- [ ] 8.5.3. Add deterministic assertion helpers for reassembly outcomes.

docs/users-guide.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,67 @@ Available fixture functions:
305305
- `correlated_hotline_wire` — frames sharing a transaction ID.
306306
- `sequential_hotline_wire` — frames with incrementing transaction IDs.
307307

308+
#### Feeding partial frames and fragments
309+
310+
Real networks rarely deliver a complete codec frame in a single TCP read.
311+
The `wireframe_testing` crate provides drivers that simulate these
312+
conditions so that codec buffering logic can be exercised in tests.
313+
314+
**Chunked-write drivers** encode payloads via a codec, concatenate the wire
315+
bytes, and write them in configurable chunk sizes (including one byte at a
316+
time):
317+
318+
```rust,no_run
319+
use std::num::NonZeroUsize;
320+
use wireframe::app::WireframeApp;
321+
use wireframe::codec::examples::HotlineFrameCodec;
322+
use wireframe_testing::drive_with_partial_frames;
323+
324+
let codec = HotlineFrameCodec::new(4096);
325+
let app = WireframeApp::new()?.with_codec(codec.clone());
326+
let chunk = NonZeroUsize::new(1).expect("non-zero");
327+
let payloads =
328+
drive_with_partial_frames(app, &codec, vec![vec![1, 2, 3]], chunk)
329+
.await?;
330+
```
331+
332+
Available chunked-write driver functions:
333+
334+
- `drive_with_partial_frames` / `drive_with_partial_frames_with_capacity`
335+
owned app, returns payload bytes.
336+
- `drive_with_partial_frames_mut` — mutable app reference, returns payload
337+
bytes.
338+
- `drive_with_partial_codec_frames` — owned app, returns decoded `F::Frame`
339+
values.
340+
341+
**Fragment-feeding drivers** accept a raw payload, fragment it with a
342+
`Fragmenter`, encode each fragment into a codec frame, and feed the frames
343+
through the app:
344+
345+
```rust,no_run
346+
use std::num::NonZeroUsize;
347+
use wireframe::app::WireframeApp;
348+
use wireframe::codec::examples::HotlineFrameCodec;
349+
use wireframe::fragment::Fragmenter;
350+
use wireframe_testing::drive_with_fragments;
351+
352+
let codec = HotlineFrameCodec::new(4096);
353+
let app = WireframeApp::new()?.with_codec(codec.clone());
354+
let fragmenter = Fragmenter::new(NonZeroUsize::new(20).unwrap());
355+
let payloads =
356+
drive_with_fragments(app, &codec, &fragmenter, vec![0; 100]).await?;
357+
```
358+
359+
Available fragment-feeding driver functions:
360+
361+
- `drive_with_fragments` / `drive_with_fragments_with_capacity` — owned
362+
app, returns payload bytes.
363+
- `drive_with_fragments_mut` — mutable app reference, returns payload
364+
bytes.
365+
- `drive_with_fragment_frames` — owned app, returns decoded `F::Frame`
366+
values.
367+
- `drive_with_partial_fragments` — fragment AND feed in chunks,
368+
exercising both fragmentation and partial-frame buffering simultaneously.
308369
#### Zero-copy payload extraction
309370

310371
For performance-critical codecs, use `Bytes` instead of `Vec<u8>` for payload
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
@partial-frame-feeding
2+
Feature: Partial frame and fragment feeding utilities
3+
4+
Scenario: Single payload survives byte-at-a-time chunked delivery
5+
Given a wireframe app with a Hotline codec allowing 4096-byte frames for partial feeding
6+
When a test payload is fed in 1-byte chunks
7+
Then the partial feeding response payloads are non-empty
8+
9+
Scenario: Multiple payloads survive misaligned chunked delivery
10+
Given a wireframe app with a Hotline codec allowing 4096-byte frames for partial feeding
11+
When 2 test payloads are fed in 7-byte chunks
12+
Then the partial feeding response contains 2 payloads
13+
14+
Scenario: Fragmented payload is delivered as fragment frames
15+
Given a wireframe app with a Hotline codec allowing 4096-byte frames for partial feeding
16+
And a fragmenter capped at 20 bytes per fragment for partial feeding
17+
When a 100-byte payload is fragmented and fed through the app
18+
Then the fragment feeding completes without error
19+
20+
Scenario: Fragmented payload survives chunked delivery
21+
Given a wireframe app with a Hotline codec allowing 4096-byte frames for partial feeding
22+
And a fragmenter capped at 20 bytes per fragment for partial feeding
23+
When a 100-byte payload is fragmented and fed in 3-byte chunks
24+
Then the fragment feeding completes without error

tests/fixtures/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub mod message_assembly;
3232
pub mod message_assembly_inbound;
3333
pub mod multi_packet;
3434
pub mod panic;
35+
pub mod partial_frame_feeding;
3536
pub mod request_parts;
3637
pub mod serializer_boundaries;
3738
pub mod stream_end;
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
//! BDD world fixture for partial frame and fragment feeding scenarios.
2+
//!
3+
//! Tracks application configuration, fragmenter state, and collected
4+
//! responses across behavioural test steps.
5+
6+
use std::{num::NonZeroUsize, sync::Arc};
7+
8+
use futures::future::BoxFuture;
9+
use rstest::fixture;
10+
use wireframe::{
11+
app::{Envelope, WireframeApp},
12+
codec::examples::HotlineFrameCodec,
13+
fragment::Fragmenter,
14+
serializer::{BincodeSerializer, Serializer},
15+
};
16+
/// Re-export `TestResult` from `wireframe_testing` for use in steps.
17+
pub use wireframe_testing::TestResult;
18+
19+
/// BDD world holding the app, codec, fragmenter, and collected responses.
20+
///
21+
/// `WireframeApp` does not implement `Debug`, so this type provides a manual
22+
/// implementation that redacts the app field.
23+
#[derive(Default)]
24+
pub struct PartialFrameFeedingWorld {
25+
codec: Option<HotlineFrameCodec>,
26+
app: Option<WireframeApp<BincodeSerializer, (), Envelope, HotlineFrameCodec>>,
27+
fragmenter: Option<Fragmenter>,
28+
response_payloads: Vec<Vec<u8>>,
29+
fragment_feeding_completed: bool,
30+
}
31+
32+
impl std::fmt::Debug for PartialFrameFeedingWorld {
33+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34+
f.debug_struct("PartialFrameFeedingWorld")
35+
.field("codec", &self.codec)
36+
.field("app", &self.app.as_ref().map(|_| ".."))
37+
.field("fragmenter", &self.fragmenter)
38+
.field("response_payloads", &self.response_payloads.len())
39+
.field(
40+
"fragment_feeding_completed",
41+
&self.fragment_feeding_completed,
42+
)
43+
.finish()
44+
}
45+
}
46+
47+
/// Fixture for partial frame feeding scenarios used by rstest-bdd steps.
48+
///
49+
/// Note: rustfmt collapses simple fixtures into one line, which triggers
50+
/// `unused_braces`, so keep `rustfmt::skip`.
51+
#[rustfmt::skip]
52+
#[fixture]
53+
pub fn partial_frame_feeding_world() -> PartialFrameFeedingWorld {
54+
PartialFrameFeedingWorld::default()
55+
}
56+
57+
impl PartialFrameFeedingWorld {
58+
/// Configure the app with a `HotlineFrameCodec`.
59+
///
60+
/// # Errors
61+
/// Returns an error if the app or route registration fails.
62+
pub fn configure_app(&mut self, max_frame_length: usize) -> TestResult {
63+
let codec = HotlineFrameCodec::new(max_frame_length);
64+
let app = WireframeApp::<BincodeSerializer, (), Envelope>::new()?
65+
.with_codec(codec.clone())
66+
.route(
67+
1,
68+
Arc::new(|_: &Envelope| -> BoxFuture<'static, ()> { Box::pin(async {}) }),
69+
)?;
70+
self.codec = Some(codec);
71+
self.app = Some(app);
72+
Ok(())
73+
}
74+
75+
/// Configure a fragmenter with the given maximum payload per fragment.
76+
///
77+
/// # Errors
78+
/// Returns an error if the payload cap is zero.
79+
pub fn configure_fragmenter(&mut self, max_payload: usize) -> TestResult {
80+
let cap = NonZeroUsize::new(max_payload).ok_or("fragment payload cap must be non-zero")?;
81+
self.fragmenter = Some(Fragmenter::new(cap));
82+
Ok(())
83+
}
84+
85+
/// Drive the app with a single payload chunked into `chunk_size` pieces.
86+
///
87+
/// # Errors
88+
/// Returns an error if the app or codec is not configured, or driving fails.
89+
pub async fn drive_chunked(&mut self, chunk_size: usize) -> TestResult {
90+
let app = self.app.take().ok_or("app not configured")?;
91+
let codec = self.codec.as_ref().ok_or("codec not configured")?;
92+
let chunk = NonZeroUsize::new(chunk_size).ok_or("chunk size must be non-zero")?;
93+
94+
let env = Envelope::new(1, Some(7), b"bdd-chunked".to_vec());
95+
let serialized = BincodeSerializer.serialize(&env)?;
96+
97+
self.response_payloads =
98+
wireframe_testing::drive_with_partial_frames(app, codec, vec![serialized], chunk)
99+
.await?;
100+
Ok(())
101+
}
102+
103+
/// Drive the app with `count` payloads chunked into `chunk_size` pieces.
104+
///
105+
/// # Errors
106+
/// Returns an error if the app or codec is not configured, or driving fails.
107+
pub async fn drive_chunked_multiple(&mut self, count: usize, chunk_size: usize) -> TestResult {
108+
let app = self.app.take().ok_or("app not configured")?;
109+
let codec = self.codec.as_ref().ok_or("codec not configured")?;
110+
let chunk = NonZeroUsize::new(chunk_size).ok_or("chunk size must be non-zero")?;
111+
112+
let mut payloads = Vec::with_capacity(count);
113+
for i in 0..count {
114+
let byte = u8::try_from(i)
115+
.map_err(|_| format!("payload index {i} exceeds u8 range; use count <= 256"))?;
116+
let env = Envelope::new(1, Some(7), vec![byte]);
117+
payloads.push(BincodeSerializer.serialize(&env)?);
118+
}
119+
120+
self.response_payloads =
121+
wireframe_testing::drive_with_partial_frames(app, codec, payloads, chunk).await?;
122+
Ok(())
123+
}
124+
125+
/// Drive the app with a fragmented payload.
126+
///
127+
/// # Errors
128+
/// Returns an error if the app, codec, or fragmenter is not configured.
129+
pub async fn drive_fragmented(&mut self, payload_len: usize) -> TestResult {
130+
let app = self.app.take().ok_or("app not configured")?;
131+
let codec = self.codec.as_ref().ok_or("codec not configured")?;
132+
let fragmenter = self
133+
.fragmenter
134+
.as_ref()
135+
.ok_or("fragmenter not configured")?;
136+
137+
let _payloads =
138+
wireframe_testing::drive_with_fragments(app, codec, fragmenter, vec![0; payload_len])
139+
.await?;
140+
self.fragment_feeding_completed = true;
141+
Ok(())
142+
}
143+
144+
/// Drive the app with a fragmented payload fed in chunks.
145+
///
146+
/// # Errors
147+
/// Returns an error if the app, codec, or fragmenter is not configured.
148+
pub async fn drive_partial_fragmented(
149+
&mut self,
150+
payload_len: usize,
151+
chunk_size: usize,
152+
) -> TestResult {
153+
let app = self.app.take().ok_or("app not configured")?;
154+
let codec = self.codec.as_ref().ok_or("codec not configured")?;
155+
let fragmenter = self
156+
.fragmenter
157+
.as_ref()
158+
.ok_or("fragmenter not configured")?;
159+
let chunk = NonZeroUsize::new(chunk_size).ok_or("chunk size must be non-zero")?;
160+
161+
let _payloads = wireframe_testing::drive_with_partial_fragments(
162+
app,
163+
codec,
164+
fragmenter,
165+
vec![0; payload_len],
166+
chunk,
167+
)
168+
.await?;
169+
self.fragment_feeding_completed = true;
170+
Ok(())
171+
}
172+
173+
/// Assert that fragment feeding completed without error.
174+
///
175+
/// # Errors
176+
/// Returns an error if fragment feeding has not been attempted or failed.
177+
pub fn assert_fragment_feeding_completed(&self) -> TestResult {
178+
if !self.fragment_feeding_completed {
179+
return Err("fragment feeding has not completed successfully".into());
180+
}
181+
Ok(())
182+
}
183+
184+
/// Assert that response payloads are non-empty.
185+
///
186+
/// # Errors
187+
/// Returns an error if no response payloads were collected.
188+
pub fn assert_payloads_non_empty(&self) -> TestResult {
189+
if self.response_payloads.is_empty() {
190+
return Err("expected non-empty response payloads".into());
191+
}
192+
Ok(())
193+
}
194+
195+
/// Assert that the response contains exactly `expected` payloads.
196+
///
197+
/// # Errors
198+
/// Returns an error if the payload count does not match.
199+
pub fn assert_payload_count(&self, expected: usize) -> TestResult {
200+
let actual = self.response_payloads.len();
201+
if actual != expected {
202+
return Err(format!("expected {expected} response payloads, got {actual}").into());
203+
}
204+
Ok(())
205+
}
206+
}

0 commit comments

Comments
 (0)