diff --git a/docs/adr-004-pluggable-protocol-codecs.md b/docs/adr-004-pluggable-protocol-codecs.md index 669fd0eb..24103f49 100644 --- a/docs/adr-004-pluggable-protocol-codecs.md +++ b/docs/adr-004-pluggable-protocol-codecs.md @@ -222,6 +222,18 @@ pub trait FrameCodec: Send + Sync + 'static { and explicit `try_from` conversions, suggesting a need for shared parsing helpers to reduce boilerplate. +## Implementation guidance + +- Add codec-specific tests that exercise encoder/decoder round-trips, payload + extraction, correlation identifiers, and maximum frame length enforcement. +- Use shared example codecs in `wireframe::codec::examples` to drive regression + and property-based tests without duplicating framing logic. +- Prefer `wireframe_testing` helpers that work with custom codecs, so test + harnesses do not assume length-delimited framing. +- Validate observability signals (logs, metrics, and protocol hooks) for codec + failures and recovery policies using the test observability harness once + available.[^adr-006] + ## Known Risks and Limitations - Associated types avoid RPITIT for `FrameCodec`, but the overall design still @@ -254,3 +266,5 @@ preserves existing behaviour through a default codec while enabling protocol specificity and reusability. By tying buffer sizing and maximum frame length to codec configuration, the design keeps transport-level constraints close to the framing rules that define them. + +[^adr-006]: See [adr-006-test-observability.md](adr-006-test-observability.md). diff --git a/docs/adr-006-test-observability.md b/docs/adr-006-test-observability.md new file mode 100644 index 00000000..fc7b55a6 --- /dev/null +++ b/docs/adr-006-test-observability.md @@ -0,0 +1,114 @@ +# Architectural decision record (ADR) 006: test observability harness + +## Status + +Proposed. + +## Date + +2026-01-02. + +## Context and Problem Statement + +Wireframe relies on logs, metrics, and protocol hooks for runtime +observability. Upcoming codec hardening work adds structured errors and +recovery policies that must be verified alongside behaviour. Today, tests can +capture logs via `wireframe_testing::logger`, but metrics require ad hoc global +exporters, and there is no shared helper for asserting instrumentation. This +makes telemetry assertions inconsistent, brittle, and hard to reuse across +downstream crates. + +A dedicated test observability harness is needed, so tests can assert on +logging and metrics deterministically without depending on external exporters +or global state leaks. + +## Decision Drivers + +- Provide deterministic, per-test observability assertions. +- Avoid external services or network dependencies in tests. +- Keep the harness reusable for downstream crates and example codecs. +- Guard global registries to prevent cross-test interference. + +## Requirements + +### Functional requirements + +- Capture logs and metrics for a single test run. +- Provide helper APIs for inspecting log records and metric samples. +- Support async tests and background tasks without losing telemetry. + +### Technical requirements + +- Avoid environment variable mutations in tests. +- Use in-process recorders and restore prior global state on drop. +- Keep dependencies minimal and aligned with existing crates. + +## Options Considered + +### Option A: keep ad hoc log capture (status quo) + +Continue using `logtest` directly in tests and rely on manual, ad hoc setup for +metrics. + +### Option B: add a unified test observability harness (preferred) + +Provide a `wireframe_testing::observability` module that installs a scoped log +capture and metrics recorder, returning a guard with inspection helpers. + +### Option C: rely on external exporters in integration tests + +Use Prometheus exporters or external telemetry services and parse their output +in tests. + +## Decision Outcome / Proposed Direction + +Adopt Option B and add a scoped test observability harness to +`wireframe_testing`. The harness should compose with the existing logger guard +and provide a metrics recorder suitable for assertions in unit, integration, +and behavioural tests. + +## Approach + +- Add an `ObservabilityHandle` that installs log capture and a metrics recorder + when constructed and restores previous globals on drop. +- Reuse `logtest` for logs and introduce a metrics recorder based on + `metrics-util` so tests can query counters and gauges without external + exporters. +- Provide helper methods to clear state between assertions and to filter by + labels when verifying counters. +- Serialize access with a global lock to avoid cross-test interference, and + document that tests using the handle should not run concurrently. +- For parallel test runners, run observability-heavy test binaries with + `--test-threads=1` or gate observability tests behind a shared fixture to + prevent interleaving. + +## Consequences + +- Tests can assert on instrumentation for codec errors and recovery policies + without bespoke setup. +- Global recorder access requires serialization, which may reduce parallelism + for observability-heavy suites, such as forcing `--test-threads=1` for the + affected test binary. See the mitigation guidance in the approach. +- The testing crate gains additional dependency surface for metrics capture. + +## Roadmap + +### Phase 1: Observability capture primitives + +- Step: Introduce the harness in `wireframe_testing`. + - Task: Add `ObservabilityHandle` and integrate with `LoggerHandle`. + - Task: Provide metric snapshot helpers for counters and gauges. + - Task: Document usage and thread-safety constraints. + +### Phase 2: Codec and recovery assertions + +- Step: Wire codec tests to the harness. + - Task: Add helpers for asserting codec error counters and log fields. + - Task: Update codec regression tests to use the new helpers. + +## Mitigation and rollback + +- Keep the harness behind a feature flag if global recorder conflicts with + downstream test environments. +- If metrics capture proves flaky, fall back to log-based assertions while + refining recorder isolation. diff --git a/docs/roadmap.md b/docs/roadmap.md index 331f9441..d1ff40d4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -425,6 +425,18 @@ integration boundaries. - [ ] Measure fragmentation overhead versus unfragmented paths. - [ ] Record memory allocation baselines for payload wrapping and decoding. +### 9.7. Codec test harness and observability + +- [ ] 9.7.1. Extend `wireframe_testing` with codec-aware drivers that can run + `WireframeApp` instances configured with custom `FrameCodec` values. +- [ ] 9.7.2. Add codec fixtures in `wireframe_testing` for generating valid and + invalid frames, including oversized payloads and correlation metadata. +- [ ] 9.7.3. Introduce a test observability harness in `wireframe_testing` that + captures logs and metrics per test run for asserting codec failures and + recovery policies.[^adr-006] +- [ ] 9.7.4. Add regression tests in `wireframe_testing` for the `CodecError` + taxonomy and recovery policy behaviours defined in 9.1.2. Requires 9.1.2. + ## 10. Wireframe client library foundation This phase delivers a first-class client runtime that mirrors the server's @@ -569,3 +581,5 @@ and usability. [message-versioning.md](message-versioning.md). [^adr-005]: See [adr-005-serializer-abstraction.md](adr-005-serializer-abstraction.md). +[^adr-006]: See +[adr-006-test-observability.md](adr-006-test-observability.md). diff --git a/docs/users-guide.md b/docs/users-guide.md index c7f4edb0..c89f64d7 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -117,6 +117,51 @@ helpers `send_response` and `send_response_framed` (or variants when encoding or I/O fails, and the connection closes after ten consecutive deserialization errors.[^6][^7] +### Custom frame codecs + +Custom protocols supply a `FrameCodec` implementation to describe their framing +rules. The codec owns the Tokio `Decoder` and `Encoder` types, while Wireframe +uses the trait surface to map frames to payload bytes and correlation data. + +A codec implementation must: + +- Define a `Frame` type and paired decoder/encoder implementations that return + `std::io::Error` on failure. +- Return only the logical payload bytes from `frame_payload` so metadata parsing + and deserialisation run against the right buffer. +- Wrap outbound payloads with `wrap_payload`, adding any protocol headers or + metadata required by the wire format. +- Provide `correlation_id` when the protocol stores it outside the payload; + Wireframe only uses this hook when the deserialized envelope is missing a + correlation identifier. +- Report `max_frame_length`, which clamps inbound frames and seeds default + fragmentation limits. + +Install a custom codec with `with_codec`. The builder resets fragmentation to +the codec-derived defaults, so override fragmentation afterwards if the +protocol uses a different budget. When a framed stream is already available, +use `send_response_framed_with_codec`, so responses pass through +`FrameCodec::wrap_payload`. + +Assume `MyCodec` implements `FrameCodec`: + +```rust,no_run +use std::sync::Arc; + +use wireframe::app::{Envelope, Handler, WireframeApp}; + +struct MyCodec; + +let handler: Handler = Arc::new(|_: &Envelope| Box::pin(async {})); + +let app = WireframeApp::new()? + .with_codec(MyCodec) + .route(1, handler)?; +``` + +See `examples/hotline_codec.rs` and `examples/mysql_codec.rs` for complete +implementations. + ## Packets, payloads, and serialization Packets drive routing. Implement the `Packet` trait (or use the bundled diff --git a/docs/wireframe-testing-crate.md b/docs/wireframe-testing-crate.md index 6152384a..6a28b56d 100644 --- a/docs/wireframe-testing-crate.md +++ b/docs/wireframe-testing-crate.md @@ -1,188 +1,337 @@ -# `wireframe_testing`: Testing Helpers for Wireframe +# `wireframe_testing`: testing helpers for Wireframe -`wireframe_testing` is a proposed companion crate providing utilities for unit -and integration tests. It focuses on driving `WireframeApp` instances with raw -frames, enabling fast tests without opening real network connections. +`wireframe_testing` is the companion crate for exercising Wireframe +applications and codecs in unit, integration, and behavioural tests. It +provides in-memory drivers for `WireframeApp` instances, codec-aware framing +helpers, and a scoped observability harness for log and metrics assertions. ## Motivation -The existing tests in [`tests/`](../tests) use a helper function `run_app` to -feed length-prefixed frames through an in-memory duplex stream. This helper -simplifies testing handlers by allowing assertions on encoded responses without -spinning up a full server. Encapsulating this logic in a dedicated crate keeps -test code concise and reusable across projects. +Wireframe now supports pluggable protocol codecs (ADR 004). The test harness +must encode and decode frames using the selected `FrameCodec`, preserving +protocol metadata and correlation identifiers. It must also allow malformed +wire bytes for negative codec tests. ADR 006 proposes a unified test +observability harness, so tests can assert on logs and metrics +deterministically without external exporters. -## Crate Layout +## Design goals -- `wireframe_testing` - - `Cargo.toml` enabling the `tokio` and `rstest` dependencies used by the - helpers. - - `src/lib.rs` exposing asynchronous functions for driving apps with raw +- Work with any `FrameCodec`, including codecs that carry metadata and + correlation identifiers. +- Preserve frame metadata when driving handlers or asserting responses. +- Allow raw byte injection for malformed frame and recovery tests. +- Provide per-test log and metrics capture without leaking global state. +- Keep helpers fast by using in-memory duplex streams instead of sockets. -```frames. -[dependencies] -tokio = { version = "1", features = ["macros", "rt"] } +## Crate layout -[dev-dependencies] -rstest = "0.18" -``` +- `src/lib.rs` re-exports the public API. +- `src/helpers.rs` provides in-memory drivers and codec-aware encode/decode + helpers. +- `src/observability.rs` implements the observability harness from ADR 006. +- `src/logging.rs` retains the standalone `LoggerHandle` fixture. +- `src/fixtures/` (or `src/codec_fixtures.rs`) stores reusable frame fixtures + for the default codec and example codecs. +- `src/multi_packet.rs` keeps the `collect_multi_packet` helper. + +## Dependencies -```rust -// src/lib.rs -pub mod helpers; - -pub use helpers::{ - drive_with_frame, - drive_with_frames, - drive_with_frame_with_capacity, - drive_with_frames_with_capacity, - drive_with_bincode, -}; +```toml +[dependencies] +tokio = { version = "1", features = ["macros", "rt", "io-util"] } +wireframe = { version = "0.1.0", path = ".." } +bincode = "2.0" +bytes = "1.0" +futures = "0.3" +tokio-util = { version = "0.7", features = ["codec"] } +log = "0.4" +logtest = "2" +metrics = "0.24.2" +metrics-util = "0.20.0" +rstest = "0.18.2" ``` -The crate would live in a `wireframe_testing/` directory alongside the main -`wireframe` crate. +## Codec-aware drivers + +The helpers remain centred on a single in-memory driver that runs +`WireframeApp::handle_connection` against a `tokio::io::duplex` stream. The +driver is responsible for framing inbound and outbound data using the selected +`FrameCodec` and for surfacing server panics as `io::Error` values prefixed +with `server task failed`. + +`wireframe_testing` retains the `TestSerializer` trait alias to keep bounds +readable: -## Proposed API +```rust,no_run +use wireframe::app::Envelope; +use wireframe::frame::FrameMetadata; +use wireframe::serializer::Serializer; -```rust -use tokio::io::Result as IoResult; -use wireframe::app::WireframeApp; -use serde::Serialize; +pub trait TestSerializer: + Serializer + FrameMetadata + Send + Sync + 'static +{ +} +``` + +### Driver entry points -/// Feed a single frame into `app` using an in-memory duplex stream. -pub async fn drive_with_frame(app: WireframeApp, frame: Vec) -> IoResult>; +Length-delimited helpers match the current `wireframe_testing` API and accept +raw frame bytes. Use `drive_with_frames` for pre-framed input (including +malformed frames) and `drive_with_payloads` to wrap payloads with the default +length-delimited framing. -/// Drive `app` with multiple frames, returning all bytes written by the app. -pub async fn drive_with_frames(app: WireframeApp, frames: Vec>) -> IoResult>; +```rust,no_run +use std::io; +use wireframe::app::{Packet, WireframeApp}; -/// Encode `msg` with `bincode`, wrap it in a frame, and drive the app. -pub async fn drive_with_bincode(app: WireframeApp, msg: M) -> IoResult> +pub async fn drive_with_frames( + app: WireframeApp, + frames: Vec>, +) -> io::Result> where - M: Serialize; -``` + S: TestSerializer, + C: Send + 'static, + E: Packet; + +pub async fn drive_with_payloads( + app: WireframeApp, + payloads: Vec>, +) -> io::Result> +where + S: TestSerializer, + C: Send + 'static, + E: Packet; -These functions mirror the behaviour of the `run_app` helper found in the -repository’s test utilities. They create a `tokio::io::duplex` stream, run the -application on the server half, and write the provided frame(s) to the client -side. All helpers delegate to a single internal function that handles this I/O -plumbing, ensuring consistent behaviour. Should the application panic, the -panic message is returned as an `io::Error` beginning with -`server task failed`, helping surface failures in tests. After the application -finishes processing the input frames, the bytes written back are collected for -inspection. - -Any I/O errors surfaced by the duplex stream or failures while decoding a -length prefix propagate through the returned `IoResult`. Malformed or truncated -frames therefore cause the future to resolve with an error, allowing tests to -assert on these failure conditions directly. - -### Custom Buffer Capacity - -A variant accepting a buffer `capacity` allows fine-tuning the size of the -in-memory duplex channel, matching the existing `run_app` helper. The value -must be greater than zero and does not exceed 10 MB. - -```helpers. -pub async fn drive_with_frame_with_capacity( - app: WireframeApp, - frame: Vec, - capacity: usize, -) -> IoResult>; - -pub async fn drive_with_frames_with_capacity( - app: WireframeApp, +pub async fn drive_with_frames_mut( + app: &mut WireframeApp, frames: Vec>, - capacity: usize, -) -> IoResult>; - -/// The above helpers consume the `WireframeApp`. For scenarios -/// where a single app instance should be reused across calls, -/// borrow it mutably instead. -pub async fn drive_with_frame_mut(app: &mut WireframeApp, frame: Vec) -> IoResult>; -pub async fn drive_with_frames_mut(app: &mut WireframeApp, frames: Vec>) -> IoResult>; +) -> io::Result> +where + S: TestSerializer, + C: Send + 'static, + E: Packet; + +pub async fn drive_with_payloads_mut( + app: &mut WireframeApp, + payloads: Vec>, +) -> io::Result> +where + S: TestSerializer, + C: Send + 'static, + E: Packet; ``` -### Bincode Convenience Wrapper +Codec-aware helpers should be added as non-breaking extensions, so tests can +pass `FrameCodec` values and inspect protocol-specific frame metadata. Prefer +distinct names (for example, `drive_with_codec_frames`) so existing tests that +use raw byte frames continue to compile unchanged. -For most tests the input frame is preassembled from raw bytes. A small wrapper -can accept any `serde::Serialize` value and perform the encoding and framing -before delegating to `drive_with_frame`. The approach mirrors patterns in -`tests/routes.rs`, where structs convert to bytes with `BincodeSerializer` -before being wrapped in a length-prefixed frame. +Behavioural details: -```rust -#[derive(serde::Serialize)] -struct Ping(u8); +- `drive_with_frames` writes the provided bytes verbatim, making it the + preferred helper for malformed frame and recovery tests. +- `drive_with_payloads` length-prefixes payload bytes before delegating to + `drive_with_frames`. +- `drive_with_bincode` encodes a message with bincode and then length-prefixes + the output before driving the app. +- Mutable variants (`drive_with_frames_mut` and `drive_with_payloads_mut`) + accept `&mut WireframeApp` so tests can reuse a configured instance. +- I/O failures, framing errors, and server task panics are all returned as + `io::Error` values, so tests can assert on error handling. + +### Buffer capacity and limits + +The duplex stream buffer defaults to `TEST_MAX_FRAME`, matching the shared +length-delimited framing guardrail. Use `run_app` or the `*_with_capacity` +helpers to override this value; they reject a `capacity` of zero or above the +maximum ceiling with `io::ErrorKind::InvalidInput`. + +Codec-aware helpers should instead read `FrameCodec::max_frame_length()` to +align buffer sizing with protocol framing rules. + +### Frame encoding and decoding helpers + +The current helpers focus on the default length-delimited framing used by +Wireframe tests: + +```rust,no_run +use tokio_util::codec::LengthDelimitedCodec; + +pub fn decode_frames(bytes: Vec) -> Vec>; + +pub fn decode_frames_with_max(bytes: Vec, max_len: usize) -> Vec>; -let bytes = drive_with_bincode(app, Ping(1)).await.unwrap(); -assert_eq!(bytes, [0, 1]); +pub fn encode_frame(codec: &mut LengthDelimitedCodec, bytes: Vec) -> Vec; ``` -### Helper macros +### Bincode convenience wrapper -Two small macros, `push_expect!` and `recv_expect!`, reduce boilerplate in test -code. They await a future and panic with a message including the call site when -the future resolves to an error. +Most tests still send a single request encoded with bincode. Keep a small +wrapper that performs `bincode::encode_to_vec` with +`bincode::config::standard()` and drives the app: -```rust -push_expect!(handle.push_high_priority(42)); -let (_, frame) = recv_expect!(queues.recv()); +```rust,no_run +use std::io; +use wireframe::app::{Packet, WireframeApp}; + +pub async fn drive_with_bincode( + app: WireframeApp, + msg: M, +) -> io::Result> +where + M: bincode::Encode, + S: TestSerializer, + C: Send + 'static, + E: Packet; ``` -## Example Usage +```rust,no_run +use wireframe_testing::{decode_frames, drive_with_bincode}; -```rust -use std::sync::Arc; -use wireframe_testing::{drive_with_frame, drive_with_frames}; -use crate::tests::{build_test_frame, expected_bytes}; +#[derive(bincode::Encode)] +struct Ping(u8); -#[tokio::test] -async fn handler_echoes_message() { - let app = WireframeApp::new() - .unwrap() - .route(1, Arc::new(|_| Box::pin(async {}))) - .unwrap(); - - let frame = build_test_frame(); - let out = drive_with_frame(app, frame).await.unwrap(); - assert_eq!(out, expected_bytes()); +let bytes = drive_with_bincode(app, Ping(1)).await?; +let frames = decode_frames(bytes); +assert!(!frames.is_empty(), "expected at least one response frame"); +``` + +## Codec fixtures + +Add reusable fixtures to avoid duplicated framing logic in tests: + +- Default codec fixtures that yield `Bytes` frames, oversized payloads, and + truncated prefixes for negative tests. +- Example codec fixtures that mirror `wireframe::codec::examples` (for example + Hotline and MySQL), including helpers for correlation identifiers. +- Invalid frame builders (bad lengths, missing headers, truncated payloads) for + codec error and recovery assertions. + +Fixtures should return `F::Frame` where possible and provide explicit +`wire_bytes()` helpers for malformed cases. + +## Test observability harness + +Introduce `wireframe_testing::observability`, providing an +`ObservabilityHandle` that combines log capture with metrics recording. + +Key behaviours: + +- Acquisition installs log capture via `LoggerHandle` and a scoped metrics + recorder using `metrics_util::debugging::DebuggingRecorder`. +- Access is serialized with a global lock, so concurrent tests do not + interfere, but the harness will reduce parallelism for the affected suite. +- Observability-heavy suites should run in a single-threaded test runner (for + example, pass `--test-threads=1` for the affected test binary), or share a + single `ObservabilityHandle` via a per-suite fixture to amortize setup costs. +- When partial parallelism is needed, group observability assertions into a + dedicated test binary that runs serially, and keep the remaining test suite + in the default parallel runner. +- Metrics snapshots should consume the captured values (matching + `DebuggingRecorder` semantics) so `clear()` can be implemented by draining a + snapshot. +- The handle should restore the previous recorder on drop by swapping the + active recorder back into a global delegating recorder. This keeps the global + recorder stable while still providing per-test isolation. +- When the `metrics` feature is disabled, the handle should still capture logs + and return empty metric snapshots. + +Proposed public API: + +```rust,no_run +use metrics_util::debugging::Snapshot; +use wireframe_testing::LoggerHandle; + +pub struct ObservabilityHandle { /* fields omitted */ } + +impl ObservabilityHandle { + pub fn new() -> Self; + pub fn logs(&mut self) -> &mut LoggerHandle; + pub fn snapshot(&self) -> Snapshot; + pub fn clear(&mut self); + pub fn counter(&self, name: &str, labels: &[(&str, &str)]) -> u64; } + +pub fn observability() -> ObservabilityHandle; ``` -This pattern mirrors the style of `tests/routes.rs`, where handlers are invoked -with prebuilt frames and their responses decoded for assertions. +Tests using `ObservabilityHandle` should not run concurrently; the global lock +serializes access, so favour a shared fixture or a dedicated serial test binary +for observability assertions. -## Benefits +## Helper macros -- **Isolation**: Handlers can be tested without spinning up a full server or - opening sockets. -- **Reusability**: Projects consuming `wireframe` can depend on - `wireframe_testing` in their dev-dependencies to leverage the same helpers. -- **Clarity**: Abstracting the duplex stream logic keeps test cases focused on - behaviour instead of transport details. +Keep `push_expect!` and `recv_expect!` for concise async assertions, with error +messages that include call-site information in debug builds. -### Capturing Logs in Tests +## Example usage -The `wireframe_testing` crate exposes a \[`LoggerHandle`\] fixture for -asserting log output. Acquire it in a test and call `clear()` to discard any -records from fixture setup. Records can then be inspected using `pop()`: +```rust,no_run +use std::sync::Arc; -```rust -use wireframe_testing::logger; +use wireframe::app::{Envelope, WireframeApp}; +use wireframe_testing::{decode_frames, drive_with_bincode, observability}; + +#[tokio::test] +async fn round_trips_with_codec() -> std::io::Result<()> { + let app = WireframeApp::new()? + .route(1, Arc::new(|_: &Envelope| Box::pin(async {})))?; + let env = Envelope::new(1, Some(5), vec![1, 2, 3]); + let out = drive_with_bincode(app, env).await?; + let frames = decode_frames(out); + assert_eq!(frames.len(), 1); + Ok(()) +} #[tokio::test] -async fn captures_logs() { - let mut logs = logger(); - logs.clear(); - log::error!(target = "demo", key = 1, "boom"); - let record = logs.pop().unwrap(); - assert_eq!(record.target(), "demo"); +async fn captures_metrics() -> std::io::Result<()> { + use wireframe::metrics::{Direction, FRAMES_PROCESSED, inc_frames}; + + let mut obs = observability(); + obs.clear(); + + inc_frames(Direction::Inbound); + assert_eq!( + obs.counter(FRAMES_PROCESSED, &[("direction", "inbound")]), + 1 + ); + Ok(()) } ``` -## Next Steps +## Implementation notes + +- Add codec-aware helpers (for example, `drive_with_codec_frames`) that accept + `F: FrameCodec` and return `F::Frame` values for tests that need protocol + metadata. +- Provide codec-aware `encode_frames` and `decode_frames` helpers that return + `io::Result` on failures instead of panicking. +- Keep the length-delimited helpers so existing tests that use raw frame bytes + remain compatible. +- Add fixture helpers for default and example codecs, including invalid frames + used by codec error tests. +- Implement the observability harness and expose it via an `rstest` fixture in + `wireframe_testing::observability`. + +## Proposed enhancements + +### Codec-aware frame encoding and decoding + +Proposed codec-aware helpers make it easy to build fixtures or inspect raw +bytes: + +```rust,no_run +use std::io; +use wireframe::codec::FrameCodec; + +pub fn encode_frames(codec: &F, frames: Vec) -> io::Result> +where + F: FrameCodec; + +pub fn decode_frames(codec: &F, bytes: Vec) -> io::Result> +where + F: FrameCodec; +``` -Implement the crate in a new directory, export the helper functions, and -migrate existing tests to use them. Additional fixtures (e.g., prebuilt frame -processors) can be added over time as test coverage grows. +These helpers should return an error when trailing bytes remain in the buffer +after the last frame, so tests can detect partial or malformed streams. diff --git a/wireframe_testing/src/helpers.rs b/wireframe_testing/src/helpers.rs index d74b90f2..3c2d73fa 100644 --- a/wireframe_testing/src/helpers.rs +++ b/wireframe_testing/src/helpers.rs @@ -279,6 +279,107 @@ where .await } +/// Encode payloads as length-delimited frames and drive `app`. +/// +/// This helper wraps each payload using the default length-delimited framing +/// format before sending it to the application. +/// +/// ```rust +/// # use wireframe_testing::drive_with_payloads; +/// # use wireframe::app::WireframeApp; +/// # async fn demo() -> std::io::Result<()> { +/// let app = WireframeApp::new().expect("failed to initialize app"); +/// let out = drive_with_payloads(app, vec![vec![1], vec![2]]).await?; +/// # let _ = out; +/// # Ok(()) +/// # } +/// ``` +pub async fn drive_with_payloads( + app: WireframeApp, + payloads: Vec>, +) -> io::Result> +where + S: TestSerializer, + C: Send + 'static, + E: Packet, +{ + drive_with_payloads_with_capacity(app, payloads, DEFAULT_CAPACITY).await +} + +/// Encode payloads as length-delimited frames and drive a mutable `app`. +/// +/// ```rust +/// # use wireframe_testing::drive_with_payloads_mut; +/// # use wireframe::app::WireframeApp; +/// # async fn demo() -> std::io::Result<()> { +/// let mut app = WireframeApp::new().expect("failed to initialize app"); +/// let out = drive_with_payloads_mut(&mut app, vec![vec![1], vec![2]]).await?; +/// # let _ = out; +/// # Ok(()) +/// # } +/// ``` +pub async fn drive_with_payloads_mut( + app: &mut WireframeApp, + payloads: Vec>, +) -> io::Result> +where + S: TestSerializer, + C: Send + 'static, + E: Packet, +{ + drive_with_payloads_with_capacity_mut(app, payloads, DEFAULT_CAPACITY).await +} + +fn encode_payloads( + payloads: Vec>, + mut codec: LengthDelimitedCodec, +) -> io::Result>> { + payloads + .into_iter() + .map(|payload| { + let header_len = LengthFormat::default().bytes(); + let mut buf = BytesMut::with_capacity(payload.len() + header_len); + codec.encode(payload.into(), &mut buf).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("frame encode failed: {err}"), + ) + })?; + Ok(buf.to_vec()) + }) + .collect() +} + +async fn drive_with_payloads_with_capacity( + app: WireframeApp, + payloads: Vec>, + capacity: usize, +) -> io::Result> +where + S: TestSerializer, + C: Send + 'static, + E: Packet, +{ + let codec = new_test_codec(DEFAULT_CAPACITY); + let frames = encode_payloads(payloads, codec)?; + drive_with_frames_with_capacity(app, frames, capacity).await +} + +async fn drive_with_payloads_with_capacity_mut( + app: &mut WireframeApp, + payloads: Vec>, + capacity: usize, +) -> io::Result> +where + S: TestSerializer, + C: Send + 'static, + E: Packet, +{ + let codec = new_test_codec(DEFAULT_CAPACITY); + let frames = encode_payloads(payloads, codec)?; + drive_with_frames_with_capacity_mut(app, frames, capacity).await +} + forward_default! { /// Feed a single frame into a mutable `app`, allowing the instance to be reused /// across calls. @@ -472,7 +573,13 @@ where #[cfg(test)] mod tests { - use wireframe::app::WireframeApp; + use std::sync::Arc; + + use wireframe::{ + Serializer, + app::{Envelope, WireframeApp}, + serializer::BincodeSerializer, + }; use super::*; @@ -493,6 +600,28 @@ mod tests { .expect_err("capacity beyond max should error"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } + + #[tokio::test] + async fn drive_with_payloads_wraps_frames() -> io::Result<()> { + let app = WireframeApp::new()?.route(1, Arc::new(|_: &Envelope| Box::pin(async {})))?; + let serializer = BincodeSerializer::default(); + let payload = vec![1_u8, 2, 3]; + let env = Envelope::new(1, Some(7), payload.clone()); + let encoded = serializer + .serialize(&env) + .expect("failed to serialize envelope"); + + let out = drive_with_payloads(app, vec![encoded]).await?; + let frames = decode_frames(out); + let [first] = frames.as_slice() else { + panic!("expected a single response frame"); + }; + let (decoded, _) = serializer + .deserialize::(first) + .expect("failed to deserialise envelope"); + assert_eq!(decoded.payload, payload, "payload mismatch"); + Ok(()) + } } /// Run `app` against an empty duplex stream. diff --git a/wireframe_testing/src/lib.rs b/wireframe_testing/src/lib.rs index 4244d5bc..7ac614ee 100644 --- a/wireframe_testing/src/lib.rs +++ b/wireframe_testing/src/lib.rs @@ -34,6 +34,8 @@ pub use helpers::{ drive_with_frames, drive_with_frames_mut, drive_with_frames_with_capacity, + drive_with_payloads, + drive_with_payloads_mut, encode_frame, new_test_codec, run_app,