Skip to content

Commit 01268cf

Browse files
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>
1 parent 859f2b9 commit 01268cf

15 files changed

+1896
-1
lines changed

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

Lines changed: 637 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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,68 @@ Supporting helpers for composing custom test patterns:
261261
- `decode_frames_with_codec` — decode wire bytes to frames.
262262
- `extract_payloads` — extract payload bytes from decoded frames.
263263

264+
#### Feeding partial frames and fragments
265+
266+
Real networks rarely deliver a complete codec frame in a single TCP read.
267+
The `wireframe_testing` crate provides drivers that simulate these
268+
conditions so you can exercise your codec's buffering logic in tests.
269+
270+
**Chunked-write drivers** encode payloads via a codec, concatenate the wire
271+
bytes, and write them in configurable chunk sizes (including one byte at a
272+
time):
273+
274+
```rust,no_run
275+
use std::num::NonZeroUsize;
276+
use wireframe::app::WireframeApp;
277+
use wireframe::codec::examples::HotlineFrameCodec;
278+
use wireframe_testing::drive_with_partial_frames;
279+
280+
let codec = HotlineFrameCodec::new(4096);
281+
let app = WireframeApp::new()?.with_codec(codec.clone());
282+
let chunk = NonZeroUsize::new(1).expect("non-zero");
283+
let payloads =
284+
drive_with_partial_frames(app, &codec, vec![vec![1, 2, 3]], chunk)
285+
.await?;
286+
```
287+
288+
Available chunked-write driver functions:
289+
290+
- `drive_with_partial_frames` / `drive_with_partial_frames_with_capacity`
291+
owned app, returns payload bytes.
292+
- `drive_with_partial_frames_mut` — mutable app reference, returns payload
293+
bytes.
294+
- `drive_with_partial_codec_frames` — owned app, returns decoded `F::Frame`
295+
values.
296+
297+
**Fragment-feeding drivers** accept a raw payload, fragment it with a
298+
`Fragmenter`, encode each fragment into a codec frame, and feed the frames
299+
through the app:
300+
301+
```rust,no_run
302+
use std::num::NonZeroUsize;
303+
use wireframe::app::WireframeApp;
304+
use wireframe::codec::examples::HotlineFrameCodec;
305+
use wireframe::fragment::Fragmenter;
306+
use wireframe_testing::drive_with_fragments;
307+
308+
let codec = HotlineFrameCodec::new(4096);
309+
let app = WireframeApp::new()?.with_codec(codec.clone());
310+
let fragmenter = Fragmenter::new(NonZeroUsize::new(20).unwrap());
311+
let payloads =
312+
drive_with_fragments(app, &codec, &fragmenter, vec![0; 100]).await?;
313+
```
314+
315+
Available fragment-feeding driver functions:
316+
317+
- `drive_with_fragments` / `drive_with_fragments_with_capacity` — owned
318+
app, returns payload bytes.
319+
- `drive_with_fragments_mut` — mutable app reference, returns payload
320+
bytes.
321+
- `drive_with_fragment_frames` — owned app, returns decoded `F::Frame`
322+
values.
323+
- `drive_with_partial_fragments` — fragment AND feed in chunks,
324+
exercising both fragmentation and partial-frame buffering simultaneously.
325+
264326
#### Zero-copy payload extraction
265327

266328
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
@@ -29,6 +29,7 @@ pub mod message_assembly;
2929
pub mod message_assembly_inbound;
3030
pub mod multi_packet;
3131
pub mod panic;
32+
pub mod partial_frame_feeding;
3233
pub mod request_parts;
3334
pub mod serializer_boundaries;
3435
pub mod stream_end;
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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 env = Envelope::new(1, Some(7), vec![u8::try_from(i).unwrap_or(0)]);
115+
payloads.push(BincodeSerializer.serialize(&env)?);
116+
}
117+
118+
self.response_payloads =
119+
wireframe_testing::drive_with_partial_frames(app, codec, payloads, chunk).await?;
120+
Ok(())
121+
}
122+
123+
/// Drive the app with a fragmented payload.
124+
///
125+
/// # Errors
126+
/// Returns an error if the app, codec, or fragmenter is not configured.
127+
pub async fn drive_fragmented(&mut self, payload_len: usize) -> TestResult {
128+
let app = self.app.take().ok_or("app not configured")?;
129+
let codec = self.codec.as_ref().ok_or("codec not configured")?;
130+
let fragmenter = self
131+
.fragmenter
132+
.as_ref()
133+
.ok_or("fragmenter not configured")?;
134+
135+
let _payloads =
136+
wireframe_testing::drive_with_fragments(app, codec, fragmenter, vec![0; payload_len])
137+
.await?;
138+
self.fragment_feeding_completed = true;
139+
Ok(())
140+
}
141+
142+
/// Drive the app with a fragmented payload fed in chunks.
143+
///
144+
/// # Errors
145+
/// Returns an error if the app, codec, or fragmenter is not configured.
146+
pub async fn drive_partial_fragmented(
147+
&mut self,
148+
payload_len: usize,
149+
chunk_size: usize,
150+
) -> TestResult {
151+
let app = self.app.take().ok_or("app not configured")?;
152+
let codec = self.codec.as_ref().ok_or("codec not configured")?;
153+
let fragmenter = self
154+
.fragmenter
155+
.as_ref()
156+
.ok_or("fragmenter not configured")?;
157+
let chunk = NonZeroUsize::new(chunk_size).ok_or("chunk size must be non-zero")?;
158+
159+
let _payloads = wireframe_testing::drive_with_partial_fragments(
160+
app,
161+
codec,
162+
fragmenter,
163+
vec![0; payload_len],
164+
chunk,
165+
)
166+
.await?;
167+
self.fragment_feeding_completed = true;
168+
Ok(())
169+
}
170+
171+
/// Assert that fragment feeding completed without error.
172+
///
173+
/// # Errors
174+
/// Returns an error if fragment feeding has not been attempted or failed.
175+
pub fn assert_fragment_feeding_completed(&self) -> TestResult {
176+
if !self.fragment_feeding_completed {
177+
return Err("fragment feeding has not completed successfully".into());
178+
}
179+
Ok(())
180+
}
181+
182+
/// Assert that response payloads are non-empty.
183+
///
184+
/// # Errors
185+
/// Returns an error if no response payloads were collected.
186+
pub fn assert_payloads_non_empty(&self) -> TestResult {
187+
if self.response_payloads.is_empty() {
188+
return Err("expected non-empty response payloads".into());
189+
}
190+
Ok(())
191+
}
192+
193+
/// Assert that the response contains exactly `expected` payloads.
194+
///
195+
/// # Errors
196+
/// Returns an error if the payload count does not match.
197+
pub fn assert_payload_count(&self, expected: usize) -> TestResult {
198+
let actual = self.response_payloads.len();
199+
if actual != expected {
200+
return Err(format!("expected {expected} response payloads, got {actual}").into());
201+
}
202+
Ok(())
203+
}
204+
}

0 commit comments

Comments
 (0)