Generator + scaffolder: emit end-to-end tests crate from OSchema (workspace-root tests/) #115

Closed
opened 2026-05-21 12:56:44 +00:00 by timur · 3 comments
Owner

Context

The scaffolder already emits a complete shape from an OSchema:

  • Typed SDK (#60)
  • Per-domain CRUD round-trip tests inside _server (tests_emit.rs<domain>/generated/tests.rs) — these are OSIS-dispatch unit tests
  • Runnable examples (examples.rs)
  • Per-root-object _admin + _web UI scaffolds (#98)
  • Benchmarks (pending #113)

What's still missing: a workspace-root end-to-end test crate that exercises the whole assembled service as a black box — real UDS socket, real hero_rpc2 transport, real typed SDK calls, full CRUD lifecycle across the scaffolded surface. Today the only thing that proves the assembled binary actually works is lab service <name> --start + manual curl. That's not regression-safe.

This is not a replacement for inline #[cfg(test)] mod tests unit tests inside source files, nor for the existing generator-emitted dispatch unit tests in _server. It's the missing peer: integration that crosses crate boundaries.

What this does

  1. New emitter crates/generator/src/generate/e2e_emit.rs (mirrors tests_emit.rs and the upcoming benches_emit.rs from #113).
  2. Scaffolder writes a workspace-root tests/ crate — handwritten src/lib.rs with in-process service spin-up helpers, generated generated/<entity>_e2e.rs per root object, generated generated/<domain>_methods_e2e.rs per domain for non-CRUD service methods.
  3. Per-root-object E2E coverage: full CRUD lifecycle round-trip (new → get → set → list → list_full → exists → delete → verify gone) over the real UDS socket, asserted through the typed SDK trait.
  4. Per-service-method E2E coverage: one #[tokio::test] per declared .oschema service method, calling through the typed SDK.

Concrete deliverables

Workspace-root layout

<workspace>/
├── tests/                              # scaffolded once at workspace root, workspace member crate
│   ├── Cargo.toml                      # [lib] + [[test]] per generated file (harness = true).
│   │                                   #   dev-deps: sdk, hero_rpc2, tokio, anyhow, serial_test
│   ├── .gitignore                      # `generated/`
│   ├── src/lib.rs                      # scaffolded once — handwritten helpers:
│   │                                   #   - spin_up_service() -> ServiceHandle (in-process via
│   │                                   #     hero_rpc2::ServerBuilder, returns SDK Client)
│   │                                   #   - clean_state(), seed_fixtures(), etc.
│   │                                   #   - contributor owns this file; codegen never overwrites
│   └── generated/                      # ALL codegen — gitignored
│       ├── mod.rs                      # (unused at runtime; each [[test]] is its own binary)
│       ├── <entity>_e2e.rs             # one #[tokio::test] per CRUD path, plus error/edge cases
│       └── <domain>_methods_e2e.rs     # one #[tokio::test] per non-CRUD service method

Each generated [[test]] file uses use hero_<name>_tests::spin_up_service; from the local lib. Cargo's standard test harness runs them in parallel by default; serial_test::serial covers any that share global state.

Per-entity E2E shape (one generated <entity>_e2e.rs)

// tests/generated/recipe_e2e.rs — generated
use hero_recipes_tests::spin_up_service;

#[tokio::test]
async fn recipe_crud_lifecycle() {
    let svc = spin_up_service().await;
    let sid = svc.client.recipe_new(/* fixture */).await.unwrap();
    let got = svc.client.recipe_get(sid).await.unwrap();
    assert_eq!(got.name, /* fixture.name */);
    svc.client.recipe_set(/* mutated fixture */).await.unwrap();
    let listed = svc.client.recipe_list_full().await.unwrap();
    assert!(listed.iter().any(|r| r.sid == sid));
    assert!(svc.client.recipe_exists(sid).await.unwrap());
    svc.client.recipe_delete(sid).await.unwrap();
    assert!(!svc.client.recipe_exists(sid).await.unwrap());
}

#[tokio::test]
async fn recipe_get_missing_returns_not_found() {
    let svc = spin_up_service().await;
    let result = svc.client.recipe_get(0xdeadbeef.into()).await;
    assert!(matches!(result, Err(/* NotFound variant */)));
}

Fixture generation

Reuse the same per-OSchema-type defaults as #98's form-input table and #113's bench fixtures. Don't re-invent — share a crates/generator/src/fixture.rs helper if these three emitters (UI / benches / e2e) end up duplicating the logic.

What to do

  1. Design first — post a comment on this issue with:
    • Final emitter layout (file paths, [[test]] Cargo manifest shape).
    • The spin_up_service() helper API — does it return (Client, JoinHandle) or a ServiceHandle RAII guard? How does it dispose the UDS socket between tests?
    • How parallel-vs-serial tests are flagged (per-test #[serial] annotation? Global semaphore?).
    • Sample of one fully-rendered <entity>_e2e.rs.
    • Wait for sign-off before coding.
  2. Implement crates/generator/src/generate/e2e_emit.rs mirroring the existing emitters.
  3. Wire into crates/generator/src/build/scaffold.rs + crates/generator/src/bin/scaffold.rs with with_tests() (default-on) and --no-tests flag — match the exact wiring pattern used for --no-web (#98) / --no-benches (#113).
  4. Share fixture-default logic with #98 + #113 (extract a helper if not yet extracted).
  5. Apply to example/recipe_server/cargo test at workspace root runs the new E2E suite green.
  6. Update hero_service_scaffold.md skill in hero_skills to document tests/ + --no-tests, distinguishing it from the inline unit tests and the _server-internal dispatch tests.

Acceptance

  • recipe_server/tests/ exists, gets regenerated on cargo build, and cargo test at workspace root runs the new E2E suite end-to-end against an in-process service with no manual setup.
  • git status clean after cargo build (tests/generated/ ignored).
  • --no-tests skips the entire tests/ scaffold.
  • Skill doc clearly distinguishes the three test layers: (1) inline #[cfg(test)] unit tests in source files, (2) generator-emitted dispatch tests inside _server/<domain>/generated/tests.rs, (3) this new workspace-root E2E suite.
  • Fresh lab service new <name> produces a complete E2E scaffold alongside test/example/UI/benches scaffolds.

Out of scope

  • Property-based / fuzz testing — separate issue if needed.
  • Multi-process integration tests (binary spawn vs in-process). In-process via ServerBuilder is sufficient for now.
  • Browser-driven E2E for the _admin / _web HTML surfaces — separate issue; this covers the RPC contract only.
  • Backwards-compat regression tests against OpenRPC schema snapshots — interesting follow-up but not blocking.
  • Parent META: hero_skills#262
  • Generated-vs-handwritten layout: #96
  • Companion scaffolds — share fixture-default helper: #98 (UI), #113 (benches)
  • CRUD methods on the typed SDK that these tests exercise: #60 + #98 sub-deliverable (b)
  • Existing emitter siblings to mirror: crates/generator/src/generate/tests_emit.rs, examples.rs, e2e.rs
## Context The scaffolder already emits a complete shape from an OSchema: - Typed SDK ([#60](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/60)) - Per-domain CRUD round-trip tests **inside `_server`** (`tests_emit.rs` → `<domain>/generated/tests.rs`) — these are OSIS-dispatch unit tests - Runnable examples (`examples.rs`) - Per-root-object `_admin` + `_web` UI scaffolds ([#98](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/98)) - Benchmarks (pending [#113](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/113)) What's still missing: a **workspace-root end-to-end test crate** that exercises the **whole assembled service** as a black box — real UDS socket, real `hero_rpc2` transport, real typed SDK calls, full CRUD lifecycle across the scaffolded surface. Today the only thing that proves the assembled binary actually works is `lab service <name> --start` + manual curl. That's not regression-safe. This is **not** a replacement for inline `#[cfg(test)] mod tests` unit tests inside source files, nor for the existing generator-emitted dispatch unit tests in `_server`. It's the missing peer: integration that crosses crate boundaries. ## What this does 1. **New emitter** `crates/generator/src/generate/e2e_emit.rs` (mirrors `tests_emit.rs` and the upcoming `benches_emit.rs` from #113). 2. **Scaffolder writes a workspace-root `tests/` crate** — handwritten `src/lib.rs` with in-process service spin-up helpers, generated `generated/<entity>_e2e.rs` per root object, generated `generated/<domain>_methods_e2e.rs` per domain for non-CRUD service methods. 3. Per-root-object E2E coverage: full CRUD lifecycle round-trip (`new → get → set → list → list_full → exists → delete → verify gone`) over the **real UDS socket**, asserted through the typed SDK trait. 4. Per-service-method E2E coverage: one `#[tokio::test]` per declared `.oschema` service method, calling through the typed SDK. ## Concrete deliverables ### Workspace-root layout ``` <workspace>/ ├── tests/ # scaffolded once at workspace root, workspace member crate │ ├── Cargo.toml # [lib] + [[test]] per generated file (harness = true). │ │ # dev-deps: sdk, hero_rpc2, tokio, anyhow, serial_test │ ├── .gitignore # `generated/` │ ├── src/lib.rs # scaffolded once — handwritten helpers: │ │ # - spin_up_service() -> ServiceHandle (in-process via │ │ # hero_rpc2::ServerBuilder, returns SDK Client) │ │ # - clean_state(), seed_fixtures(), etc. │ │ # - contributor owns this file; codegen never overwrites │ └── generated/ # ALL codegen — gitignored │ ├── mod.rs # (unused at runtime; each [[test]] is its own binary) │ ├── <entity>_e2e.rs # one #[tokio::test] per CRUD path, plus error/edge cases │ └── <domain>_methods_e2e.rs # one #[tokio::test] per non-CRUD service method ``` Each generated `[[test]]` file uses `use hero_<name>_tests::spin_up_service;` from the local lib. Cargo's standard test harness runs them in parallel by default; `serial_test::serial` covers any that share global state. ### Per-entity E2E shape (one generated `<entity>_e2e.rs`) ```rust // tests/generated/recipe_e2e.rs — generated use hero_recipes_tests::spin_up_service; #[tokio::test] async fn recipe_crud_lifecycle() { let svc = spin_up_service().await; let sid = svc.client.recipe_new(/* fixture */).await.unwrap(); let got = svc.client.recipe_get(sid).await.unwrap(); assert_eq!(got.name, /* fixture.name */); svc.client.recipe_set(/* mutated fixture */).await.unwrap(); let listed = svc.client.recipe_list_full().await.unwrap(); assert!(listed.iter().any(|r| r.sid == sid)); assert!(svc.client.recipe_exists(sid).await.unwrap()); svc.client.recipe_delete(sid).await.unwrap(); assert!(!svc.client.recipe_exists(sid).await.unwrap()); } #[tokio::test] async fn recipe_get_missing_returns_not_found() { let svc = spin_up_service().await; let result = svc.client.recipe_get(0xdeadbeef.into()).await; assert!(matches!(result, Err(/* NotFound variant */))); } ``` ### Fixture generation Reuse the same per-OSchema-type defaults as #98's form-input table and #113's bench fixtures. Don't re-invent — share a `crates/generator/src/fixture.rs` helper if these three emitters (UI / benches / e2e) end up duplicating the logic. ## What to do 1. **Design first** — post a comment on this issue with: - Final emitter layout (file paths, `[[test]]` Cargo manifest shape). - The `spin_up_service()` helper API — does it return `(Client, JoinHandle)` or a `ServiceHandle` RAII guard? How does it dispose the UDS socket between tests? - How parallel-vs-serial tests are flagged (per-test `#[serial]` annotation? Global semaphore?). - Sample of one fully-rendered `<entity>_e2e.rs`. - Wait for sign-off before coding. 2. Implement `crates/generator/src/generate/e2e_emit.rs` mirroring the existing emitters. 3. Wire into `crates/generator/src/build/scaffold.rs` + `crates/generator/src/bin/scaffold.rs` with `with_tests()` (default-on) and `--no-tests` flag — match the exact wiring pattern used for `--no-web` (#98) / `--no-benches` (#113). 4. Share fixture-default logic with #98 + #113 (extract a helper if not yet extracted). 5. Apply to `example/recipe_server/` — `cargo test` at workspace root runs the new E2E suite green. 6. Update `hero_service_scaffold.md` skill in `hero_skills` to document `tests/` + `--no-tests`, distinguishing it from the inline unit tests and the `_server`-internal dispatch tests. ## Acceptance - `recipe_server/tests/` exists, gets regenerated on `cargo build`, and `cargo test` at workspace root runs the new E2E suite end-to-end against an in-process service with no manual setup. - `git status` clean after `cargo build` (`tests/generated/` ignored). - `--no-tests` skips the entire `tests/` scaffold. - Skill doc clearly distinguishes the **three** test layers: (1) inline `#[cfg(test)]` unit tests in source files, (2) generator-emitted dispatch tests inside `_server/<domain>/generated/tests.rs`, (3) this new workspace-root E2E suite. - Fresh `lab service new <name>` produces a complete E2E scaffold alongside test/example/UI/benches scaffolds. ## Out of scope - Property-based / fuzz testing — separate issue if needed. - Multi-process integration tests (binary spawn vs in-process). In-process via `ServerBuilder` is sufficient for now. - Browser-driven E2E for the `_admin` / `_web` HTML surfaces — separate issue; this covers the RPC contract only. - Backwards-compat regression tests against OpenRPC schema snapshots — interesting follow-up but not blocking. ## Related - Parent META: [hero_skills#262](https://forge.ourworld.tf/lhumina_code/hero_skills/issues/262) - Generated-vs-handwritten layout: [#96](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/96) - Companion scaffolds — share fixture-default helper: [#98](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/98) (UI), [#113](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/113) (benches) - CRUD methods on the typed SDK that these tests exercise: [#60](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/60) + [#98](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/98) sub-deliverable (b) - Existing emitter siblings to mirror: `crates/generator/src/generate/tests_emit.rs`, `examples.rs`, `e2e.rs`
Author
Owner

Design — workspace-root tests/ crate scaffolder

Posting before coding per the workflow ask. Worktree at /tmp/hero_rpc_115 on branch issue-115-e2e-tests, off latest dev (87289a1). Flagging one upstream dependency at the bottom — needs a decision before this can ship cleanly.

1. Emitter layout

New emitter crates/generator/src/generate/e2e_emit.rs, sibling of tests_emit.rs, examples.rs, and the (forthcoming) benches_emit.rs from #113. Same module shape:

impl super::Generator {
    pub(in crate::generate) fn generate_e2e_files(&self, result: &mut GenerationResult) -> Result<()> { ... }
}

Codegen-triggered (called from the per-domain codegen path, not from WorkspaceScaffolder). Walks Schema::definitions, filters obj.is_root_object || has_sid, emits one <entity>_e2e.rs per root object plus one <domain>_methods_e2e.rs per domain (for non-CRUD service methods). Output lives under <workspace>/tests/generated/.

Note on naming: there's an existing crates/generator/src/generate/e2e.rs — it emits the per-domain runnable example (examples/rust/<domain>/client_server.rs), unrelated to tests. I'll name the new file e2e_emit.rs to match the tests_emit.rs / benches_emit.rs convention and avoid the collision.

2. Scaffolder wiring — tests/ workspace member

The scaffolder writes the parent tests/ crate as a workspace member (preserved-once, contributor-owned):

<workspace>/
├── tests/                              # scaffolded once, workspace member
│   ├── Cargo.toml                      # [package] + [lib] + [[test]] per entity
│   ├── .gitignore                      # `generated/`
│   ├── src/lib.rs                      # handwritten spin_up_service() helper
│   └── generated/                      # ALL codegen — gitignored
│       ├── <entity>_e2e.rs             # one #[tokio::test] per CRUD path + edge cases
│       └── <domain>_methods_e2e.rs     # one #[tokio::test] per non-CRUD service method

Wiring exactly parallels --no-web (PR #103, ac70c7e) and the upcoming --no-benches from #113. Specifically:

  • WorkspaceScaffolder::generate_tests: bool field, default true.
  • with_tests() / without_tests() fluent builders.
  • bin/scaffold.rs: --no-tests flag → scaffolder.without_tests().
  • scaffold() calls generate_tests_crate(&workspace_dir, &mut result)? after generate_examples_crate.
  • create_workspace_dirs adds tests/{,src,generated}.
  • generate_workspace_cargo_toml adds "tests" to [workspace] members when the flag is on.
  • generate_tests_crate writes the preserved-once files: Cargo.toml, .gitignore (generated/), src/lib.rs.

Tests added (mirroring test_scaffolder_web_default_on_with_without_toggle + test_scaffold_emits_examples_crate):

  • test_scaffolder_tests_default_on_with_without_toggle
  • test_scaffold_emits_tests_crate
  • test_scaffold_no_tests_skips_dir

3. tests/Cargo.toml shape

[package]
name = "hero_recipes_tests"
version.workspace = true
edition.workspace = true
publish = false

[lib]
path = "src/lib.rs"

[dependencies]
hero_recipes_sdk    = { path = "../sdk/rust" }
hero_recipes_server = { path = "../crates/hero_recipes_server" }
hero_rpc_osis       = { ... }   # for OsisDomainInit
hero_rpc2           = { ..., features = ["client", "uds-http"] }
tokio               = { workspace = true }
tempfile            = "3.12"
anyhow              = "1.0"

[dev-dependencies]
# (none beyond [dependencies] — tests reuse the same surface)

# Standard harness, one [[test]] per generated file pointing into generated/.
[[test]]
name = "recipe_e2e"
path = "generated/recipe_e2e.rs"

[[test]]
name = "collection_e2e"
path = "generated/collection_e2e.rs"

[[test]]
name = "recipes_methods_e2e"
path = "generated/recipes_methods_e2e.rs"

harness = true is the Cargo default — left implicit for brevity. Each [[test]] compiles to its own binary, which means Cargo can parallelize across files. Inside each file, #[tokio::test] annotations let multiple test fns share the binary's tokio runtime.

4. src/lib.rsspin_up_service() helper (scaffolded once, handwritten)

//! E2E test support. Re-running the generator never touches this file.

use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};

use hero_recipes_sdk::recipes::RecipesClient;
use hero_recipes_server::recipes::OsisRecipes;
use hero_rpc_osis::rpc::{OsisDomainInit, rpc2_adapter};
use hero_rpc2::prelude::*;
use tempfile::TempDir;

/// RAII handle holding the live in-process service + a connected typed client.
/// Drop tears down the server, deletes the socket file, and removes the tmp data dir.
pub struct ServiceHandle {
    pub client: Arc<dyn jsonrpsee::core::client::ClientT + Send + Sync>,
    server: Option<ServerJoinHandle>,
    socket_path: PathBuf,
    _data_dir: TempDir,
}

impl Drop for ServiceHandle {
    fn drop(&mut self) {
        if let Some(server) = self.server.take() {
            // Best-effort shutdown — we're already on a runtime in the test.
            let _ = tokio::runtime::Handle::try_current()
                .ok()
                .map(|h| h.block_on(server.shutdown()));
        }
        let _ = std::fs::remove_file(&self.socket_path);
    }
}

static SOCK_COUNTER: AtomicU64 = AtomicU64::new(0);

pub async fn spin_up_service() -> anyhow::Result<ServiceHandle> {
    let data_dir = tempfile::tempdir()?;
    let app: Arc<OsisRecipes> = <OsisRecipes as OsisDomainInit>::create(
        data_dir.path().to_str().unwrap(),
        0,
    )?;
    let module = rpc2_adapter::module_for(app)?;

    // SUN_LEN limit (~104 chars on macOS). /tmp is short, pid+counter avoids collisions.
    let n = SOCK_COUNTER.fetch_add(1, Ordering::Relaxed);
    let sock = std::env::temp_dir().join(format!("h-rec-{}-{}.sock", std::process::id(), n));
    let _ = std::fs::remove_file(&sock);

    let server = ServerBuilder::new(module).serve_http(&sock).await?;
    let client = ClientBuilder::new().connect_http(&sock).await?;

    Ok(ServiceHandle {
        client: Arc::new(client),
        server: Some(server),
        socket_path: sock,
        _data_dir: data_dir,
    })
}

The contributor owns this file. If they want shared fixtures, custom claims headers, or pre-seeded data, they extend spin_up_service() here — codegen never overwrites it.

5. Parallel vs serial

Parallel by default. Each test owns its own tmp data dir + UDS socket path (per-test atomic counter). No shared global state, no serial_test::serial needed. The contributor adds #[serial] themselves on any test that touches a singleton (env vars, global metrics, etc.).

6. Sample fully-rendered <entity>_e2e.rs

Against the post-#117 unified shape (CREATE/UPDATE split):

//! E2E tests for Recipe — auto-generated; do not edit. Edit src/lib.rs to
//! customize spin-up, or add bespoke tests under tests/<custom>.rs.

use hero_recipes_sdk::recipes::{Recipe, RecipeInput, RecipesClient};
use hero_recipes_tests::spin_up_service;

#[tokio::test]
async fn recipe_full_lifecycle() {
    let svc = spin_up_service().await.expect("spin up");

    // CREATE
    let sid = RecipesClient::recipe_new(
        &*svc.client,
        None,
        RecipeInput { name: "smoke".into(), ..Default::default() },
    )
    .await
    .expect("recipe_new");

    // READ — full object
    let got = RecipesClient::recipe_get(&*svc.client, None, sid.clone())
        .await
        .expect("recipe_get");
    assert_eq!(got.name, "smoke");
    assert_eq!(got.sid, sid);

    // UPDATE
    RecipesClient::recipe_set(
        &*svc.client,
        None,
        sid.clone(),
        RecipeInput { name: "updated".into(), ..Default::default() },
    )
    .await
    .expect("recipe_set");
    let after = RecipesClient::recipe_get(&*svc.client, None, sid.clone())
        .await
        .unwrap();
    assert_eq!(after.name, "updated");

    // LIST — sids + full
    let sids = RecipesClient::recipe_list(&*svc.client, None).await.unwrap();
    assert!(sids.contains(&sid));
    let full = RecipesClient::recipe_list_full(&*svc.client, None).await.unwrap();
    assert!(full.iter().any(|r| r.sid == sid));

    // EXISTS
    assert!(RecipesClient::recipe_exists(&*svc.client, None, sid.clone()).await.unwrap());

    // DELETE
    let deleted = RecipesClient::recipe_delete(&*svc.client, None, sid.clone()).await.unwrap();
    assert!(deleted);
    assert!(!RecipesClient::recipe_exists(&*svc.client, None, sid).await.unwrap());
}

#[tokio::test]
async fn recipe_get_unknown_sid_returns_error() {
    let svc = spin_up_service().await.unwrap();
    let result = RecipesClient::recipe_get(&*svc.client, None, "0:0:9999".into()).await;
    assert!(result.is_err(), "get on unknown sid must error");
}

#[tokio::test]
async fn recipe_delete_unknown_sid_returns_false() {
    let svc = spin_up_service().await.unwrap();
    let r = RecipesClient::recipe_delete(&*svc.client, None, "0:0:9999".into()).await.unwrap();
    assert!(!r);
}

The "fixture" used in RecipeInput { name: "smoke".into(), ..Default::default() } is the same per-FieldKind default-value table used by #98 / #113 — see §7.

7. Shared fixture helper

The fixture-default logic for OSchema fields is currently entangled in ui_emit.rs::FieldKind (HTML inputs). #113 hasn't shipped, so the helper isn't extracted yet. I'll do the extraction as part of this PR:

New file crates/generator/src/fixture.rs:

  • Move FieldKind, FieldInfo, RootObjectInfo, discover_root_objects out of build/ui_emit.rs into here. These are emitter-neutral data; the UI emitter, the e2e emitter, and (soon) the bench emitter all consume them.
  • Add impl FieldKind { pub fn rust_default_expr(&self) -> String } — emits e.g. "String::new()" for Str, "OTime::default()" for OTime, "0u32" for UnsignedInt, "Vec::new()" for PrimitiveList, etc. Used by e2e + benches to populate fixture struct literals.
  • ui_emit.rs re-exports the types it moved (for back-compat with any external callers), keeps its UI-specific input_html / detail_expr methods.

This eliminates the duplication risk the issue body called out and is a low-risk refactor (grep shows ~5 internal call sites).

8. Hero_skills follow-up

After the hero_rpc PR merges:

  • Add --no-tests to the CLI flags table + library example in skills/hero/service/hero_service_scaffold.md.
  • Add tests/ to the workspace-root file tree.
  • Add a "End-to-end tests — out of the box" section parallel to the existing UI section.
  • Add the three-layer test taxonomy:
    Layer Location What it tests
    Inline unit #[cfg(test)] mod tests in source pure-function correctness
    Dispatch crates/<name>_server/src/<domain>/generated/tests.rs OSIS-dispatch CRUD round-trips, in-memory
    E2E (this issue) tests/generated/<entity>_e2e.rs whole assembled service over real UDS socket via typed SDK

Open question — upstream dependency on #117

The sample renders in §6 assume the post-#117 unified CRUD shape (recipe_new(input) -> Sid, recipe_set(sid, input) -> (), RecipeInput distinct from Recipe). Against today's pre-#117 trait (recipe_new() -> Recipe, recipe_set(Recipe) -> Sid), the renders look meaningfully different.

Three sequencing options:

  • (a) Block #115 on #117. Cleanest result — emit against the final shape once. Slowest to ship.
  • (b) Land #115 against today's shape; refactor when #117 lands. Faster but the emitter strings would be rewritten almost in full.
  • (c) Land #115 against the post-#117 shape now; gate the new emitter behind a --with-unified-crud flag until #117 merges. Lets #115 ship today but adds a temporary feature flag.

I lean (a). #117 is a foundational reshape; redoing every render string twice is more churn than waiting. Happy to be overridden — if you want #115 to land first, I'll write today against the pre-#117 trait and queue the refactor as a #117-blocker follow-up.


Sign-off needed on:

  1. §1 — e2e_emit.rs naming to avoid collision with existing e2e.rs (which is the runnable example emitter, not tests). OK?
  2. §5 — parallel-by-default, no serial_test dependency. OK?
  3. §7 — extract fixture.rs as part of this PR (since #113 hasn't shipped). OK?
  4. Open question — sequencing (a/b/c). Default proposing (a).

Will hold on coding until the green light.

## Design — workspace-root `tests/` crate scaffolder Posting before coding per the workflow ask. Worktree at `/tmp/hero_rpc_115` on branch `issue-115-e2e-tests`, off latest dev (`87289a1`). Flagging one upstream dependency at the bottom — needs a decision before this can ship cleanly. ### 1. Emitter layout New emitter `crates/generator/src/generate/e2e_emit.rs`, sibling of `tests_emit.rs`, `examples.rs`, and the (forthcoming) `benches_emit.rs` from #113. Same module shape: ```rust impl super::Generator { pub(in crate::generate) fn generate_e2e_files(&self, result: &mut GenerationResult) -> Result<()> { ... } } ``` Codegen-triggered (called from the per-domain codegen path, *not* from `WorkspaceScaffolder`). Walks `Schema::definitions`, filters `obj.is_root_object || has_sid`, emits one `<entity>_e2e.rs` per root object plus one `<domain>_methods_e2e.rs` per domain (for non-CRUD service methods). Output lives under `<workspace>/tests/generated/`. Note on naming: there's an existing `crates/generator/src/generate/e2e.rs` — it emits the per-domain *runnable example* (`examples/rust/<domain>/client_server.rs`), unrelated to tests. I'll name the new file `e2e_emit.rs` to match the `tests_emit.rs` / `benches_emit.rs` convention and avoid the collision. ### 2. Scaffolder wiring — `tests/` workspace member The scaffolder writes the parent `tests/` crate as a workspace member (preserved-once, contributor-owned): ``` <workspace>/ ├── tests/ # scaffolded once, workspace member │ ├── Cargo.toml # [package] + [lib] + [[test]] per entity │ ├── .gitignore # `generated/` │ ├── src/lib.rs # handwritten spin_up_service() helper │ └── generated/ # ALL codegen — gitignored │ ├── <entity>_e2e.rs # one #[tokio::test] per CRUD path + edge cases │ └── <domain>_methods_e2e.rs # one #[tokio::test] per non-CRUD service method ``` Wiring exactly parallels `--no-web` (PR #103, `ac70c7e`) and the upcoming `--no-benches` from #113. Specifically: - `WorkspaceScaffolder::generate_tests: bool` field, default `true`. - `with_tests()` / `without_tests()` fluent builders. - `bin/scaffold.rs`: `--no-tests` flag → `scaffolder.without_tests()`. - `scaffold()` calls `generate_tests_crate(&workspace_dir, &mut result)?` after `generate_examples_crate`. - `create_workspace_dirs` adds `tests/{,src,generated}`. - `generate_workspace_cargo_toml` adds `"tests"` to `[workspace] members` when the flag is on. - `generate_tests_crate` writes the preserved-once files: `Cargo.toml`, `.gitignore` (`generated/`), `src/lib.rs`. Tests added (mirroring `test_scaffolder_web_default_on_with_without_toggle` + `test_scaffold_emits_examples_crate`): - `test_scaffolder_tests_default_on_with_without_toggle` - `test_scaffold_emits_tests_crate` - `test_scaffold_no_tests_skips_dir` ### 3. `tests/Cargo.toml` shape ```toml [package] name = "hero_recipes_tests" version.workspace = true edition.workspace = true publish = false [lib] path = "src/lib.rs" [dependencies] hero_recipes_sdk = { path = "../sdk/rust" } hero_recipes_server = { path = "../crates/hero_recipes_server" } hero_rpc_osis = { ... } # for OsisDomainInit hero_rpc2 = { ..., features = ["client", "uds-http"] } tokio = { workspace = true } tempfile = "3.12" anyhow = "1.0" [dev-dependencies] # (none beyond [dependencies] — tests reuse the same surface) # Standard harness, one [[test]] per generated file pointing into generated/. [[test]] name = "recipe_e2e" path = "generated/recipe_e2e.rs" [[test]] name = "collection_e2e" path = "generated/collection_e2e.rs" [[test]] name = "recipes_methods_e2e" path = "generated/recipes_methods_e2e.rs" ``` `harness = true` is the Cargo default — left implicit for brevity. Each `[[test]]` compiles to its own binary, which means Cargo can parallelize across files. Inside each file, `#[tokio::test]` annotations let multiple test fns share the binary's tokio runtime. ### 4. `src/lib.rs` — `spin_up_service()` helper (scaffolded once, handwritten) ```rust //! E2E test support. Re-running the generator never touches this file. use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use hero_recipes_sdk::recipes::RecipesClient; use hero_recipes_server::recipes::OsisRecipes; use hero_rpc_osis::rpc::{OsisDomainInit, rpc2_adapter}; use hero_rpc2::prelude::*; use tempfile::TempDir; /// RAII handle holding the live in-process service + a connected typed client. /// Drop tears down the server, deletes the socket file, and removes the tmp data dir. pub struct ServiceHandle { pub client: Arc<dyn jsonrpsee::core::client::ClientT + Send + Sync>, server: Option<ServerJoinHandle>, socket_path: PathBuf, _data_dir: TempDir, } impl Drop for ServiceHandle { fn drop(&mut self) { if let Some(server) = self.server.take() { // Best-effort shutdown — we're already on a runtime in the test. let _ = tokio::runtime::Handle::try_current() .ok() .map(|h| h.block_on(server.shutdown())); } let _ = std::fs::remove_file(&self.socket_path); } } static SOCK_COUNTER: AtomicU64 = AtomicU64::new(0); pub async fn spin_up_service() -> anyhow::Result<ServiceHandle> { let data_dir = tempfile::tempdir()?; let app: Arc<OsisRecipes> = <OsisRecipes as OsisDomainInit>::create( data_dir.path().to_str().unwrap(), 0, )?; let module = rpc2_adapter::module_for(app)?; // SUN_LEN limit (~104 chars on macOS). /tmp is short, pid+counter avoids collisions. let n = SOCK_COUNTER.fetch_add(1, Ordering::Relaxed); let sock = std::env::temp_dir().join(format!("h-rec-{}-{}.sock", std::process::id(), n)); let _ = std::fs::remove_file(&sock); let server = ServerBuilder::new(module).serve_http(&sock).await?; let client = ClientBuilder::new().connect_http(&sock).await?; Ok(ServiceHandle { client: Arc::new(client), server: Some(server), socket_path: sock, _data_dir: data_dir, }) } ``` The contributor owns this file. If they want shared fixtures, custom claims headers, or pre-seeded data, they extend `spin_up_service()` here — codegen never overwrites it. ### 5. Parallel vs serial **Parallel by default.** Each test owns its own tmp data dir + UDS socket path (per-test atomic counter). No shared global state, no `serial_test::serial` needed. The contributor adds `#[serial]` themselves on any test that touches a singleton (env vars, global metrics, etc.). ### 6. Sample fully-rendered `<entity>_e2e.rs` Against the post-#117 unified shape (CREATE/UPDATE split): ```rust //! E2E tests for Recipe — auto-generated; do not edit. Edit src/lib.rs to //! customize spin-up, or add bespoke tests under tests/<custom>.rs. use hero_recipes_sdk::recipes::{Recipe, RecipeInput, RecipesClient}; use hero_recipes_tests::spin_up_service; #[tokio::test] async fn recipe_full_lifecycle() { let svc = spin_up_service().await.expect("spin up"); // CREATE let sid = RecipesClient::recipe_new( &*svc.client, None, RecipeInput { name: "smoke".into(), ..Default::default() }, ) .await .expect("recipe_new"); // READ — full object let got = RecipesClient::recipe_get(&*svc.client, None, sid.clone()) .await .expect("recipe_get"); assert_eq!(got.name, "smoke"); assert_eq!(got.sid, sid); // UPDATE RecipesClient::recipe_set( &*svc.client, None, sid.clone(), RecipeInput { name: "updated".into(), ..Default::default() }, ) .await .expect("recipe_set"); let after = RecipesClient::recipe_get(&*svc.client, None, sid.clone()) .await .unwrap(); assert_eq!(after.name, "updated"); // LIST — sids + full let sids = RecipesClient::recipe_list(&*svc.client, None).await.unwrap(); assert!(sids.contains(&sid)); let full = RecipesClient::recipe_list_full(&*svc.client, None).await.unwrap(); assert!(full.iter().any(|r| r.sid == sid)); // EXISTS assert!(RecipesClient::recipe_exists(&*svc.client, None, sid.clone()).await.unwrap()); // DELETE let deleted = RecipesClient::recipe_delete(&*svc.client, None, sid.clone()).await.unwrap(); assert!(deleted); assert!(!RecipesClient::recipe_exists(&*svc.client, None, sid).await.unwrap()); } #[tokio::test] async fn recipe_get_unknown_sid_returns_error() { let svc = spin_up_service().await.unwrap(); let result = RecipesClient::recipe_get(&*svc.client, None, "0:0:9999".into()).await; assert!(result.is_err(), "get on unknown sid must error"); } #[tokio::test] async fn recipe_delete_unknown_sid_returns_false() { let svc = spin_up_service().await.unwrap(); let r = RecipesClient::recipe_delete(&*svc.client, None, "0:0:9999".into()).await.unwrap(); assert!(!r); } ``` The "fixture" used in `RecipeInput { name: "smoke".into(), ..Default::default() }` is the same per-FieldKind default-value table used by #98 / #113 — see §7. ### 7. Shared fixture helper The fixture-default logic for OSchema fields is currently entangled in `ui_emit.rs::FieldKind` (HTML inputs). #113 hasn't shipped, so the helper isn't extracted yet. I'll do the extraction as part of this PR: **New file `crates/generator/src/fixture.rs`:** - Move `FieldKind`, `FieldInfo`, `RootObjectInfo`, `discover_root_objects` out of `build/ui_emit.rs` into here. These are emitter-neutral data; the UI emitter, the e2e emitter, and (soon) the bench emitter all consume them. - Add `impl FieldKind { pub fn rust_default_expr(&self) -> String }` — emits e.g. `"String::new()"` for `Str`, `"OTime::default()"` for `OTime`, `"0u32"` for `UnsignedInt`, `"Vec::new()"` for `PrimitiveList`, etc. Used by e2e + benches to populate fixture struct literals. - `ui_emit.rs` re-exports the types it moved (for back-compat with any external callers), keeps its UI-specific `input_html` / `detail_expr` methods. This eliminates the duplication risk the issue body called out and is a low-risk refactor (`grep` shows ~5 internal call sites). ### 8. Hero_skills follow-up After the hero_rpc PR merges: - Add `--no-tests` to the CLI flags table + library example in `skills/hero/service/hero_service_scaffold.md`. - Add `tests/` to the workspace-root file tree. - Add a "End-to-end tests — out of the box" section parallel to the existing UI section. - Add the three-layer test taxonomy: | Layer | Location | What it tests | |---|---|---| | Inline unit | `#[cfg(test)] mod tests` in source | pure-function correctness | | Dispatch | `crates/<name>_server/src/<domain>/generated/tests.rs` | OSIS-dispatch CRUD round-trips, in-memory | | **E2E (this issue)** | **`tests/generated/<entity>_e2e.rs`** | **whole assembled service over real UDS socket via typed SDK** | --- ### Open question — upstream dependency on #117 The sample renders in §6 assume the **post-#117 unified CRUD shape** (`recipe_new(input) -> Sid`, `recipe_set(sid, input) -> ()`, `RecipeInput` distinct from `Recipe`). Against today's pre-#117 trait (`recipe_new() -> Recipe`, `recipe_set(Recipe) -> Sid`), the renders look meaningfully different. Three sequencing options: - **(a) Block #115 on #117.** Cleanest result — emit against the final shape once. Slowest to ship. - **(b) Land #115 against today's shape; refactor when #117 lands.** Faster but the emitter strings would be rewritten almost in full. - **(c) Land #115 against the post-#117 shape now; gate the new emitter behind a `--with-unified-crud` flag until #117 merges.** Lets #115 ship today but adds a temporary feature flag. I lean **(a)**. #117 is a foundational reshape; redoing every render string twice is more churn than waiting. Happy to be overridden — if you want #115 to land first, I'll write today against the pre-#117 trait and queue the refactor as a #117-blocker follow-up. --- **Sign-off needed on:** 1. §1 — `e2e_emit.rs` naming to avoid collision with existing `e2e.rs` (which is the runnable example emitter, not tests). OK? 2. §5 — parallel-by-default, no `serial_test` dependency. OK? 3. §7 — extract `fixture.rs` as part of this PR (since #113 hasn't shipped). OK? 4. Open question — sequencing (a/b/c). Default proposing **(a)**. Will hold on coding until the green light.
Author
Owner

Design revisions — three decisions confirmed by the user

Folding follow-ups into the #115 design. Three substantial changes from my first design comment:

1. Drop the existing tests_emit.rs entirely

User signed off on collapsing the test taxonomy from three layers to two. The existing crates/generator/src/generate/tests_emit.rs — which emits OSIS-direct dispatch tests inside <server>/src/<domain>/generated/tests.rs — goes away as part of this PR. Its CRUD coverage is transitively covered by the new wire-level E2E suite; per-OSIS-layer correctness lives in inline #[cfg(test)] blocks where it belongs.

Updated three-layer table from §8 of my first comment collapses to:

Layer Location What it tests
Inline unit #[cfg(test)] mod tests in source pure-function correctness
E2E (this issue) tests/generated/<entity>_e2e.rs whole assembled service over real UDS socket via typed SDK

Deletion scope as part of this PR:

  • crates/generator/src/generate/tests_emit.rs — entire file.
  • Its call site in the per-domain codegen path.
  • Existing <server>/src/<domain>/generated/tests.rs outputs become stale and stop being emitted; no migration needed since they're regenerated on every build (per #96).

2. Emitter file naming — parallel

The generate/ subdirectory has drifted on naming. Cleaning up alongside this PR:

Before After Note
tests_emit.rs (dispatch tests) deleted per §1
examples.rs (workspace-level basic_crud.rs) merged into examples_emit.rs both example emitters live in one file
e2e.rs (per-domain client_server.rs example) merged into examples_emit.rs same
tests_emit.rs (new — workspace-root E2E tests, this issue) reuses the freed-up name
benches_emit.rs (new — #113) parallel naming

Final layout: examples_emit.rs, tests_emit.rs, benches_emit.rs — three peers. Workspace-root output directories already line up: examples/, tests/, benches/.

3. Spin-up uses <service>_server::run(ServerConfig::for_test()) from #117

This is the biggest change. My §4 in the first comment described a custom spin_up_service() helper that duplicated the foreground bootstrap from main.rs. User correctly pushed back on this — custom spin-up paths drift from production and don't reflect the canonical lifecycle.

#117 now covers (per this comment) promoting the scaffolded server crate from bin-only to lib + bin and extracting the foreground bootstrap into <service>_server::run(ServerConfig). The same run() is used by main.rs (prod), this issue's tests/src/lib.rs, #113's benches/runner.rs, and the runnable examples/rust/<domain>/client_server.rs.

Updated tests/src/lib.rs:

//! E2E test support — handwritten, scaffolded once.
//!
//! Re-running the generator never touches this file. Spin-up logic itself
//! lives in `hero_recipes_server::run()` (per #117) — this is just the
//! test-side glue.

use std::sync::Arc;

use anyhow::Result;
use hero_recipes_server::{run, RunningServer, ServerConfig};
use hero_rpc2::prelude::*;
use tempfile::TempDir;

pub struct ServiceHandle {
    pub client: Arc<dyn jsonrpsee::core::client::ClientT + Send + Sync>,
    pub running: RunningServer,
    _data_dir: TempDir,
}

pub async fn spin_up_service() -> Result<ServiceHandle> {
    let (cfg, _data_dir) = ServerConfig::for_test("hero_recipes_server")?;
    let running = run(cfg).await?;
    let client = ClientBuilder::new().connect_http(running.socket_path()).await?;
    Ok(ServiceHandle {
        client: Arc::new(client),
        running,
        _data_dir,
    })
}

spin_up_service() is now a 5-line wrapper around #117's run(). No duplicated ServerBuilder block, no duplicated socket-path math, no duplicated header-lifting config. If the production bootstrap changes shape (lifted headers, service-info fields, anything), the test bootstrap inherits the change automatically.

4. Sequencing — confirmed

Sequence (a) from the first comment is locked in: #115 blocks on #117 (which now bundles types + seeding + bootstrap unification). I won't cut e2e_emit.rs code until #117 lands.


Updated open questions

The four sign-off points from my first comment are answered:

  • §1 — tests_emit.rs naming: yes (reusing the freed name after deleting the old one).
  • §5 — parallel by default: ✓ (each test owns a TempDir + per-test atomic-counter socket via ServerConfig::for_test).
  • §7 — extract fixture.rs as part of this PR: still applies.
  • Open question on sequencing: (a) confirmed.

No new open questions. Will resume coding once #117's expanded scope lands (worktree /tmp/hero_rpc_115 already prepared off latest dev 87289a1; will rebase on top of #117's PR when it merges).

## Design revisions — three decisions confirmed by the user Folding follow-ups into the #115 design. Three substantial changes from my [first design comment](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/115#issuecomment-35620): ### 1. Drop the existing `tests_emit.rs` entirely User signed off on collapsing the test taxonomy from three layers to two. The existing `crates/generator/src/generate/tests_emit.rs` — which emits OSIS-direct dispatch tests inside `<server>/src/<domain>/generated/tests.rs` — goes away as part of this PR. Its CRUD coverage is transitively covered by the new wire-level E2E suite; per-OSIS-layer correctness lives in inline `#[cfg(test)]` blocks where it belongs. Updated three-layer table from §8 of my first comment collapses to: | Layer | Location | What it tests | |---|---|---| | Inline unit | `#[cfg(test)] mod tests` in source | pure-function correctness | | **E2E (this issue)** | **`tests/generated/<entity>_e2e.rs`** | **whole assembled service over real UDS socket via typed SDK** | Deletion scope as part of this PR: - `crates/generator/src/generate/tests_emit.rs` — entire file. - Its call site in the per-domain codegen path. - Existing `<server>/src/<domain>/generated/tests.rs` outputs become stale and stop being emitted; no migration needed since they're regenerated on every build (per #96). ### 2. Emitter file naming — parallel The `generate/` subdirectory has drifted on naming. Cleaning up alongside this PR: | Before | After | Note | |---|---|---| | `tests_emit.rs` (dispatch tests) | _deleted_ | per §1 | | `examples.rs` (workspace-level `basic_crud.rs`) | merged into `examples_emit.rs` | both example emitters live in one file | | `e2e.rs` (per-domain `client_server.rs` example) | merged into `examples_emit.rs` | same | | — | **`tests_emit.rs`** (new — workspace-root E2E tests, this issue) | reuses the freed-up name | | — | `benches_emit.rs` (new — #113) | parallel naming | Final layout: `examples_emit.rs`, `tests_emit.rs`, `benches_emit.rs` — three peers. Workspace-root output directories already line up: `examples/`, `tests/`, `benches/`. ### 3. Spin-up uses `<service>_server::run(ServerConfig::for_test())` from #117 This is the biggest change. My §4 in the first comment described a custom `spin_up_service()` helper that duplicated the foreground bootstrap from `main.rs`. User correctly pushed back on this — custom spin-up paths drift from production and don't reflect the canonical lifecycle. [#117](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/117) now covers (per [this comment](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/117#issuecomment-35662)) promoting the scaffolded server crate from `bin`-only to `lib + bin` and extracting the foreground bootstrap into `<service>_server::run(ServerConfig)`. The same `run()` is used by `main.rs` (prod), this issue's `tests/src/lib.rs`, #113's `benches/runner.rs`, and the runnable `examples/rust/<domain>/client_server.rs`. **Updated `tests/src/lib.rs`:** ```rust //! E2E test support — handwritten, scaffolded once. //! //! Re-running the generator never touches this file. Spin-up logic itself //! lives in `hero_recipes_server::run()` (per #117) — this is just the //! test-side glue. use std::sync::Arc; use anyhow::Result; use hero_recipes_server::{run, RunningServer, ServerConfig}; use hero_rpc2::prelude::*; use tempfile::TempDir; pub struct ServiceHandle { pub client: Arc<dyn jsonrpsee::core::client::ClientT + Send + Sync>, pub running: RunningServer, _data_dir: TempDir, } pub async fn spin_up_service() -> Result<ServiceHandle> { let (cfg, _data_dir) = ServerConfig::for_test("hero_recipes_server")?; let running = run(cfg).await?; let client = ClientBuilder::new().connect_http(running.socket_path()).await?; Ok(ServiceHandle { client: Arc::new(client), running, _data_dir, }) } ``` `spin_up_service()` is now a 5-line wrapper around #117's `run()`. No duplicated `ServerBuilder` block, no duplicated socket-path math, no duplicated header-lifting config. If the production bootstrap changes shape (lifted headers, service-info fields, anything), the test bootstrap inherits the change automatically. ### 4. Sequencing — confirmed Sequence (a) from the first comment is locked in: **#115 blocks on #117** (which now bundles types + seeding + bootstrap unification). I won't cut e2e_emit.rs code until #117 lands. --- ### Updated open questions The four sign-off points from my first comment are answered: - §1 — `tests_emit.rs` naming: yes (reusing the freed name after deleting the old one). - §5 — parallel by default: ✓ (each test owns a `TempDir` + per-test atomic-counter socket via `ServerConfig::for_test`). - §7 — extract `fixture.rs` as part of this PR: still applies. - Open question on sequencing: (a) confirmed. No new open questions. Will resume coding once #117's expanded scope lands (worktree `/tmp/hero_rpc_115` already prepared off latest dev `87289a1`; will rebase on top of #117's PR when it merges).
Author
Owner

Correction — <service>_server stays bin-only

Quick correction on the spin-up shape from my revisions comment. The "tests call hero_recipes_server::run(ServerConfig::for_test())" line is wrong because it implied promoting the server crate to lib + bin — which it must not be (see #117 correction for the full reasoning).

Corrected: the canonical bootstrap lives in hero_rpc_osis::rpc::server::{run, run_for_test}. Tests call:

// tests/src/lib.rs — handwritten, scaffolded once
use std::sync::Arc;
use anyhow::Result;
use hero_recipes_server::recipes::OsisRecipes;   // existing per-domain OSIS type
use hero_rpc_osis::rpc::server::{run_for_test, RunningServer};
use hero_rpc2::prelude::*;
use tempfile::TempDir;

pub struct ServiceHandle {
    pub client: Arc<dyn jsonrpsee::core::client::ClientT + Send + Sync>,
    pub running: RunningServer,
}

pub async fn spin_up_service() -> Result<(ServiceHandle, TempDir)> {
    let (running, data_dir) = run_for_test::<OsisRecipes>("recipes", 0).await?;
    let client = ClientBuilder::new().connect_http(running.socket_path()).await?;
    Ok((ServiceHandle { client: Arc::new(client), running }, data_dir))
}

Five lines of bootstrap glue. No duplicated ServerBuilder block, no custom socket-path math, and crucially — the production main.rs and the test path go through the same hero_rpc_osis::rpc::server module, so any drift in the OSIS-over-rpc2 boot sequence is caught by tests automatically.

hero_recipes_server stays bin-only. Its Cargo.toml gains no [lib] section. The scaffolded tests/Cargo.toml (#115) does pull hero_rpc_osis directly (already a transitive dep, just made direct), with the test-support feature enabled.

Everything else in my revisions comment stands: drop the old tests_emit.rs, rename emitters to examples_emit.rs / tests_emit.rs / benches_emit.rs, parallel tests by default, sequence (a). Worktree at /tmp/hero_rpc_115 still ready.

## Correction — `<service>_server` stays bin-only Quick correction on the spin-up shape from my [revisions comment](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/115#issuecomment-35665). The "tests call `hero_recipes_server::run(ServerConfig::for_test())`" line is **wrong** because it implied promoting the server crate to `lib + bin` — which it must not be (see [#117 correction](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/117#issuecomment-35662) for the full reasoning). **Corrected**: the canonical bootstrap lives in `hero_rpc_osis::rpc::server::{run, run_for_test}`. Tests call: ```rust // tests/src/lib.rs — handwritten, scaffolded once use std::sync::Arc; use anyhow::Result; use hero_recipes_server::recipes::OsisRecipes; // existing per-domain OSIS type use hero_rpc_osis::rpc::server::{run_for_test, RunningServer}; use hero_rpc2::prelude::*; use tempfile::TempDir; pub struct ServiceHandle { pub client: Arc<dyn jsonrpsee::core::client::ClientT + Send + Sync>, pub running: RunningServer, } pub async fn spin_up_service() -> Result<(ServiceHandle, TempDir)> { let (running, data_dir) = run_for_test::<OsisRecipes>("recipes", 0).await?; let client = ClientBuilder::new().connect_http(running.socket_path()).await?; Ok((ServiceHandle { client: Arc::new(client), running }, data_dir)) } ``` Five lines of bootstrap glue. No duplicated `ServerBuilder` block, no custom socket-path math, and crucially — the production `main.rs` and the test path go through the **same** `hero_rpc_osis::rpc::server` module, so any drift in the OSIS-over-rpc2 boot sequence is caught by tests automatically. `hero_recipes_server` stays `bin`-only. Its Cargo.toml gains no `[lib]` section. The scaffolded `tests/Cargo.toml` (#115) does pull `hero_rpc_osis` directly (already a transitive dep, just made direct), with the `test-support` feature enabled. Everything else in my revisions comment stands: drop the old `tests_emit.rs`, rename emitters to `examples_emit.rs` / `tests_emit.rs` / `benches_emit.rs`, parallel tests by default, sequence (a). Worktree at `/tmp/hero_rpc_115` still ready.
timur closed this issue 2026-05-22 00:51:40 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_rpc#115
No description provided.