diff --git a/docs/adr-004-pluggable-protocol-codecs.md b/docs/adr-004-pluggable-protocol-codecs.md index fc71bf2d..7d955c8f 100644 --- a/docs/adr-004-pluggable-protocol-codecs.md +++ b/docs/adr-004-pluggable-protocol-codecs.md @@ -340,6 +340,33 @@ Roadmap item 9.6.1 is now covered by dedicated criterion benchmark targets: The benchmark harness remains internal: no library runtime API changes were required to ship these measurements. +### Codec-aware test harness drivers (resolved 2026-02-26) + +Roadmap item 9.7.1 extended `wireframe_testing` with codec-aware driver +functions that accept any `FrameCodec` and handle frame encoding/decoding +transparently: + +- **Payload-level drivers** (`drive_with_codec_payloads` and variants) accept + raw payload byte vectors, encode them through the codec, drive the app, and + return decoded payload bytes. This covers the common case where tests care + only about payload content. +- **Frame-level drivers** (`drive_with_codec_frames` and variants) follow the + same flow but return decoded `F::Frame` values, enabling tests to inspect + codec-specific metadata (e.g. transaction identifiers, sequence numbers). +- **Codec helpers** (`encode_payloads_with_codec`, `decode_frames_with_codec`, + `extract_payloads`) provide composable building blocks for custom test + patterns. + +Design choices: + +- The codec is passed explicitly as a `&F` parameter rather than extracted from + the app. This avoids coupling to `WireframeApp` field visibility and mirrors + the existing `payloads.rs` pattern. +- No new trait is introduced; the existing `TestSerializer` bound captures + serializer constraints while `F: FrameCodec` is added as an orthogonal + generic parameter. +- A `codec()` accessor was added to `WireframeApp` for convenience. + ## Architectural Rationale A dedicated `FrameCodec` abstraction aligns framing with the protocol boundary diff --git a/docs/execplans/9-7-1-codec-aware-wireframe-testing.md b/docs/execplans/9-7-1-codec-aware-wireframe-testing.md new file mode 100644 index 00000000..45375022 --- /dev/null +++ b/docs/execplans/9-7-1-codec-aware-wireframe-testing.md @@ -0,0 +1,588 @@ +# 9.7.1 Extend wireframe\_testing with codec-aware test drivers + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, +`Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work +proceeds. + +Status: COMPLETE + +## Purpose / big picture + +The `wireframe_testing` crate provides in-memory test drivers that spin up a +`WireframeApp` on a `tokio::io::duplex` stream and return the bytes written by +the app. Today every driver is hard-wired to the default +`LengthDelimitedFrameCodec`. A test author wanting to exercise a custom +`FrameCodec` (e.g. `HotlineFrameCodec`, `MysqlFrameCodec`, or any user-defined +codec) must manually create a duplex, spawn the app, encode frames with the +codec's encoder, collect raw output, and decode with the codec's decoder. This +is roughly 30 lines of boilerplate per test. + +After this change a test author can write: + +```rust +use wireframe::codec::examples::HotlineFrameCodec; +use wireframe_testing::drive_with_codec_payloads; + +let codec = HotlineFrameCodec::new(4096); +let app = WireframeApp::new()?.with_codec(codec.clone()); +let response_payloads = drive_with_codec_payloads( + app, &codec, vec![payload_bytes], +).await?; +``` + +The codec-aware drivers handle frame encoding, transport, and decoding +internally, returning decoded payload bytes (or, for the frame-level variant, +decoded `F::Frame` values for metadata inspection). + +Observable success: running `make test` passes, including new unit tests in +`wireframe_testing` and new rstest-bdd behavioural scenarios that exercise the +codec-aware drivers with `HotlineFrameCodec`. + +## Constraints + +- All existing `wireframe_testing` public APIs must remain source-compatible. + The new functions are purely additive. +- No file may exceed 400 lines. +- Use `rstest` for new unit tests and `rstest-bdd` v0.5.0 for new behavioural + tests. +- Use existing in-tree codecs (`HotlineFrameCodec` from + `wireframe::codec::examples`) rather than defining new codec types. +- en-GB-oxendict spelling in all comments and documentation. +- Strict Clippy with `-D warnings` on all targets including tests. +- Every module must begin with a `//!` doc comment. +- Public functions must have `///` doc comments with examples. +- Quality gates (`make check-fmt`, `make lint`, `make test`) must pass before + completion. + +## Tolerances (exception triggers) + +- Scope: if implementation requires more than 15 new/modified files or 1,200 + net changed lines, stop and re-scope. +- Dependencies: if any new external crate is required beyond what is already in + `wireframe_testing/Cargo.toml`, stop and escalate. +- Interface: if a public API signature on the main `wireframe` crate must + change beyond an additive accessor, stop and escalate. +- Iterations: if the same failing root cause persists after 3 fix attempts, + stop and document options in the Decision Log. + +## Risks + +- Risk: `drive_internal` is `pub(super)`, so a new sibling module + `codec_drive.rs` under `helpers/` can call it directly. If the module + hierarchy changes, this access path breaks. Severity: low. Likelihood: low. + Mitigation: the new module lives alongside `drive.rs` under `helpers/`, + matching the existing pattern. + +- Risk: the `FrameCodec` trait's `Encoder`/`Decoder` associated types are + opaque; codec-aware decode may fail on partial frames if the duplex buffer is + too small. Severity: medium. Likelihood: medium. Mitigation: default capacity + is 4096 bytes (matching existing helpers); document that callers should + increase capacity for large-frame codecs. + +- Risk: adding a `pub fn codec(&self) -> &F` accessor to `WireframeApp` is a + minor public API addition to the main crate. Severity: low. Likelihood: + certain. Mitigation: this is a non-breaking, additive accessor. It enables + convenient codec reuse in tests without requiring the caller to hold a + separate clone. Document in `docs/users-guide.md`. + +## Progress + +- [x] (2026-02-26) Drafted ExecPlan. +- [x] (2026-02-26) Stage A: add `codec()` accessor to `WireframeApp`. +- [x] (2026-02-26) Stage B: implement codec-aware encode/decode helpers in + `wireframe_testing`. +- [x] (2026-02-26) Stage C: implement codec-aware driver functions. +- [x] (2026-02-26) Stage D: add rstest unit tests for the new drivers. +- [x] (2026-02-26) Stage E: add rstest-bdd behavioural tests. +- [x] (2026-02-26) Stage F: documentation and roadmap updates. +- [x] (2026-02-26) Stage G: full validation and evidence capture. + +## Surprises & discoveries + +- `WireframeApp` does not implement `Debug`. The behaviour-driven + development (BDD) world struct `CodecTestHarnessWorld` stores an + `Option>`, so `#[derive(Debug)]` cannot be used. Resolved + with a manual `Debug` implementation that redacts the app field. + +- `wireframe_testing` is a dev-dependency of the main crate, not a workspace + member. Internal `#[cfg(test)]` modules within `wireframe_testing` do not run + via `cargo test` from the workspace root. Resolved by placing the unit tests + in `tests/codec_test_harness.rs` as an integration test file instead. + +- `WireframeApp::new()` returns a `Result` whose `Ok` variant uses + `LengthDelimitedFrameCodec` as the default codec. When chaining + `.with_codec()` immediately, the compiler cannot infer the initial codec type + parameter. Resolved by writing + `WireframeApp::::new()`. + +- Clippy `panic_in_result_fn` lint fires on test functions that return + `io::Result<()>` and use `assert_eq!`/`assert!`. Resolved by replacing + assertions with explicit `if` checks returning `Err(io::Error::other(...))`. + +## Decision log + +- Decision: accept the codec as an explicit `&F` parameter rather than + extracting it from the app. Rationale: `WireframeApp.codec` is + `pub(in crate::app)` and not accessible from `wireframe_testing`. While we + add a `codec()` accessor for convenience, the driver API takes the codec + explicitly so it works regardless of whether the caller holds the app or a + reference. This also mirrors the existing `payloads.rs` pattern where the + codec is constructed independently. Date/Author: 2026-02-26 / Plan phase. + +- Decision: provide two levels of codec-aware drivers — payload-level + (returns `Vec>`) and frame-level (returns `Vec`). + Rationale: payload-level covers the common case (90% of tests care about + payload bytes, not codec metadata). Frame-level covers advanced tests needing + to inspect correlation IDs, sequence numbers, or other frame-level metadata. + Date/Author: 2026-02-26 / Plan phase. + +- Decision: do not introduce a new `TestCodecSerializer` trait. + Rationale: the codec generic `F` is orthogonal to the serializer generic `S`. + The existing `TestSerializer` trait already captures serializer bounds. The + new drivers simply add `F: FrameCodec` as an additional generic parameter. + Date/Author: 2026-02-26 / Plan phase. + +## Outcomes & retrospective + +Completed 2026-02-26. All quality gates pass: + +- `make fmt` — clean (no formatting changes) +- `make check-fmt` — clean +- `make lint` — clean (cargo doc + clippy with `-D warnings`) +- `make test` — all green (0 failures) + +New test coverage: + +- 6 integration tests in `tests/codec_test_harness.rs` (encode round-trip, + empty decode, extract payloads, payload driver round-trip, frame metadata + preservation, mutable app reuse). +- 2 BDD scenarios in `tests/scenarios/codec_test_harness_scenarios.rs` + (payload round-trip, frame-level metadata). + +Files created: 7 (codec\_ext.rs, codec\_drive.rs, codec\_test\_harness.rs, +codec\_test\_harness.feature, codec\_test\_harness fixture/steps/scenarios). +Files modified: 10 (codec.rs accessor, helpers.rs, lib.rs re-exports, BDD mod +files, ADR, users-guide, roadmap). + +The Stage D plan originally called for tests inside +`wireframe_testing/src/helpers/tests/`, but the crate is not a workspace member +so internal tests never execute. Moving them to `tests/` as an integration test +was the correct approach. Future ExecPlans targeting `wireframe_testing` +internals should account for this constraint. + +## Context and orientation + +### Repository layout (relevant subset) + +```plaintext +src/ + codec.rs # FrameCodec trait (line 59-103) + codec/examples.rs # HotlineFrameCodec, MysqlFrameCodec + app/builder/core.rs # WireframeApp struct (line 29-50) + app/builder/codec.rs # with_codec(), serializer(), buffer_capacity() + app/inbound_handler.rs # handle_connection[_result]() for any F: FrameCodec + +wireframe_testing/ + Cargo.toml # dev-dependency on wireframe (path = "..") + src/lib.rs # public re-exports + src/helpers.rs # TestSerializer trait, module root, constants + src/helpers/drive.rs # drive_internal (pub(super)), drive_with_frame[s][_mut] + src/helpers/payloads.rs # drive_with_payloads, drive_with_bincode + src/helpers/codec.rs # new_test_codec, decode_frames, encode_frame + src/helpers/runtime.rs # run_app, run_with_duplex_server + src/helpers/tests/ # internal unit tests + +tests/ + fixtures/mod.rs # BDD world fixtures + steps/mod.rs # BDD step definitions + scenarios/mod.rs # BDD scenario registrations + features/ # Gherkin .feature files + fixtures/codec_stateful.rs # Existing codec BDD world (reference pattern) +``` + +### Key types + +`WireframeApp` is the central application builder, generic over +serializer (`S`), connection context (`C`), envelope/packet type (`E`), and +frame codec (`F`). The codec defaults to `LengthDelimitedFrameCodec`. + +`FrameCodec` (defined in `src/codec.rs:59-103`) requires +`Send + Sync + Clone + 'static` and has associated types `Frame`, `Decoder`, +`Encoder` with methods `decoder()`, `encoder()`, `frame_payload()`, +`wrap_payload()`, `correlation_id()`, and `max_frame_length()`. + +`drive_internal` (in `wireframe_testing/src/helpers/drive.rs:31-72`) is the +low-level transport function: it takes a `server_fn: FnOnce(DuplexStream)`, raw +`Vec>` frames, and a capacity, writes the frames to the client half, +and returns the raw bytes produced by the server half. All existing drivers +delegate to it. It is `pub(super)`, accessible from sibling modules under +`helpers/`. + +### BDD test pattern + +The project uses rstest-bdd v0.5.0. A BDD test domain consists of four files: + +1. `tests/features/.feature` — Gherkin scenarios. +2. `tests/fixtures/.rs` — a world struct with `#[fixture]` function. +3. `tests/steps/_steps.rs` — `#[given]`, `#[when]`, `#[then]` + functions that mutate the world. +4. `tests/scenarios/_scenarios.rs` — `#[scenario]` functions binding + feature file to steps. + +Steps use `tokio::runtime::Runtime::new()?.block_on(...)` for async. Worlds are +plain structs implementing `Default`. Each must be wired into +`tests/fixtures/mod.rs`, `tests/steps/mod.rs`, and `tests/scenarios/mod.rs`. + +## Plan of work + +### Stage A: add `codec()` accessor to `WireframeApp` + +Add a public method to `src/app/builder/codec.rs` within the existing +`impl WireframeApp` block (line 10-38): + +```rust +/// Return a reference to the configured frame codec. +pub fn codec(&self) -> &F { &self.codec } +``` + +This is a one-line additive change. It enables test code (and library +consumers) to access the codec instance without cloning. The change goes in the +existing impl block that already has the correct bounds. + +Stage A acceptance: `make check-fmt && make lint && make test` pass. The +accessor is callable from an external crate. + +### Stage B: implement codec-aware encode/decode helpers + +Create `wireframe_testing/src/helpers/codec_ext.rs` with generic functions that +encode payloads into raw bytes and decode raw bytes into frames or payloads +using any `FrameCodec`. + +Functions to implement: + +1. `encode_payloads_with_codec` — for each payload, call + `codec.wrap_payload(Bytes::from(payload))` then encode the resulting frame + using `codec.encoder()` into a `BytesMut`, returning the raw bytes for each + encoded frame. + +2. `decode_frames_with_codec` — feed the raw bytes into + `codec.decoder()` and collect all decoded frames. + +3. `extract_payloads` — call `F::frame_payload(frame).to_vec()` + for each frame. + +These are pure, testable functions with no async or side effects. + +Register the module in `wireframe_testing/src/helpers.rs` as `mod codec_ext;` +and re-export the three functions via `pub use codec_ext::{...};`. + +Stage B acceptance: `make check-fmt && make lint` pass. Functions compile and +are accessible from the crate root. + +### Stage C: implement codec-aware driver functions + +Create `wireframe_testing/src/helpers/codec_drive.rs` with the following public +async functions. All delegate to `drive_internal` from the sibling `drive` +module. + +**Payload-level drivers** (return `Vec>`): + +1. `drive_with_codec_payloads` +2. `drive_with_codec_payloads_with_capacity` +3. `drive_with_codec_payloads_mut` +4. `drive_with_codec_payloads_with_capacity_mut` + +**Frame-level drivers** (return `Vec`): + +1. `drive_with_codec_frames` +2. `drive_with_codec_frames_with_capacity` + +Generic bounds on all functions: +`S: TestSerializer, C: Send + 'static, E: Packet, F: FrameCodec`. + +Internal flow for payload-level drivers: + +```plaintext +encode_payloads_with_codec(codec, payloads) + -> Vec> (raw encoded frames) + -> drive_internal(|s| app.handle_connection(s), frames, capacity) + -> Vec (raw output bytes) + -> decode_frames_with_codec(codec, raw_output) + -> Vec + -> extract_payloads(frames) + -> Vec> +``` + +Frame-level drivers follow the same flow but return after the decode step +without extracting payloads. + +Add module declaration `mod codec_drive;` and re-exports in +`wireframe_testing/src/helpers.rs`. Add public re-exports in +`wireframe_testing/src/lib.rs`. + +Stage C acceptance: `make check-fmt && make lint` pass. Functions compile and +are accessible from the crate root. + +### Stage D: add integration tests + +Create `tests/codec_test_harness.rs` as an integration test file exercising the +new codec-aware drivers. (The tests live outside `wireframe_testing` because +the crate is a dev-dependency, not a workspace member — see Surprises.) + +Tests to implement: + +1. `encode_payloads_with_codec_produces_decodable_frames` — round-trip test: + encode payloads with `HotlineFrameCodec`, decode back, verify payloads match. + +2. `decode_frames_with_codec_handles_empty_input` — empty byte vector + produces an empty frame vector. + +3. `extract_payloads_returns_payload_bytes` — construct `HotlineFrame` + values manually, verify `extract_payloads` returns the correct byte slices. + +4. `drive_with_codec_payloads_round_trips_through_echo_app` — build a + `WireframeApp` with `HotlineFrameCodec` and an echo route, drive it, verify + payload bytes round-trip. + +5. `drive_with_codec_frames_preserves_codec_metadata` — build a + `WireframeApp` with `HotlineFrameCodec`, drive it, verify decoded + `HotlineFrame` values carry expected `transaction_id` values. + +6. `drive_with_codec_payloads_mut_allows_app_reuse` — drive a mutable app + reference twice, verify both calls succeed. + +Stage D acceptance: `make test` passes with the new tests green. + +### Stage E: add rstest-bdd behavioural tests + +Create four files for the `codec_test_harness` BDD domain: + +**`tests/features/codec_test_harness.feature`**: + +```gherkin +Feature: Codec-aware test harness drivers + The wireframe_testing crate provides codec-aware driver functions + that handle frame encoding and decoding transparently for any + FrameCodec implementation. + + Scenario: Payload round-trip through a custom codec driver + Given a wireframe app configured with a Hotline codec + When a test payload is driven through the codec-aware driver + Then the response payloads are non-empty + + Scenario: Frame-level driver preserves codec metadata + Given a wireframe app configured with a Hotline codec + When a test payload is driven through the frame-level driver + Then the response frames contain transaction identifiers +``` + +**`tests/fixtures/codec_test_harness.rs`**: A `CodecTestHarnessWorld` struct +holding an optional app, codec, response payloads, and response frames. +Provides async helper methods for building the app, driving it with the +payload-level driver, and driving it with the frame-level driver. Includes a +`#[fixture]` function `codec_test_harness_world`. + +**`tests/steps/codec_test_harness_steps.rs`**: Step definitions using +`#[given]`, `#[when]`, `#[then]` from `rstest_bdd_macros`. Each step wraps +async calls with `tokio::runtime::Runtime::new()?.block_on(...)`. + +**`tests/scenarios/codec_test_harness_scenarios.rs`**: Two `#[scenario]` +functions binding to the feature file. + +Wire the new module into: + +- `tests/fixtures/mod.rs` — add `pub mod codec_test_harness;` +- `tests/steps/mod.rs` — add `mod codec_test_harness_steps;` +- `tests/scenarios/mod.rs` — add `mod codec_test_harness_scenarios;` + +Stage E acceptance: `make test` passes with the new BDD scenarios green. + +### Stage F: documentation and roadmap updates + +1. Update `docs/adr-004-pluggable-protocol-codecs.md` with a design decision + recording the codec-aware test harness API and its rationale (explicit codec + parameter, payload vs frame level drivers, reuse of `drive_internal`). + +2. Update `docs/users-guide.md` with a section documenting: + - The new `codec()` accessor on `WireframeApp`. + - The new `drive_with_codec_payloads` and `drive_with_codec_frames` + families of functions in `wireframe_testing`. + - A short usage example. + +3. Mark roadmap item `9.7.1` as done in `docs/roadmap.md`: + change `- [ ] 9.7.1.` to `- [x] 9.7.1.` + +Stage F acceptance: documentation is internally consistent and +`make markdownlint` passes. + +### Stage G: full validation and evidence capture + +Run all quality gates with logging: + + +```shell +set -o pipefail; make fmt 2>&1 | tee /tmp/9-7-1-fmt.log +set -o pipefail; make check-fmt 2>&1 | tee /tmp/9-7-1-check-fmt.log +set -o pipefail; make markdownlint 2>&1 | tee /tmp/9-7-1-markdownlint.log +set -o pipefail; make lint 2>&1 | tee /tmp/9-7-1-lint.log +set -o pipefail; make test 2>&1 | tee /tmp/9-7-1-test.log +``` + + +Update the `Progress` and `Outcomes & Retrospective` sections with final +evidence and timestamps. + +Stage G acceptance: all commands exit 0. + +## Validation and acceptance + +Quality criteria (what "done" means): + +- Tests: `make test` passes, including the new integration tests in + `tests/codec_test_harness.rs` and the new rstest-bdd scenarios in + `tests/scenarios/codec_test_harness_scenarios.rs`. +- Lint: `make lint` passes (Clippy with `-D warnings` on all targets). +- Format: `make check-fmt` passes. +- Markdown: `make markdownlint` passes. +- Documentation: `docs/users-guide.md` documents the new public API. + `docs/roadmap.md` item 9.7.1 is marked done. + +Quality method: + +- Run the shell commands in Stage G and verify all exit 0. +- Verify the new BDD scenarios pass: `cargo test codec_test_harness`. +- Verify the new integration tests pass: `cargo test codec_test_harness`. + +## Idempotence and recovery + +All stages produce additive changes. If a stage fails partway through, the +incomplete changes can be reverted with `git checkout -- .` and the stage +retried from the beginning. No destructive operations are involved. + +## Interfaces and dependencies + +### New public accessor (wireframe crate) + +In `src/app/builder/codec.rs`, within the existing impl block: + +```rust +/// Return a reference to the configured frame codec. +pub fn codec(&self) -> &F { &self.codec } +``` + +### New public functions (wireframe\_testing crate) + +In `wireframe_testing/src/helpers/codec_ext.rs`: + +```rust +pub fn encode_payloads_with_codec( + codec: &F, + payloads: Vec>, +) -> io::Result>>; + +pub fn decode_frames_with_codec( + codec: &F, + bytes: Vec, +) -> io::Result>; + +pub fn extract_payloads( + frames: &[F::Frame], +) -> Vec>; +``` + +In `wireframe_testing/src/helpers/codec_drive.rs`: + +```rust +pub async fn drive_with_codec_payloads( + app: WireframeApp, + codec: &F, + payloads: Vec>, +) -> io::Result>> +where + S: TestSerializer, C: Send + 'static, E: Packet, F: FrameCodec; + +pub async fn drive_with_codec_payloads_with_capacity( + app: WireframeApp, + codec: &F, + payloads: Vec>, + capacity: usize, +) -> io::Result>> +where + S: TestSerializer, C: Send + 'static, E: Packet, F: FrameCodec; + +pub async fn drive_with_codec_payloads_mut( + app: &mut WireframeApp, + codec: &F, + payloads: Vec>, +) -> io::Result>> +where + S: TestSerializer, C: Send + 'static, E: Packet, F: FrameCodec; + +pub async fn drive_with_codec_payloads_with_capacity_mut( + app: &mut WireframeApp, + codec: &F, + payloads: Vec>, + capacity: usize, +) -> io::Result>> +where + S: TestSerializer, C: Send + 'static, E: Packet, F: FrameCodec; + +pub async fn drive_with_codec_frames( + app: WireframeApp, + codec: &F, + payloads: Vec>, +) -> io::Result> +where + S: TestSerializer, C: Send + 'static, E: Packet, F: FrameCodec; + +pub async fn drive_with_codec_frames_with_capacity( + app: WireframeApp, + codec: &F, + payloads: Vec>, + capacity: usize, +) -> io::Result> +where + S: TestSerializer, C: Send + 'static, E: Packet, F: FrameCodec; +``` + +### Files to create + +| File | Purpose | Est. lines | +| ------------------------------------------------- | ----------------------------- | ---------- | +| `wireframe_testing/src/helpers/codec_ext.rs` | Generic encode/decode helpers | ~80 | +| `wireframe_testing/src/helpers/codec_drive.rs` | Codec-aware driver functions | ~200 | +| `tests/codec_test_harness.rs` | Integration tests | ~160 | +| `tests/features/codec_test_harness.feature` | BDD feature file | ~15 | +| `tests/fixtures/codec_test_harness.rs` | BDD world fixture | ~120 | +| `tests/steps/codec_test_harness_steps.rs` | BDD step definitions | ~50 | +| `tests/scenarios/codec_test_harness_scenarios.rs` | BDD scenario registrations | ~25 | + +### Files to modify + +| File | Change | +| ---------------------------------------------------------------- | ---------------------------------------------------- | +| `src/app/builder/codec.rs` | Add `pub fn codec(&self) -> &F` accessor | +| `wireframe_testing/src/helpers.rs` | Add `mod codec_ext; mod codec_drive;` and re-exports | +| `wireframe_testing/src/lib.rs` | Add re-exports for new public functions | +| (none — tests placed in `tests/codec_test_harness.rs` instead) | See Surprises | +| `tests/fixtures/mod.rs` | Add `pub mod codec_test_harness;` | +| `tests/steps/mod.rs` | Add `mod codec_test_harness_steps;` | +| `tests/scenarios/mod.rs` | Add `mod codec_test_harness_scenarios;` | +| `docs/adr-004-pluggable-protocol-codecs.md` | Add test harness design decision | +| `docs/users-guide.md` | Document new public API | +| `docs/roadmap.md` | Mark 9.7.1 done | + +## Artifacts and notes + +Reference pattern for BDD world: `tests/fixtures/codec_stateful.rs` — defines +`CodecStatefulWorld` with a `SeqFrameCodec`, server management, and frame +exchange helpers. The new `CodecTestHarnessWorld` follows the same pattern but +uses `wireframe_testing` codec-aware drivers instead of manual +`Framed`/`SinkExt` operations. + +Reference pattern for step definitions: `tests/steps/codec_stateful_steps.rs` — +wraps async calls with `tokio::runtime::Runtime::new()?.block_on(...)`. + +Reference pattern for scenarios: `tests/scenarios/codec_stateful_scenarios.rs` +— `#[scenario]` macro with `#[expect(unused_variables)]` attribute. diff --git a/docs/roadmap.md b/docs/roadmap.md index 00fd382c..920fc129 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -430,7 +430,7 @@ integration boundaries. ### 9.7. Codec test harness and observability -- [ ] 9.7.1. Extend `wireframe_testing` with codec-aware drivers that can run +- [x] 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. diff --git a/docs/users-guide.md b/docs/users-guide.md index f7e54a28..ab27c355 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -208,6 +208,59 @@ let app = WireframeApp::new()? See `examples/hotline_codec.rs` and `examples/mysql_codec.rs` for complete implementations. +#### Codec accessor + +Retrieve the configured codec from a `WireframeApp` instance: + +```rust,no_run +use wireframe::app::WireframeApp; +use wireframe::codec::examples::HotlineFrameCodec; + +let codec = HotlineFrameCodec::new(4096); +let app = WireframeApp::new()?.with_codec(codec); +let codec_ref = app.codec(); // &HotlineFrameCodec +``` + +#### Testing custom codecs with `wireframe_testing` + +The `wireframe_testing` crate provides codec-aware driver functions that handle +frame encoding and decoding transparently: + +```rust,no_run +use wireframe::app::WireframeApp; +use wireframe::codec::examples::HotlineFrameCodec; +use wireframe_testing::{drive_with_codec_payloads, drive_with_codec_frames}; + +let codec = HotlineFrameCodec::new(4096); +let payload: Vec = vec![0x01, 0x02, 0x03]; + +// Payload-level: returns decoded response payloads as byte vectors. +let app = WireframeApp::new()?.with_codec(codec.clone()); +let payloads = + drive_with_codec_payloads(app, &codec, vec![payload.clone()]).await?; + +// Frame-level: returns decoded codec frames for metadata inspection. +let app = WireframeApp::new()?.with_codec(codec.clone()); +let frames = + drive_with_codec_frames(app, &codec, vec![payload]).await?; +``` + +Available codec-aware driver functions: + +- `drive_with_codec_payloads` / `drive_with_codec_payloads_with_capacity` — + owned app, returns payload bytes. +- `drive_with_codec_payloads_mut` / + `drive_with_codec_payloads_with_capacity_mut` — mutable app reference, + returns payload bytes. +- `drive_with_codec_frames` / `drive_with_codec_frames_with_capacity` — owned + app, returns decoded `F::Frame` values. + +Supporting helpers for composing custom test patterns: + +- `encode_payloads_with_codec` — encode payloads to wire bytes. +- `decode_frames_with_codec` — decode wire bytes to frames. +- `extract_payloads` — extract payload bytes from decoded frames. + #### Zero-copy payload extraction For performance-critical codecs, use `Bytes` instead of `Vec` for payload diff --git a/src/app/builder/codec.rs b/src/app/builder/codec.rs index 98d49dcb..a48cc827 100644 --- a/src/app/builder/codec.rs +++ b/src/app/builder/codec.rs @@ -14,6 +14,9 @@ where E: Packet, F: FrameCodec, { + /// Return a reference to the configured frame codec. + pub fn codec(&self) -> &F { &self.codec } + /// Replace the frame codec used for framing I/O. /// /// This resets any installed protocol hooks because the frame type may diff --git a/tests/codec_test_harness.rs b/tests/codec_test_harness.rs new file mode 100644 index 00000000..ad492f1f --- /dev/null +++ b/tests/codec_test_harness.rs @@ -0,0 +1,161 @@ +//! Integration tests for the codec-aware test harness drivers in +//! `wireframe_testing`. +#![cfg(not(loom))] + +use std::{io, sync::Arc}; + +use bytes::Bytes; +use futures::future::BoxFuture; +use wireframe::{ + app::{Envelope, WireframeApp}, + codec::examples::{HotlineFrame, HotlineFrameCodec}, + serializer::{BincodeSerializer, Serializer}, +}; +use wireframe_testing::{ + decode_frames_with_codec, + drive_with_codec_frames, + drive_with_codec_payloads, + drive_with_codec_payloads_mut, + encode_payloads_with_codec, + extract_payloads, +}; + +fn hotline_codec() -> HotlineFrameCodec { HotlineFrameCodec::new(4096) } + +fn build_echo_app( + codec: HotlineFrameCodec, +) -> io::Result> { + WireframeApp::::new() + .map_err(|e| io::Error::other(format!("app init: {e}")))? + .with_codec(codec) + .route( + 1, + Arc::new(|_: &Envelope| -> BoxFuture<'static, ()> { Box::pin(async {}) }), + ) + .map_err(|e| io::Error::other(format!("route: {e}"))) +} + +fn serialize_envelope(payload: &[u8]) -> io::Result> { + let env = Envelope::new(1, Some(7), payload.to_vec()); + BincodeSerializer + .serialize(&env) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("serialize: {e}"))) +} + +#[test] +fn encode_payloads_with_codec_produces_decodable_frames() -> io::Result<()> { + let codec = hotline_codec(); + let payloads = vec![vec![1, 2, 3], vec![4, 5]]; + let encoded = encode_payloads_with_codec(&codec, payloads.clone())?; + + let wire: Vec = encoded.into_iter().flatten().collect(); + let frames = decode_frames_with_codec(&codec, wire)?; + + let extracted = extract_payloads::(&frames); + if extracted != payloads { + return Err(io::Error::other("round-trip payloads must match")); + } + Ok(()) +} + +#[test] +fn decode_frames_with_codec_handles_empty_input() -> io::Result<()> { + let codec = hotline_codec(); + let frames = decode_frames_with_codec(&codec, vec![])?; + if !frames.is_empty() { + return Err(io::Error::other("empty input should produce no frames")); + } + Ok(()) +} + +#[test] +fn extract_payloads_returns_payload_bytes() { + let frames = vec![ + HotlineFrame { + transaction_id: 1, + payload: Bytes::from_static(b"hello"), + }, + HotlineFrame { + transaction_id: 2, + payload: Bytes::from_static(b"world"), + }, + ]; + let payloads = extract_payloads::(&frames); + assert_eq!(payloads, vec![b"hello".to_vec(), b"world".to_vec()]); +} + +#[tokio::test] +async fn drive_with_codec_payloads_round_trips_through_echo_app() -> io::Result<()> { + let codec = hotline_codec(); + let app = build_echo_app(codec.clone())?; + + let payload = vec![10, 20, 30]; + let serialized = serialize_envelope(&payload)?; + + let response_payloads = drive_with_codec_payloads(app, &codec, vec![serialized]).await?; + + if response_payloads.len() != 1 { + return Err(io::Error::other(format!( + "expected one response payload, got {}", + response_payloads.len() + ))); + } + let first = response_payloads + .first() + .ok_or_else(|| io::Error::other("missing response payload"))?; + let (decoded, _) = BincodeSerializer + .deserialize::(first) + .map_err(|e| io::Error::other(format!("deserialize: {e}")))?; + if decoded.payload_bytes() != payload.as_slice() { + return Err(io::Error::other("payload mismatch")); + } + Ok(()) +} + +#[tokio::test] +async fn drive_with_codec_frames_preserves_codec_metadata() -> io::Result<()> { + let codec = hotline_codec(); + let app = build_echo_app(codec.clone())?; + + let serialized = serialize_envelope(&[42])?; + + let frames = drive_with_codec_frames(app, &codec, vec![serialized]).await?; + + if frames.len() != 1 { + return Err(io::Error::other(format!( + "expected one response frame, got {}", + frames.len() + ))); + } + let frame = frames + .first() + .ok_or_else(|| io::Error::other("missing response frame"))?; + // wrap_payload assigns transaction_id 0, confirming codec metadata flows + // through the driver pipeline. + if frame.transaction_id != 0 { + return Err(io::Error::other(format!( + "wrap_payload should assign transaction_id 0, got {}", + frame.transaction_id + ))); + } + Ok(()) +} + +#[tokio::test] +async fn drive_with_codec_payloads_mut_allows_app_reuse() -> io::Result<()> { + let codec = hotline_codec(); + let mut app = build_echo_app(codec.clone())?; + + let serialized = serialize_envelope(&[1])?; + + let first = drive_with_codec_payloads_mut(&mut app, &codec, vec![serialized.clone()]).await?; + if first.is_empty() { + return Err(io::Error::other("first call should produce output")); + } + + let second = drive_with_codec_payloads_mut(&mut app, &codec, vec![serialized]).await?; + if second.is_empty() { + return Err(io::Error::other("second call should produce output")); + } + Ok(()) +} diff --git a/tests/features/codec_test_harness.feature b/tests/features/codec_test_harness.feature new file mode 100644 index 00000000..31b66122 --- /dev/null +++ b/tests/features/codec_test_harness.feature @@ -0,0 +1,14 @@ +Feature: Codec-aware test harness drivers + The wireframe_testing crate provides codec-aware driver functions + that handle frame encoding and decoding transparently for any + FrameCodec implementation. + + Scenario: Payload round-trip through a custom codec driver + Given a wireframe app configured with a Hotline codec allowing frames up to 4096 bytes + When a test payload is driven through the codec-aware payload driver + Then the response payloads are non-empty + + Scenario: Frame-level driver preserves codec metadata + Given a wireframe app configured with a Hotline codec allowing frames up to 4096 bytes + When a test payload is driven through the codec-aware frame driver + Then the response frames contain valid transaction identifiers diff --git a/tests/fixtures/codec_test_harness.rs b/tests/fixtures/codec_test_harness.rs new file mode 100644 index 00000000..4e4f08f7 --- /dev/null +++ b/tests/fixtures/codec_test_harness.rs @@ -0,0 +1,132 @@ +//! Test world for codec-aware test harness behavioural scenarios. +//! +//! Exercises the `wireframe_testing` codec-aware driver API with +//! `HotlineFrameCodec` to verify transparent encoding and decoding. + +use std::sync::Arc; + +use futures::future::BoxFuture; +use rstest::fixture; +use wireframe::{ + app::{Envelope, WireframeApp}, + codec::examples::{HotlineFrame, HotlineFrameCodec}, + serializer::{BincodeSerializer, Serializer}, +}; +/// Re-export `TestResult` from `wireframe_testing` for use in steps. +pub use wireframe_testing::TestResult; + +/// BDD world holding the app, codec, and collected responses. +/// +/// `WireframeApp` does not implement `Debug`, so this type provides a manual +/// implementation that redacts the app field. +#[derive(Default)] +pub struct CodecTestHarnessWorld { + codec: Option, + app: Option>, + response_payloads: Vec>, + response_frames: Vec, +} + +impl std::fmt::Debug for CodecTestHarnessWorld { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CodecTestHarnessWorld") + .field("codec", &self.codec) + .field("app", &self.app.as_ref().map(|_| "..")) + .field("response_payloads", &self.response_payloads.len()) + .field("response_frames", &self.response_frames.len()) + .finish() + } +} + +/// Fixture for codec test harness scenarios used by rstest-bdd steps. +/// +/// Note: rustfmt collapses simple fixtures into one line, which triggers +/// `unused_braces`, so keep `rustfmt::skip`. +#[rustfmt::skip] +#[fixture] +pub fn codec_test_harness_world() -> CodecTestHarnessWorld { + CodecTestHarnessWorld::default() +} + +impl CodecTestHarnessWorld { + /// Configure the app with a `HotlineFrameCodec`. + /// + /// # Errors + /// Returns an error if the app or route registration fails. + pub fn configure_app(&mut self, max_frame_length: usize) -> TestResult { + let codec = HotlineFrameCodec::new(max_frame_length); + let app = WireframeApp::::new()? + .with_codec(codec.clone()) + .route( + 1, + Arc::new(|_: &Envelope| -> BoxFuture<'static, ()> { Box::pin(async {}) }), + )?; + self.codec = Some(codec); + self.app = Some(app); + Ok(()) + } + + /// Drive the app through the payload-level codec driver. + /// + /// # Errors + /// Returns an error if serialization or the driver fails. + pub async fn drive_payload(&mut self) -> TestResult { + let app = self.app.take().ok_or("app not configured")?; + let codec = self.codec.as_ref().ok_or("codec not configured")?; + + let env = Envelope::new(1, Some(7), b"bdd-test".to_vec()); + let serialized = BincodeSerializer.serialize(&env)?; + + self.response_payloads = + wireframe_testing::drive_with_codec_payloads(app, codec, vec![serialized]).await?; + Ok(()) + } + + /// Drive the app through the frame-level codec driver. + /// + /// # Errors + /// Returns an error if serialization or the driver fails. + pub async fn drive_frames(&mut self) -> TestResult { + let app = self.app.take().ok_or("app not configured")?; + let codec = self.codec.as_ref().ok_or("codec not configured")?; + + let env = Envelope::new(1, Some(7), b"bdd-frame-test".to_vec()); + let serialized = BincodeSerializer.serialize(&env)?; + + self.response_frames = + wireframe_testing::drive_with_codec_frames(app, codec, vec![serialized]).await?; + Ok(()) + } + + /// Verify that payload responses are non-empty. + /// + /// # Errors + /// Returns an error if the assertion fails. + pub fn verify_payloads_non_empty(&self) -> TestResult { + if self.response_payloads.is_empty() { + return Err("expected non-empty response payloads".into()); + } + Ok(()) + } + + /// Verify that response frames contain valid transaction identifiers. + /// + /// # Errors + /// Returns an error if no frames were received. + pub fn verify_frames_have_transaction_ids(&self) -> TestResult { + if self.response_frames.is_empty() { + return Err("expected at least one response frame".into()); + } + // wrap_payload assigns transaction_id = 0; confirming the field is + // present in the decoded frame proves codec metadata survives the + // driver pipeline. + for frame in &self.response_frames { + if frame.transaction_id != 0 { + return Err( + format!("expected transaction_id 0, got {}", frame.transaction_id).into(), + ); + } + } + Ok(()) + } +} diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index c55ae4ad..2d41f97e 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -13,6 +13,7 @@ pub mod codec_error; pub mod codec_performance_benchmarks; pub mod codec_property_roundtrip; pub mod codec_stateful; +pub mod codec_test_harness; pub mod correlation; pub mod fragment; pub mod interleaved_push_queues; diff --git a/tests/scenarios/codec_test_harness_scenarios.rs b/tests/scenarios/codec_test_harness_scenarios.rs new file mode 100644 index 00000000..7e36a7d4 --- /dev/null +++ b/tests/scenarios/codec_test_harness_scenarios.rs @@ -0,0 +1,25 @@ +//! Scenario tests for codec-aware test harness behaviours. + +use rstest_bdd_macros::scenario; + +use crate::fixtures::codec_test_harness::*; + +#[scenario( + path = "tests/features/codec_test_harness.feature", + name = "Payload round-trip through a custom codec driver" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn payload_round_trip(codec_test_harness_world: CodecTestHarnessWorld) {} + +#[scenario( + path = "tests/features/codec_test_harness.feature", + name = "Frame-level driver preserves codec metadata" +)] +#[expect( + unused_variables, + reason = "rstest-bdd wires steps via parameters without using them directly" +)] +fn frame_level_metadata(codec_test_harness_world: CodecTestHarnessWorld) {} diff --git a/tests/scenarios/mod.rs b/tests/scenarios/mod.rs index d7ea76c9..babd696d 100644 --- a/tests/scenarios/mod.rs +++ b/tests/scenarios/mod.rs @@ -17,6 +17,7 @@ mod codec_error_scenarios; mod codec_performance_benchmarks_scenarios; mod codec_property_roundtrip_scenarios; mod codec_stateful_scenarios; +mod codec_test_harness_scenarios; mod correlation_scenarios; mod fragment_scenarios; mod interleaved_push_queues_scenarios; diff --git a/tests/steps/codec_test_harness_steps.rs b/tests/steps/codec_test_harness_steps.rs new file mode 100644 index 00000000..96020f54 --- /dev/null +++ b/tests/steps/codec_test_harness_steps.rs @@ -0,0 +1,40 @@ +//! Step definitions for codec-aware test harness behavioural tests. + +use rstest_bdd_macros::{given, then, when}; + +use crate::fixtures::codec_test_harness::{CodecTestHarnessWorld, TestResult}; + +#[given( + "a wireframe app configured with a Hotline codec allowing frames up to \ + {max_frame_length:usize} bytes" +)] +fn given_app_with_hotline_codec( + codec_test_harness_world: &mut CodecTestHarnessWorld, + max_frame_length: usize, +) -> TestResult { + codec_test_harness_world.configure_app(max_frame_length) +} + +#[when("a test payload is driven through the codec-aware payload driver")] +fn when_payload_driven(codec_test_harness_world: &mut CodecTestHarnessWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_test_harness_world.drive_payload()) +} + +#[when("a test payload is driven through the codec-aware frame driver")] +fn when_frame_driven(codec_test_harness_world: &mut CodecTestHarnessWorld) -> TestResult { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(codec_test_harness_world.drive_frames()) +} + +#[then("the response payloads are non-empty")] +fn then_payloads_non_empty(codec_test_harness_world: &mut CodecTestHarnessWorld) -> TestResult { + codec_test_harness_world.verify_payloads_non_empty() +} + +#[then("the response frames contain valid transaction identifiers")] +fn then_frames_have_transaction_ids( + codec_test_harness_world: &mut CodecTestHarnessWorld, +) -> TestResult { + codec_test_harness_world.verify_frames_have_transaction_ids() +} diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index 57efc70d..0b60065c 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -13,6 +13,7 @@ mod codec_error_steps; mod codec_performance_benchmarks_steps; mod codec_property_roundtrip_steps; mod codec_stateful_steps; +mod codec_test_harness_steps; mod correlation_steps; mod fragment_steps; mod interleaved_push_queues_steps; diff --git a/wireframe_testing/Cargo.lock b/wireframe_testing/Cargo.lock index 93496afd..d16e396e 100644 --- a/wireframe_testing/Cargo.lock +++ b/wireframe_testing/Cargo.lock @@ -1831,6 +1831,7 @@ dependencies = [ "logtest", "metrics-util", "rstest", + "thiserror", "tokio", "tokio-util", "wireframe", diff --git a/wireframe_testing/src/helpers.rs b/wireframe_testing/src/helpers.rs index a3dece4c..2178c48e 100644 --- a/wireframe_testing/src/helpers.rs +++ b/wireframe_testing/src/helpers.rs @@ -10,6 +10,8 @@ use wireframe::{ }; mod codec; +mod codec_drive; +mod codec_ext; mod drive; mod payloads; mod runtime; @@ -45,6 +47,15 @@ pub(crate) const EMPTY_SERVER_CAPACITY: usize = 64; pub const TEST_MAX_FRAME: usize = DEFAULT_CAPACITY; pub use codec::{decode_frames, decode_frames_with_max, encode_frame, new_test_codec}; +pub use codec_drive::{ + drive_with_codec_frames, + drive_with_codec_frames_with_capacity, + drive_with_codec_payloads, + drive_with_codec_payloads_mut, + drive_with_codec_payloads_with_capacity, + drive_with_codec_payloads_with_capacity_mut, +}; +pub use codec_ext::{decode_frames_with_codec, encode_payloads_with_codec, extract_payloads}; pub use drive::{ drive_with_frame, drive_with_frame_mut, diff --git a/wireframe_testing/src/helpers/codec_drive.rs b/wireframe_testing/src/helpers/codec_drive.rs new file mode 100644 index 00000000..ba1386eb --- /dev/null +++ b/wireframe_testing/src/helpers/codec_drive.rs @@ -0,0 +1,279 @@ +//! Codec-aware in-memory driving helpers. +//! +//! These functions extend the frame-oriented drivers in [`super::drive`] with +//! automatic encoding and decoding through an arbitrary [`FrameCodec`]. Test +//! authors pass raw payloads and receive decoded payloads (or frames) without +//! manually constructing encoders or decoders. + +use std::io; + +use tokio::io::DuplexStream; +use wireframe::{ + app::{Packet, WireframeApp}, + codec::FrameCodec, +}; + +use super::{ + DEFAULT_CAPACITY, + TestSerializer, + codec_ext::{decode_frames_with_codec, encode_payloads_with_codec, extract_payloads}, + drive::drive_internal, +}; + +// --------------------------------------------------------------------------- +// Shared internal helper +// --------------------------------------------------------------------------- + +/// Encode payloads, drive the server handler, and decode response frames. +/// +/// Every public driver in this module delegates to this function. Keeping the +/// encode → transport → decode pipeline in one place prevents behavioural +/// drift between the owned and mutable variants. +async fn drive_codec_frames_internal( + handler: H, + codec: &F, + payloads: Vec>, + capacity: usize, +) -> io::Result> +where + F: FrameCodec, + H: FnOnce(DuplexStream) -> Fut, + Fut: std::future::Future + Send, +{ + let encoded = encode_payloads_with_codec(codec, payloads)?; + let raw = drive_internal(handler, encoded, capacity).await?; + decode_frames_with_codec(codec, raw) +} + +// --------------------------------------------------------------------------- +// Payload-level drivers (return Vec>) +// --------------------------------------------------------------------------- + +/// Drive `app` with payloads encoded by `codec` and return decoded response +/// payloads. +/// +/// Each input payload is wrapped and encoded using the codec, sent through an +/// in-memory duplex stream, and the server's response bytes are decoded back +/// into payload byte vectors. +/// +/// # Errors +/// +/// Returns any I/O or codec error encountered during encoding, transport, or +/// decoding. +/// +/// ```rust +/// # use wireframe::app::WireframeApp; +/// # use wireframe::codec::examples::HotlineFrameCodec; +/// # use wireframe_testing::drive_with_codec_payloads; +/// # async fn demo() -> std::io::Result<()> { +/// let codec = HotlineFrameCodec::new(4096); +/// let app = WireframeApp::new().expect("app").with_codec(codec.clone()); +/// let payloads = drive_with_codec_payloads(app, &codec, vec![vec![1]]).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn drive_with_codec_payloads( + app: WireframeApp, + codec: &F, + payloads: Vec>, +) -> io::Result>> +where + S: TestSerializer, + C: Send + 'static, + E: Packet, + F: FrameCodec, +{ + drive_with_codec_payloads_with_capacity(app, codec, payloads, DEFAULT_CAPACITY).await +} + +/// Drive `app` with payloads using a duplex buffer of `capacity` bytes. +/// +/// # Errors +/// +/// Returns any I/O or codec error encountered during encoding, transport, or +/// decoding. +/// +/// ```rust +/// # use wireframe::app::WireframeApp; +/// # use wireframe::codec::examples::HotlineFrameCodec; +/// # use wireframe_testing::drive_with_codec_payloads_with_capacity; +/// # async fn demo() -> std::io::Result<()> { +/// let codec = HotlineFrameCodec::new(4096); +/// let app = WireframeApp::new().expect("app").with_codec(codec.clone()); +/// let payloads = +/// drive_with_codec_payloads_with_capacity(app, &codec, vec![vec![1]], 8192).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn drive_with_codec_payloads_with_capacity( + app: WireframeApp, + codec: &F, + payloads: Vec>, + capacity: usize, +) -> io::Result>> +where + S: TestSerializer, + C: Send + 'static, + E: Packet, + F: FrameCodec, +{ + let frames = drive_with_codec_frames_with_capacity(app, codec, payloads, capacity).await?; + Ok(extract_payloads::(&frames)) +} + +/// Drive a mutable `app` with payloads encoded by `codec`. +/// +/// The mutable reference allows the app instance to be reused across +/// successive calls. +/// +/// # Errors +/// +/// Returns any I/O or codec error encountered during encoding, transport, or +/// decoding. +/// +/// ```rust +/// # use wireframe::app::WireframeApp; +/// # use wireframe::codec::examples::HotlineFrameCodec; +/// # use wireframe_testing::drive_with_codec_payloads_mut; +/// # async fn demo() -> std::io::Result<()> { +/// let codec = HotlineFrameCodec::new(4096); +/// let mut app = WireframeApp::new().expect("app").with_codec(codec.clone()); +/// let payloads = drive_with_codec_payloads_mut(&mut app, &codec, vec![vec![1]]).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn drive_with_codec_payloads_mut( + app: &mut WireframeApp, + codec: &F, + payloads: Vec>, +) -> io::Result>> +where + S: TestSerializer, + C: Send + 'static, + E: Packet, + F: FrameCodec, +{ + drive_with_codec_payloads_with_capacity_mut(app, codec, payloads, DEFAULT_CAPACITY).await +} + +/// Drive a mutable `app` with payloads using a duplex buffer of `capacity` +/// bytes. +/// +/// # Errors +/// +/// Returns any I/O or codec error encountered during encoding, transport, or +/// decoding. +/// +/// ```rust +/// # use wireframe::app::WireframeApp; +/// # use wireframe::codec::examples::HotlineFrameCodec; +/// # use wireframe_testing::drive_with_codec_payloads_with_capacity_mut; +/// # async fn demo() -> std::io::Result<()> { +/// let codec = HotlineFrameCodec::new(4096); +/// let mut app = WireframeApp::new().expect("app").with_codec(codec.clone()); +/// let payloads = +/// drive_with_codec_payloads_with_capacity_mut(&mut app, &codec, vec![vec![1]], 8192).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn drive_with_codec_payloads_with_capacity_mut( + app: &mut WireframeApp, + codec: &F, + payloads: Vec>, + capacity: usize, +) -> io::Result>> +where + S: TestSerializer, + C: Send + 'static, + E: Packet, + F: FrameCodec, +{ + let frames = drive_codec_frames_internal( + |server| async move { app.handle_connection(server).await }, + codec, + payloads, + capacity, + ) + .await?; + Ok(extract_payloads::(&frames)) +} + +// --------------------------------------------------------------------------- +// Frame-level drivers (return Vec) +// --------------------------------------------------------------------------- + +/// Drive `app` with payloads and return decoded response frames. +/// +/// Unlike the payload-level drivers, this variant returns the full codec +/// frames so tests can inspect frame-level metadata such as transaction +/// identifiers or sequence numbers. +/// +/// # Errors +/// +/// Returns any I/O or codec error encountered during encoding, transport, or +/// decoding. +/// +/// ```rust +/// # use wireframe::app::WireframeApp; +/// # use wireframe::codec::examples::HotlineFrameCodec; +/// # use wireframe_testing::drive_with_codec_frames; +/// # async fn demo() -> std::io::Result<()> { +/// let codec = HotlineFrameCodec::new(4096); +/// let app = WireframeApp::new().expect("app").with_codec(codec.clone()); +/// let frames = drive_with_codec_frames(app, &codec, vec![vec![1]]).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn drive_with_codec_frames( + app: WireframeApp, + codec: &F, + payloads: Vec>, +) -> io::Result> +where + S: TestSerializer, + C: Send + 'static, + E: Packet, + F: FrameCodec, +{ + drive_with_codec_frames_with_capacity(app, codec, payloads, DEFAULT_CAPACITY).await +} + +/// Drive `app` with payloads using a duplex buffer of `capacity` bytes and +/// return decoded response frames. +/// +/// # Errors +/// +/// Returns any I/O or codec error encountered during encoding, transport, or +/// decoding. +/// +/// ```rust +/// # use wireframe::app::WireframeApp; +/// # use wireframe::codec::examples::HotlineFrameCodec; +/// # use wireframe_testing::drive_with_codec_frames_with_capacity; +/// # async fn demo() -> std::io::Result<()> { +/// let codec = HotlineFrameCodec::new(4096); +/// let app = WireframeApp::new().expect("app").with_codec(codec.clone()); +/// let frames = drive_with_codec_frames_with_capacity(app, &codec, vec![vec![1]], 8192).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn drive_with_codec_frames_with_capacity( + app: WireframeApp, + codec: &F, + payloads: Vec>, + capacity: usize, +) -> io::Result> +where + S: TestSerializer, + C: Send + 'static, + E: Packet, + F: FrameCodec, +{ + drive_codec_frames_internal( + |server| async move { app.handle_connection(server).await }, + codec, + payloads, + capacity, + ) + .await +} diff --git a/wireframe_testing/src/helpers/codec_ext.rs b/wireframe_testing/src/helpers/codec_ext.rs new file mode 100644 index 00000000..c6ca27b8 --- /dev/null +++ b/wireframe_testing/src/helpers/codec_ext.rs @@ -0,0 +1,144 @@ +//! Generic encode/decode helpers for any [`FrameCodec`] implementation. +//! +//! These functions translate between raw payload bytes and codec-framed wire +//! bytes, allowing test drivers to work transparently with arbitrary codecs. + +use std::io; + +use bytes::{Bytes, BytesMut}; +use tokio_util::codec::{Decoder, Encoder}; +use wireframe::codec::FrameCodec; + +/// Encode each payload into wire bytes using `codec`. +/// +/// For every payload the codec's [`FrameCodec::wrap_payload`] produces a +/// frame, which is then serialized through the codec's encoder. The resulting +/// raw byte vectors are suitable for writing directly to a duplex stream. +/// +/// # Errors +/// +/// Returns an error if the encoder rejects a frame (e.g. payload too large). +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::encode_payloads_with_codec; +/// +/// let codec = HotlineFrameCodec::new(4096); +/// let frames = encode_payloads_with_codec(&codec, vec![vec![1, 2, 3]]) +/// .expect("encoding should succeed for a valid payload"); +/// assert!(!frames.is_empty()); +/// ``` +pub fn encode_payloads_with_codec( + codec: &F, + payloads: Vec>, +) -> io::Result>> { + let mut encoder = codec.encoder(); + payloads + .into_iter() + .map(|payload| { + let frame = codec.wrap_payload(Bytes::from(payload)); + let mut buf = BytesMut::new(); + encoder.encode(frame, &mut buf).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("codec encode failed: {error}"), + ) + })?; + Ok(buf.to_vec()) + }) + .collect() +} + +/// Decode raw wire bytes into frames using `codec`. +/// +/// The byte vector is fed into the codec's decoder until no more complete +/// frames can be extracted. After the main decode loop, `decode_eof` is called +/// to flush any partial frame remaining in the buffer. If the buffer still +/// contains unconsumed bytes after `decode_eof` returns `None`, an error is +/// returned — this catches truncation bugs where the server emits incomplete +/// trailing data. +/// +/// # Errors +/// +/// Returns an error if the decoder encounters malformed data or if trailing +/// bytes remain that do not form a complete frame. +/// +/// ```rust +/// use wireframe::codec::examples::HotlineFrameCodec; +/// use wireframe_testing::{decode_frames_with_codec, encode_payloads_with_codec}; +/// +/// let codec = HotlineFrameCodec::new(4096); +/// let wire: Vec = encode_payloads_with_codec(&codec, vec![vec![42]]) +/// .expect("encoding should succeed") +/// .into_iter() +/// .flatten() +/// .collect(); +/// let frames = decode_frames_with_codec(&codec, wire) +/// .expect("decoding should succeed for well-formed wire bytes"); +/// assert_eq!(frames.len(), 1); +/// ``` +pub fn decode_frames_with_codec( + codec: &F, + bytes: Vec, +) -> io::Result> { + let mut decoder = codec.decoder(); + let mut buf = BytesMut::from(bytes.as_slice()); + let mut frames = Vec::new(); + while let Some(frame) = decoder.decode(&mut buf).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("codec decode failed: {error}"), + ) + })? { + frames.push(frame); + } + + // Flush any partial frame remaining at stream end. + while let Some(frame) = decoder.decode_eof(&mut buf).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("codec decode_eof failed: {error}"), + ) + })? { + frames.push(frame); + } + + if !buf.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "trailing {} byte(s) after decoding — possible truncated frame", + buf.len() + ), + )); + } + + Ok(frames) +} + +/// Extract raw payload bytes from a slice of codec frames. +/// +/// Calls [`FrameCodec::frame_payload`] on each frame and collects the +/// results into owned byte vectors. +/// +/// ```rust +/// use bytes::Bytes; +/// use wireframe::codec::{ +/// FrameCodec, +/// examples::{HotlineFrame, HotlineFrameCodec}, +/// }; +/// use wireframe_testing::extract_payloads; +/// +/// let frame = HotlineFrame { +/// transaction_id: 1, +/// payload: Bytes::from_static(b"hi"), +/// }; +/// let payloads = extract_payloads::(&[frame]); +/// assert_eq!(payloads, vec![b"hi".to_vec()]); +/// ``` +pub fn extract_payloads(frames: &[F::Frame]) -> Vec> { + frames + .iter() + .map(|frame| F::frame_payload(frame).to_vec()) + .collect() +} diff --git a/wireframe_testing/src/lib.rs b/wireframe_testing/src/lib.rs index dc472b13..9575a06f 100644 --- a/wireframe_testing/src/lib.rs +++ b/wireframe_testing/src/lib.rs @@ -30,8 +30,15 @@ pub use helpers::{ TEST_MAX_FRAME, TestSerializer, decode_frames, + decode_frames_with_codec, decode_frames_with_max, drive_with_bincode, + drive_with_codec_frames, + drive_with_codec_frames_with_capacity, + drive_with_codec_payloads, + drive_with_codec_payloads_mut, + drive_with_codec_payloads_with_capacity, + drive_with_codec_payloads_with_capacity_mut, drive_with_frame, drive_with_frame_mut, drive_with_frame_with_capacity, @@ -41,6 +48,8 @@ pub use helpers::{ drive_with_payloads, drive_with_payloads_mut, encode_frame, + encode_payloads_with_codec, + extract_payloads, new_test_codec, run_app, run_with_duplex_server,