From abc1a9db3cb9c0f964745562a1906e98ceba442c Mon Sep 17 00:00:00 2001 From: Will Killian Date: Wed, 5 Aug 2026 23:15:49 -0400 Subject: [PATCH 1/3] test: reduce test execution time Signed-off-by: Will Killian --- .../skills/maintain-dynamic-plugins/SKILL.md | 6 + .agents/skills/test-ffi-surface/SKILL.md | 1 + .agents/skills/test-python-binding/SKILL.md | 4 + .agents/skills/test-rust-core/SKILL.md | 1 + .agents/skills/validate-change/SKILL.md | 1 + .github/workflows/ci_go.yml | 2 +- .github/workflows/ci_node.yml | 2 +- .github/workflows/ci_python.yml | 2 +- .github/workflows/ci_rust.yml | 2 +- .../adaptive/tests/integration/redis_tests.rs | 22 +- crates/cli/tests/cli_tests.rs | 12 +- .../coverage/agents/plugin_install_tests.rs | 2 +- .../coverage/shared/agent_process_tests.rs | 3 +- .../tests/fixtures/native_plugin/Cargo.lock | 360 ++++++++++++++++++ .../tests/integration/native_plugin_tests.rs | 56 +-- .../tests/integration/worker_plugin_tests.rs | 51 +-- .../tests/unit/observability/atof_tests.rs | 4 +- .../observability/plugin_component_tests.rs | 10 +- .../integration/plugin_activation_tests.rs | 129 ++----- crates/worker/tests/worker_sdk_tests.rs | 2 +- docs/contribute/testing-and-docs.mdx | 15 + go/nemo_relay/plugin_activation_test.go | 126 ++---- justfile | 66 +++- python/tests/plugin/test_package_build.py | 97 ----- python/tests/test_dynamic_plugin_host.py | 53 +-- scripts/validate_python_plugin_package.py | 105 +++++ 26 files changed, 704 insertions(+), 430 deletions(-) create mode 100644 crates/core/tests/fixtures/native_plugin/Cargo.lock delete mode 100644 python/tests/plugin/test_package_build.py create mode 100644 scripts/validate_python_plugin_package.py diff --git a/.agents/skills/maintain-dynamic-plugins/SKILL.md b/.agents/skills/maintain-dynamic-plugins/SKILL.md index edb3218a9..e1eeaafbe 100644 --- a/.agents/skills/maintain-dynamic-plugins/SKILL.md +++ b/.agents/skills/maintain-dynamic-plugins/SKILL.md @@ -59,6 +59,7 @@ Use this skill for `plugin.kind = "rust_dynamic"`, `plugin.kind = "worker"`, ## Validation ```bash +just build-test-plugin-fixtures cargo test -p nemo-relay-types cargo test -p nemo-relay-plugin cargo test -p nemo-relay-worker-proto @@ -70,6 +71,11 @@ just test-python just docs ``` +The canonical `just test-rust`, `just test-python`, and `just test-go` recipes +prepare plugin fixtures automatically. Run `just build-test-plugin-fixtures` +before raw focused native or worker plugin tests; fixture compilation must not +happen inside an individual test case. + For broad runtime or public API changes, run the full `validate-change` matrix. ## References diff --git a/.agents/skills/test-ffi-surface/SKILL.md b/.agents/skills/test-ffi-surface/SKILL.md index c79ec2f54..06db67d75 100644 --- a/.agents/skills/test-ffi-surface/SKILL.md +++ b/.agents/skills/test-ffi-surface/SKILL.md @@ -36,6 +36,7 @@ just build-go # Required Rust validation cargo fmt --all just test-rust +just build-test-plugin-fixtures cargo test -p nemo-relay-ffi cargo clippy --workspace --all-targets -- -D warnings diff --git a/.agents/skills/test-python-binding/SKILL.md b/.agents/skills/test-python-binding/SKILL.md index e09fd2b4b..bcf8df06a 100644 --- a/.agents/skills/test-python-binding/SKILL.md +++ b/.agents/skills/test-python-binding/SKILL.md @@ -57,6 +57,10 @@ docs/examples. # Focused test loop uv run pytest -k "" +# Required first for focused dynamic-plugin host tests +just build-test-plugin-fixtures +uv run pytest python/tests/test_dynamic_plugin_host.py + # Focused Python worker plugin SDK suite just test-python-plugin diff --git a/.agents/skills/test-rust-core/SKILL.md b/.agents/skills/test-rust-core/SKILL.md index d5d2fa5ca..a6c78fd87 100644 --- a/.agents/skills/test-rust-core/SKILL.md +++ b/.agents/skills/test-rust-core/SKILL.md @@ -47,6 +47,7 @@ cargo test -p nemo-relay cargo test -p nemo-relay-adaptive # Dynamic plugin crates when touched +just build-test-plugin-fixtures cargo test -p nemo-relay-types cargo test -p nemo-relay-plugin cargo test -p nemo-relay-worker-proto diff --git a/.agents/skills/validate-change/SKILL.md b/.agents/skills/validate-change/SKILL.md index 2b4eb7106..02085c8a8 100644 --- a/.agents/skills/validate-change/SKILL.md +++ b/.agents/skills/validate-change/SKILL.md @@ -78,6 +78,7 @@ just test-node ```bash # Rust only just build-rust +just build-test-plugin-fixtures just test-rust just ci=true test-rust cargo fmt --all diff --git a/.github/workflows/ci_go.yml b/.github/workflows/ci_go.yml index 9cdf4ebdc..cf06434cb 100644 --- a/.github/workflows/ci_go.yml +++ b/.github/workflows/ci_go.yml @@ -24,7 +24,7 @@ jobs: Test: name: Test (${{ matrix.platform }}) runs-on: ${{ matrix.runner }} - timeout-minutes: 120 + timeout-minutes: 30 continue-on-error: ${{ startsWith(matrix.platform, 'windows') }} permissions: contents: read diff --git a/.github/workflows/ci_node.yml b/.github/workflows/ci_node.yml index 30fdfcef4..9f96ef560 100644 --- a/.github/workflows/ci_node.yml +++ b/.github/workflows/ci_node.yml @@ -43,7 +43,7 @@ jobs: Test: name: Test (${{ matrix.platform }}) runs-on: ${{ matrix.runner }} - timeout-minutes: 120 + timeout-minutes: 30 continue-on-error: ${{ startsWith(matrix.platform, 'windows') }} permissions: contents: read diff --git a/.github/workflows/ci_python.yml b/.github/workflows/ci_python.yml index c0fdc181f..6a7453610 100644 --- a/.github/workflows/ci_python.yml +++ b/.github/workflows/ci_python.yml @@ -43,7 +43,7 @@ jobs: Test: name: Test (${{ matrix.platform }}) runs-on: ${{ matrix.runner }} - timeout-minutes: 120 + timeout-minutes: 30 continue-on-error: ${{ startsWith(matrix.platform, 'windows') }} permissions: contents: read diff --git a/.github/workflows/ci_rust.yml b/.github/workflows/ci_rust.yml index 5cdd1c361..e6640a936 100644 --- a/.github/workflows/ci_rust.yml +++ b/.github/workflows/ci_rust.yml @@ -30,7 +30,7 @@ jobs: Test: name: Test (${{ matrix.platform }}) runs-on: ${{ matrix.runner }} - timeout-minutes: 120 + timeout-minutes: 30 continue-on-error: ${{ startsWith(matrix.platform, 'windows') }} permissions: contents: read diff --git a/crates/adaptive/tests/integration/redis_tests.rs b/crates/adaptive/tests/integration/redis_tests.rs index 8e2eca4f4..d50d383e4 100644 --- a/crates/adaptive/tests/integration/redis_tests.rs +++ b/crates/adaptive/tests/integration/redis_tests.rs @@ -55,12 +55,7 @@ fn enable_operational_logs() { /// Redis tests were not explicitly enabled or Redis is unavailable. async fn get_test_redis() -> Option { enable_operational_logs(); - let redis_test_env = - std::env::var_os(REDIS_TEST_ENV).map(|value| value.to_string_lossy().into_owned()); - if !env_value_is_truthy(redis_test_env.as_deref()) { - eprintln!( - "SKIP: set {REDIS_TEST_ENV} to a truthy value (for example, {REDIS_TEST_ENV}=1) to run Redis-backed tests" - ); + if !redis_tests_enabled() { return None; } @@ -75,8 +70,23 @@ async fn get_test_redis() -> Option { } } +fn redis_tests_enabled() -> bool { + let redis_test_env = + std::env::var_os(REDIS_TEST_ENV).map(|value| value.to_string_lossy().into_owned()); + if !env_value_is_truthy(redis_test_env.as_deref()) { + eprintln!( + "SKIP: set {REDIS_TEST_ENV} to a truthy value (for example, {REDIS_TEST_ENV}=1) to run Redis-backed tests" + ); + return false; + } + true +} + async fn get_test_redis_with_prefix() -> Option<(RedisBackend, String)> { enable_operational_logs(); + if !redis_tests_enabled() { + return None; + } let prefix = format!("test:{}:", Uuid::now_v7()); match RedisBackend::new("redis://127.0.0.1/", prefix.clone()).await { Ok(backend) => Some((backend, prefix)), diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 356d85586..9be2f27c2 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -25,7 +25,7 @@ fn gateway_bin() -> &'static str { const ACTIVE_GENERATION_TOKEN: &str = "active-generation"; const BOOTSTRAP_PROTOCOL_VERSION: u64 = 3; -const SIDECAR_PUBLICATION_TIMEOUT: Duration = Duration::from_secs(30); +const SIDECAR_PUBLICATION_TIMEOUT: Duration = Duration::from_secs(5); fn write_active_generation(temp: &std::path::Path) -> std::path::PathBuf { let generation = temp.join("plugin/.nemo-relay-generation"); @@ -1466,7 +1466,7 @@ fn find_runtime_files_matching( } fn wait_child(child: &mut Child) -> ExitStatus { - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + Duration::from_secs(5); loop { if let Some(status) = child.try_wait().unwrap() { return status; @@ -1523,7 +1523,7 @@ fn wait_child_with_output(mut child: Child) -> Output { let stdout = read_pipe(child.stdout.take()); let stderr = read_pipe(child.stderr.take()); - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + Duration::from_secs(5); let status = loop { if let Some(status) = child.try_wait().unwrap() { break status; @@ -1607,7 +1607,7 @@ fn run_persistent_hook_with_token( } fn wait_for_port_closed(address: SocketAddr) { - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + Duration::from_secs(5); loop { if TcpStream::connect_timeout(&address, Duration::from_millis(100)).is_err() { return; @@ -4340,7 +4340,7 @@ fn assert_non_tty_signal_forwarding( #[cfg(unix)] fn wait_for_agent_pid_file(relay: &mut std::process::Child, pids: &Path, signal_name: &str) { - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + Duration::from_secs(5); while !pids.is_file() { if Instant::now() >= deadline { // SAFETY: Relay's PID is live and owned by this test; SIGTERM exercises its registered @@ -4592,7 +4592,7 @@ fn collect_replacement_requests( stopped: &AtomicBool, requests: &Mutex>, ) { - let deadline = Instant::now() + Duration::from_secs(12); + let deadline = Instant::now() + Duration::from_secs(5); while !stopped.load(Ordering::Relaxed) && Instant::now() < deadline { match listener.accept() { Ok((mut stream, _)) => { diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index d20daa95b..fb68ef9b5 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -1168,7 +1168,7 @@ fn cross_process_lock_holder() { return; } std::fs::write(ready, b"ready").unwrap(); - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + Duration::from_secs(5); while !release.exists() { assert!(Instant::now() < deadline, "lock holder release timed out"); thread::sleep(Duration::from_millis(10)); diff --git a/crates/cli/tests/coverage/shared/agent_process_tests.rs b/crates/cli/tests/coverage/shared/agent_process_tests.rs index dfaba9b74..894a434d4 100644 --- a/crates/cli/tests/coverage/shared/agent_process_tests.rs +++ b/crates/cli/tests/coverage/shared/agent_process_tests.rs @@ -213,8 +213,7 @@ while (-not (Test-Path -LiteralPath $args[1])) { }; std::fs::write(release_path, b"ready").unwrap(); - let status = match tokio::time::timeout(std::time::Duration::from_secs(15), child.wait()).await - { + let status = match tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await { Ok(status) => status.unwrap(), Err(_) => { let _ = child.terminate().await; diff --git a/crates/core/tests/fixtures/native_plugin/Cargo.lock b/crates/core/tests/fixtures/native_plugin/Cargo.lock new file mode 100644 index 000000000..33325b40d --- /dev/null +++ b/crates/core/tests/fixtures/native_plugin/Cargo.lock @@ -0,0 +1,360 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nemo-relay-plugin" +version = "0.8.0" +dependencies = [ + "nemo-relay-types", + "serde", + "serde_json", +] + +[[package]] +name = "nemo-relay-plugin-fixture" +version = "0.0.0" +dependencies = [ + "nemo-relay-plugin", + "serde_json", +] + +[[package]] +name = "nemo-relay-types" +version = "0.8.0" +dependencies = [ + "bitflags", + "chrono", + "serde", + "serde_json", + "typed-builder", + "uuid", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/core/tests/integration/native_plugin_tests.rs b/crates/core/tests/integration/native_plugin_tests.rs index da6a4fa18..039ba6340 100644 --- a/crates/core/tests/integration/native_plugin_tests.rs +++ b/crates/core/tests/integration/native_plugin_tests.rs @@ -777,7 +777,7 @@ async fn native_v3_async_registration_supports_all_middleware_kinds() { let pending = tokio::spawn(async { tool_request_intercepts("async-pending", json!({"input": true})).await }); - tokio::time::timeout(std::time::Duration::from_secs(10), async { + tokio::time::timeout(std::time::Duration::from_secs(5), async { while !unsafe { pending_entered() } { tokio::task::yield_now().await; } @@ -814,7 +814,7 @@ async fn native_v3_async_registration_supports_all_middleware_kinds() { ) .await }); - tokio::time::timeout(std::time::Duration::from_secs(10), async { + tokio::time::timeout(std::time::Duration::from_secs(5), async { while !unsafe { pending_entered() } { tokio::task::yield_now().await; } @@ -1572,7 +1572,7 @@ async fn plugin_host_clear_allows_an_in_flight_native_callback_to_finish() { }); entered_rx - .recv_timeout(std::time::Duration::from_secs(10)) + .recv_timeout(std::time::Duration::from_secs(5)) .expect("native callback should enter its continuation"); activation .clear() @@ -2037,8 +2037,6 @@ fn find_event<'a>( } struct BuiltFixture { - _source_dir: TempDir, - _target_dir: TempDir, manifest_dir: TempDir, library_path: PathBuf, } @@ -2046,54 +2044,30 @@ struct BuiltFixture { fn build_fixture_plugin() -> BuiltFixture { let _ = spdlog::init_log_crate_proxy(); log::set_max_level(log::LevelFilter::Info); - let source_dir = TempDir::new().expect("fixture source dir"); - let fixture_dir = source_dir.path().join("native_plugin"); - let fixture_src_dir = fixture_dir.join("src"); - std::fs::create_dir_all(&fixture_src_dir).expect("fixture src dir"); - let native_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../plugin"); - let fixture_manifest = std::fs::read_to_string( - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/native_plugin/Cargo.toml"), - ) - .expect("fixture Cargo.toml template") - .replace( - r#"nemo-relay-plugin = { path = "../../../../plugin" }"#, - &format!("nemo-relay-plugin = {{ path = {native_path:?} }}"), - ); - std::fs::write(fixture_dir.join("Cargo.toml"), fixture_manifest).expect("fixture Cargo.toml"); - std::fs::copy( - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/native_plugin/src/lib.rs"), - fixture_src_dir.join("lib.rs"), - ) - .expect("fixture lib.rs"); - let target_dir = TempDir::new().expect("fixture target dir"); let manifest_dir = TempDir::new().expect("fixture manifest dir"); - let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); - let status = Command::new(cargo) - .arg("build") - .arg("--quiet") - .arg("--manifest-path") - .arg(fixture_dir.join("Cargo.toml")) - .arg("--target-dir") - .arg(target_dir.path()) - .status() - .expect("fixture cargo build should start"); - assert!(status.success(), "fixture cargo build failed: {status}"); - - let library_path = target_dir.path().join("debug").join(fixture_library_name()); + let library_path = prepared_plugin_fixture("NEMO_RELAY_TEST_NATIVE_PLUGIN"); assert!( library_path.exists(), - "fixture library missing at {}", + "fixture library is missing; run `just build-test-plugin-fixtures`: {}", library_path.display() ); BuiltFixture { - _source_dir: source_dir, - _target_dir: target_dir, manifest_dir, library_path, } } +fn prepared_plugin_fixture(environment: &str) -> PathBuf { + std::env::var_os(environment) + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/test-plugin-fixtures/debug") + .join(fixture_library_name()) + }) +} + fn write_manifest(fixture: &BuiltFixture) -> PathBuf { write_manifest_text(ManifestOptions { manifest_dir: fixture.manifest_dir.path(), diff --git a/crates/core/tests/integration/worker_plugin_tests.rs b/crates/core/tests/integration/worker_plugin_tests.rs index 358447ae9..d1ac3db67 100644 --- a/crates/core/tests/integration/worker_plugin_tests.rs +++ b/crates/core/tests/integration/worker_plugin_tests.rs @@ -4,8 +4,7 @@ //! Integration coverage for gRPC worker dynamic plugins. use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, Mutex}; use futures::StreamExt; use nemo_relay::api::event::{Event, ScopeCategory}; @@ -1319,34 +1318,22 @@ impl BuiltWorkerFixture { fn build_fixture_worker() -> BuiltWorkerFixture { enable_operational_logs(); - static FIXTURE_BINARY: OnceLock = OnceLock::new(); - let binary_path = FIXTURE_BINARY.get_or_init(|| { - let fixture_dir = fixture_root(); - let target_root = - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/worker-plugin-fixture"); - let target_dir = target_root.join("target"); - let manifest = fixture_dir.join("Cargo.toml"); - let status = Command::new("cargo") - .arg("build") - .arg("--quiet") - .arg("--locked") - .arg("--manifest-path") - .arg(&manifest) - .arg("--target-dir") - .arg(&target_dir) - .status() - .expect("fixture worker build should start"); - assert!(status.success(), "fixture worker build should succeed"); - let binary_path = target_dir.join("debug").join(format!( - "nemo-relay-worker-plugin-fixture{}", - std::env::consts::EXE_SUFFIX - )); - assert!(binary_path.exists(), "fixture worker binary should exist"); - binary_path - }); - BuiltWorkerFixture { - binary_path: binary_path.clone(), - } + let binary_path = std::env::var_os("NEMO_RELAY_TEST_WORKER_PLUGIN") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/test-plugin-fixtures/debug") + .join(format!( + "nemo-relay-worker-plugin-fixture{}", + std::env::consts::EXE_SUFFIX + )) + }); + assert!( + binary_path.exists(), + "fixture worker binary is missing; run `just build-test-plugin-fixtures`: {}", + binary_path.display() + ); + BuiltWorkerFixture { binary_path } } fn write_manifest(binary: &Path) -> (TempDir, PathBuf) { @@ -1460,10 +1447,6 @@ impl Drop for EnvVarGuard { } } -fn fixture_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/worker_plugin") -} - fn find_event<'a>( events: &'a [Event], name: &str, diff --git a/crates/core/tests/unit/observability/atof_tests.rs b/crates/core/tests/unit/observability/atof_tests.rs index 1e2d8e3e7..87c398c86 100644 --- a/crates/core/tests/unit/observability/atof_tests.rs +++ b/crates/core/tests/unit/observability/atof_tests.rs @@ -1628,12 +1628,12 @@ fn http_endpoint_worker_acknowledges_flush_close_and_logs_http_errors() { let (flush_tx, flush_rx) = std::sync::mpsc::channel(); tx.send(EndpointMessage::Flush(flush_tx)).unwrap(); flush_rx - .recv_timeout(std::time::Duration::from_secs(10)) + .recv_timeout(std::time::Duration::from_secs(5)) .unwrap(); let (close_tx, close_rx) = std::sync::mpsc::channel(); tx.send(EndpointMessage::Close(close_tx)).unwrap(); close_rx - .recv_timeout(std::time::Duration::from_secs(10)) + .recv_timeout(std::time::Duration::from_secs(5)) .unwrap(); worker.join().unwrap(); server.join().unwrap(); diff --git a/crates/core/tests/unit/observability/plugin_component_tests.rs b/crates/core/tests/unit/observability/plugin_component_tests.rs index e12ff0d09..d3a9090ab 100644 --- a/crates/core/tests/unit/observability/plugin_component_tests.rs +++ b/crates/core/tests/unit/observability/plugin_component_tests.rs @@ -116,7 +116,7 @@ fn start_http_status_server( let url = format!("http://{}", listener.local_addr().unwrap()); let server = std::thread::spawn(move || -> std::io::Result<()> { listener.set_nonblocking(true)?; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); let (mut stream, _) = loop { match listener.accept() { Ok(connection) => break connection, @@ -133,7 +133,7 @@ fn start_http_status_server( } }; stream.set_nonblocking(false)?; - stream.set_read_timeout(Some(std::time::Duration::from_secs(10)))?; + stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?; let mut request = Vec::new(); let mut byte = [0_u8; 1]; while !request.ends_with(b"\r\n\r\n") { @@ -195,7 +195,7 @@ fn start_otlp_capture_server() -> (String, mpsc::Receiver>) { std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); stream - .set_read_timeout(Some(Duration::from_secs(10))) + .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); let mut request = Vec::new(); let mut byte = [0_u8; 1]; @@ -2861,7 +2861,7 @@ fn opentelemetry_endpoints_fan_out_to_heterogeneous_and_repeated_types() { for request in [full_request, gen_ai_request, repeated_request] { let body = request - .recv_timeout(Duration::from_secs(10)) + .recv_timeout(Duration::from_secs(5)) .expect("each configured endpoint should receive the exported span"); assert!(!body.is_empty()); } @@ -3076,7 +3076,7 @@ fn opentelemetry_endpoint_delivery_failure_does_not_block_other_endpoints() { let _ = clear_plugin_configuration(); let body = healthy_request - .recv_timeout(Duration::from_secs(10)) + .recv_timeout(Duration::from_secs(5)) .expect("healthy endpoint should receive spans despite another endpoint failing"); assert!(!body.is_empty()); } diff --git a/crates/ffi/tests/integration/plugin_activation_tests.rs b/crates/ffi/tests/integration/plugin_activation_tests.rs index c00b13736..f122d92ad 100644 --- a/crates/ffi/tests/integration/plugin_activation_tests.rs +++ b/crates/ffi/tests/integration/plugin_activation_tests.rs @@ -5,8 +5,8 @@ use super::*; use std::path::{Path, PathBuf}; use std::process::Command; use std::ptr; +use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Mutex, OnceLock}; use nemo_relay_ffi::types::{FfiPluginActivation, nemo_relay_plugin_activation_free}; use tempfile::TempDir; @@ -17,11 +17,6 @@ static DISCOVERED_STATIC_REGISTRATIONS: AtomicUsize = AtomicUsize::new(0); static DISCOVERED_STATIC_CALLBACKS: AtomicUsize = AtomicUsize::new(0); static DISCOVERED_STATIC_CONFIG: Mutex> = Mutex::new(None); -struct NativeFixture { - _source_dir: TempDir, - library: PathBuf, -} - struct PluginDiscoveryTestEnv { previous_cwd: PathBuf, previous_xdg_config_home: Option, @@ -523,89 +518,41 @@ fn plugin_kinds() -> Vec { } fn build_native_fixture() -> &'static Path { - static FIXTURE: OnceLock = OnceLock::new(); - &FIXTURE - .get_or_init(|| { - let source_dir = TempDir::new().expect("native fixture source tempdir"); - let fixture_dir = source_dir.path().join("native_plugin"); - let source = fixture_dir.join("src"); - std::fs::create_dir_all(&source).expect("native fixture src dir"); - let plugin_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../plugin"); - let manifest_template = std::fs::read_to_string( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../core/tests/fixtures/native_plugin/Cargo.toml"), - ) - .expect("native fixture Cargo.toml"); - let manifest = manifest_template.replace( - r#"nemo-relay-plugin = { path = "../../../../plugin" }"#, - &format!("nemo-relay-plugin = {{ path = {plugin_path:?} }}"), - ); - std::fs::write(fixture_dir.join("Cargo.toml"), manifest) - .expect("write native fixture Cargo.toml"); - std::fs::copy( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../core/tests/fixtures/native_plugin/src/lib.rs"), - source.join("lib.rs"), - ) - .expect("copy native fixture source"); - - // Nextest runs each test in a separate process. Keep this process's - // generated crate and target directory together so parallel tests do - // not race on a shared fixture artifact. - let target = source_dir.path().join("target"); - let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) - .arg("build") - .arg("--quiet") - .arg("--manifest-path") - .arg(fixture_dir.join("Cargo.toml")) - .arg("--target-dir") - .arg(&target) - .status() - .expect("native fixture build should start"); - assert!(status.success(), "native fixture build failed: {status}"); - let library = target.join("debug").join(native_library_name()); - assert!( - library.exists(), - "missing native fixture: {}", - library.display() - ); - NativeFixture { - _source_dir: source_dir, - library, - } - }) - .library + prepared_fixture("NEMO_RELAY_TEST_NATIVE_PLUGIN") } fn build_worker_fixture() -> &'static Path { - static FIXTURE: OnceLock = OnceLock::new(); - FIXTURE.get_or_init(|| { - let manifest = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../core/tests/fixtures/worker_plugin/Cargo.toml"); - let target = - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/ffi-worker-plugin-fixture"); - let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) - .arg("build") - .arg("--quiet") - .arg("--locked") - .arg("--manifest-path") - .arg(manifest) - .arg("--target-dir") - .arg(&target) - .status() - .expect("worker fixture build should start"); - assert!(status.success(), "worker fixture build failed: {status}"); - let binary = target.join("debug").join(format!( - "nemo-relay-worker-plugin-fixture{}", - std::env::consts::EXE_SUFFIX - )); - assert!( - binary.exists(), - "missing worker fixture: {}", - binary.display() - ); - binary - }) + prepared_fixture("NEMO_RELAY_TEST_WORKER_PLUGIN") +} + +fn prepared_fixture(environment: &str) -> &'static Path { + let path = std::env::var_os(environment) + .map(PathBuf::from) + .unwrap_or_else(|| { + let filename = if environment == "NEMO_RELAY_TEST_NATIVE_PLUGIN" { + if cfg!(target_os = "windows") { + "nemo_relay_plugin_fixture.dll".into() + } else if cfg!(target_os = "macos") { + "libnemo_relay_plugin_fixture.dylib".into() + } else { + "libnemo_relay_plugin_fixture.so".into() + } + } else { + format!( + "nemo-relay-worker-plugin-fixture{}", + std::env::consts::EXE_SUFFIX + ) + }; + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/test-plugin-fixtures/debug") + .join(filename) + }); + assert!( + path.exists(), + "plugin test fixture is missing; run `just build-test-plugin-fixtures`: {}", + path.display() + ); + Box::leak(path.into_boxed_path()) } fn write_native_manifest(directory: &Path, library: &Path) -> PathBuf { @@ -675,13 +622,3 @@ entrypoint = {entrypoint:?} .expect("write worker fixture manifest"); manifest } - -fn native_library_name() -> &'static str { - if cfg!(target_os = "windows") { - "nemo_relay_plugin_fixture.dll" - } else if cfg!(target_os = "macos") { - "libnemo_relay_plugin_fixture.dylib" - } else { - "libnemo_relay_plugin_fixture.so" - } -} diff --git a/crates/worker/tests/worker_sdk_tests.rs b/crates/worker/tests/worker_sdk_tests.rs index d120425b6..28a2cbfb9 100644 --- a/crates/worker/tests/worker_sdk_tests.rs +++ b/crates/worker/tests/worker_sdk_tests.rs @@ -53,7 +53,7 @@ use tower::service_fn; const ACTIVATION_ID: &str = "activation-1"; const AUTH_TOKEN: &str = "secret-token"; const PLUGIN_ID: &str = "acme.worker"; -const WORKER_TEST_TIMEOUT: Duration = Duration::from_secs(10); +const WORKER_TEST_TIMEOUT: Duration = Duration::from_secs(5); const REQUIRED_WORKER_ENVS: &[&str] = &[ "NEMO_RELAY_WORKER_SOCKET", "NEMO_RELAY_HOST_SOCKET", diff --git a/docs/contribute/testing-and-docs.mdx b/docs/contribute/testing-and-docs.mdx index 294d4e8c6..4b03e8e75 100644 --- a/docs/contribute/testing-and-docs.mdx +++ b/docs/contribute/testing-and-docs.mdx @@ -27,6 +27,20 @@ just test-node just test-openclaw ``` +The Rust, Python, and Go recipes build the native and worker dynamic-plugin test +fixtures once before starting their test runners. Before a focused raw test that +uses those fixtures, prepare them explicitly: + +```bash +just build-test-plugin-fixtures +``` + +Keep normal hermetic test cases below five seconds. Do not compile packages or +fixtures inside a test case, keep real-time synchronization guards at five +seconds or less, and use virtual time when validating timer behavior. Package +builds and opt-in live end-to-end checks remain separate validation steps with +their own runtime budgets. + Use the matching build recipes when you need explicit build-only passes: ```bash @@ -46,6 +60,7 @@ Run the Rust validation loop when a change touches the core runtime or Rust-facing API surface. ```bash +just build-test-plugin-fixtures cargo test --workspace ``` diff --git a/go/nemo_relay/plugin_activation_test.go b/go/nemo_relay/plugin_activation_test.go index 7387cc13e..fe80328ba 100644 --- a/go/nemo_relay/plugin_activation_test.go +++ b/go/nemo_relay/plugin_activation_test.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "os" - "os/exec" "path/filepath" "regexp" "runtime" @@ -32,14 +31,8 @@ const ( ) var ( - goNativePluginFixtureOnce sync.Once - goNativePluginFixturePath string - goNativePluginFixtureErr error - goWorkerPluginFixtureOnce sync.Once - goWorkerPluginFixturePath string - goWorkerPluginFixtureErr error - workspacePackagePattern = regexp.MustCompile(`(?ms)^[\t ]*\[workspace\.package\][\t ]*(?:#[^\r\n]*)?\r?\n(.*?)(?:^[\t ]*\[|\z)`) - workspaceVersionPattern = regexp.MustCompile(`(?m)^[\t ]*version[\t ]*=[\t ]*(?:"([^"\r\n]+)"|'([^'\r\n]+)')[\t ]*(?:#[^\r\n]*)?\r?$`) + workspacePackagePattern = regexp.MustCompile(`(?ms)^[\t ]*\[workspace\.package\][\t ]*(?:#[^\r\n]*)?\r?\n(.*?)(?:^[\t ]*\[|\z)`) + workspaceVersionPattern = regexp.MustCompile(`(?m)^[\t ]*version[\t ]*=[\t ]*(?:"([^"\r\n]+)"|'([^'\r\n]+)')[\t ]*(?:#[^\r\n]*)?\r?$`) ) func withPluginActivationStubs(t *testing.T) { @@ -968,95 +961,41 @@ func createUnclosedPluginActivation(t *testing.T, specs []DynamicPluginActivatio func goNativePluginFixture(t *testing.T) string { t.Helper() - goNativePluginFixtureOnce.Do(func() { - goNativePluginFixturePath, goNativePluginFixtureErr = buildGoNativePluginFixture() - }) - if goNativePluginFixtureErr != nil { - t.Fatal(goNativePluginFixtureErr) - } - return goNativePluginFixturePath + return preparedPluginFixture(t, "NEMO_RELAY_TEST_NATIVE_PLUGIN") } -func buildGoNativePluginFixture() (string, error) { - repoRoot, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - return "", err - } - sourceRoot, err := os.MkdirTemp("", "nemo-relay-go-native-plugin-") - if err != nil { - return "", err - } - defer os.RemoveAll(sourceRoot) - fixtureRoot := filepath.Join(sourceRoot, "native_plugin") - if err := os.MkdirAll(filepath.Join(fixtureRoot, "src"), 0o700); err != nil { - return "", err - } - fixtureSource := filepath.Join(repoRoot, "crates", "core", "tests", "fixtures", "native_plugin") - manifestBytes, err := os.ReadFile(filepath.Join(fixtureSource, cargoManifestName)) - if err != nil { - return "", err - } - pluginPath := filepath.Join(repoRoot, "crates", "plugin") - manifestContents := strings.Replace(string(manifestBytes), `nemo-relay-plugin = { path = "../../../../plugin" }`, fmt.Sprintf("nemo-relay-plugin = { path = %q }", pluginPath), 1) - manifest := filepath.Join(fixtureRoot, cargoManifestName) - if err := os.WriteFile(manifest, []byte(manifestContents), 0o600); err != nil { - return "", err - } - librarySource, err := os.ReadFile(filepath.Join(fixtureSource, "src", "lib.rs")) - if err != nil { - return "", err - } - if err := os.WriteFile(filepath.Join(fixtureRoot, "src", "lib.rs"), librarySource, 0o600); err != nil { - return "", err - } - target := filepath.Join(repoRoot, "target") - cargo := os.Getenv("CARGO") - if cargo == "" { - cargo = "cargo" - } - command := exec.Command(cargo, "build", "--quiet", "--manifest-path", manifest, "--target-dir", target) - if output, err := command.CombinedOutput(); err != nil { - return "", fmt.Errorf("build native plugin fixture: %w\n%s", err, output) - } - fixturePath := filepath.Join(target, "debug", goNativeLibraryName()) - if _, err := os.Stat(fixturePath); err != nil { - return "", fmt.Errorf("native plugin fixture output: %w", err) - } - return fixturePath, nil +func goWorkerPluginFixture(t *testing.T) string { + t.Helper() + return preparedPluginFixture(t, "NEMO_RELAY_TEST_WORKER_PLUGIN") } -func goWorkerPluginFixture(t *testing.T) string { +func preparedPluginFixture(t *testing.T, environment string) string { t.Helper() - goWorkerPluginFixtureOnce.Do(func() { + path := os.Getenv(environment) + if path == "" { repoRoot, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { - goWorkerPluginFixtureErr = err - return - } - manifest := filepath.Join(repoRoot, "crates", "core", "tests", "fixtures", "worker_plugin", cargoManifestName) - target := filepath.Join(repoRoot, "target") - cargo := os.Getenv("CARGO") - if cargo == "" { - cargo = "cargo" - } - command := exec.Command(cargo, "build", "--quiet", "--locked", "--manifest-path", manifest, "--target-dir", target) - if output, err := command.CombinedOutput(); err != nil { - goWorkerPluginFixtureErr = fmt.Errorf("build worker plugin fixture: %w\n%s", err, output) - return + t.Fatal(err) } - executable := "nemo-relay-worker-plugin-fixture" - if runtime.GOOS == "windows" { - executable += ".exe" - } - goWorkerPluginFixturePath = filepath.Join(target, "debug", executable) - if _, err := os.Stat(goWorkerPluginFixturePath); err != nil { - goWorkerPluginFixtureErr = fmt.Errorf("worker plugin fixture output: %w", err) + filename := "nemo-relay-worker-plugin-fixture" + if environment == "NEMO_RELAY_TEST_NATIVE_PLUGIN" { + switch runtime.GOOS { + case "windows": + filename = "nemo_relay_plugin_fixture.dll" + case "darwin": + filename = "libnemo_relay_plugin_fixture.dylib" + default: + filename = "libnemo_relay_plugin_fixture.so" + } + } else if runtime.GOOS == "windows" { + filename += ".exe" } - }) - if goWorkerPluginFixtureErr != nil { - t.Fatal(goWorkerPluginFixtureErr) + path = filepath.Join(repoRoot, "target", "test-plugin-fixtures", "debug", filename) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("plugin test fixture %q is missing; run `just build-test-plugin-fixtures`: %v", path, err) } - return goWorkerPluginFixturePath + return path } func writeGoNativePluginManifest(t *testing.T, library string) string { @@ -1196,14 +1135,3 @@ func TestWorkspaceVersionFromCargoTOML(t *testing.T) { }) } } - -func goNativeLibraryName() string { - switch runtime.GOOS { - case "windows": - return "nemo_relay_plugin_fixture.dll" - case "darwin": - return "libnemo_relay_plugin_fixture.dylib" - default: - return "libnemo_relay_plugin_fixture.so" - } -} diff --git a/justfile b/justfile index 4d331e8c3..a84c6c23c 100644 --- a/justfile +++ b/justfile @@ -867,6 +867,52 @@ prepend_go_bin_to_path() { export PATH="$go_bin:$PATH" } +prepare_test_plugin_fixtures() { + local target_dir="$NEMO_RELAY_REPO_ROOT/target/test-plugin-fixtures" + local native_library="" + local worker_executable="nemo-relay-worker-plugin-fixture" + local host_os="" + + host_os="$(uname -s 2>/dev/null || true)" + case "${RUNNER_OS:-}:${OSTYPE:-}:$host_os" in + Windows:*|*:msys*:*|*:win32*:*|*:*:MINGW*|*:*:MSYS*|*:*:CYGWIN*) + native_library="nemo_relay_plugin_fixture.dll" + worker_executable="${worker_executable}.exe" + ;; + *:darwin*:*|*:*:Darwin) + native_library="libnemo_relay_plugin_fixture.dylib" + ;; + *) + native_library="libnemo_relay_plugin_fixture.so" + ;; + esac + + cd "$NEMO_RELAY_REPO_ROOT" + cargo build --quiet --locked \ + --manifest-path crates/core/tests/fixtures/native_plugin/Cargo.toml \ + --target-dir "$target_dir" + cargo build --quiet --locked \ + --manifest-path crates/core/tests/fixtures/worker_plugin/Cargo.toml \ + --target-dir "$target_dir" + + export NEMO_RELAY_TEST_NATIVE_PLUGIN="$target_dir/debug/$native_library" + export NEMO_RELAY_TEST_WORKER_PLUGIN="$target_dir/debug/$worker_executable" + if [[ ! -f "$NEMO_RELAY_TEST_NATIVE_PLUGIN" ]]; then + echo "ERROR: missing native plugin test fixture: $NEMO_RELAY_TEST_NATIVE_PLUGIN" >&2 + exit 1 + fi + if [[ ! -f "$NEMO_RELAY_TEST_WORKER_PLUGIN" ]]; then + echo "ERROR: missing worker plugin test fixture: $NEMO_RELAY_TEST_WORKER_PLUGIN" >&2 + exit 1 + fi + + if command -v cygpath >/dev/null 2>&1; then + NEMO_RELAY_TEST_NATIVE_PLUGIN="$(cygpath -w "$NEMO_RELAY_TEST_NATIVE_PLUGIN")" + NEMO_RELAY_TEST_WORKER_PLUGIN="$(cygpath -w "$NEMO_RELAY_TEST_WORKER_PLUGIN")" + export NEMO_RELAY_TEST_NATIVE_PLUGIN NEMO_RELAY_TEST_WORKER_PLUGIN + fi +} + prepare_llvm_cov_workspace() { eval "$(cargo llvm-cov show-env --sh)" cargo llvm-cov clean --workspace @@ -1015,12 +1061,23 @@ check-python-worker-proto: assert pb.LLM_STREAM_EXECUTION_INTERCEPT == 25 PY -generate-worker-plugin-lockfile: +generate-test-plugin-lockfiles: #!/usr/bin/env bash {{ bash_helpers }} cd "$NEMO_RELAY_REPO_ROOT" + cargo generate-lockfile --manifest-path crates/core/tests/fixtures/native_plugin/Cargo.toml cargo generate-lockfile --manifest-path crates/core/tests/fixtures/worker_plugin/Cargo.toml +generate-worker-plugin-lockfile: generate-test-plugin-lockfiles + +# Build the native and worker dynamic-plugin fixtures once for test processes. +build-test-plugin-fixtures: + #!/usr/bin/env bash + {{ bash_helpers }} + prepare_test_plugin_fixtures + printf 'Native plugin fixture: %s\n' "$NEMO_RELAY_TEST_NATIVE_PLUGIN" + printf 'Worker plugin fixture: %s\n' "$NEMO_RELAY_TEST_WORKER_PLUGIN" + # --set [ci=true|false] build-go: @@ -1158,6 +1215,7 @@ test-rust: if rust_source_coverage_supported; then prepare_llvm_cov_workspace fi + prepare_test_plugin_fixtures cargo nextest run --workspace --profile ci --no-fail-fast cp "$NEMO_RELAY_REPO_ROOT/target/nextest/ci/rust_junit_report.xml" "$junit_out" if rust_source_coverage_supported; then @@ -1167,6 +1225,7 @@ test-rust: --output-path "$coverage_out" fi else + prepare_test_plugin_fixtures cargo test --workspace --exclude nemo-relay-ffi cargo test -p nemo-relay-ffi -- --test-threads=1 fi @@ -1214,6 +1273,8 @@ test-python: fi use_project_python_source "$python_executable" "$python_executable" -m maturin develop --skip-install + prepare_test_plugin_fixtures + pytest_cmd+=(--durations=25) "$python_executable" -m "${pytest_cmd[@]}" --ignore=python/tests/integrations if is_true "{{ ci }}" && [[ -n "$rust_coverage_out" ]]; then cargo llvm-cov report \ @@ -1375,6 +1436,7 @@ test-go: esac cd "$NEMO_RELAY_REPO_ROOT" cargo build $flag -p nemo-relay-ffi + prepare_test_plugin_fixtures if [[ "$is_windows" == true ]]; then export CC=clang @@ -1792,6 +1854,8 @@ package-python-plugin: echo "Error: No Python plugin wheels found in $package_dir" exit 1 fi + python_executable="$(project_python_executable)" + "$python_executable" scripts/validate_python_plugin_package.py # Package a prebuilt CLI binary for PyPI. package-cli-bin binary target version package_dir: diff --git a/python/tests/plugin/test_package_build.py b/python/tests/plugin/test_package_build.py deleted file mode 100644 index f64a4e987..000000000 --- a/python/tests/plugin/test_package_build.py +++ /dev/null @@ -1,97 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Build regression tests for the Python worker plugin package.""" - -from __future__ import annotations - -import shlex -import shutil -import subprocess -import sys -import tarfile -from pathlib import Path -from zipfile import ZipFile - - -def test_sdist_rebuilds_worker_bindings_without_checked_in_codegen(tmp_path: Path): - repository_root = Path(__file__).parents[3] - plugin_source = repository_root / "python/plugin" - workspace_root = tmp_path / "workspace" - project_root = workspace_root / "python/plugin" - shutil.copytree(plugin_source, project_root, ignore=_ignore_build_outputs) - for generated in (project_root / "src/nemo_relay_plugin/_proto").glob("plugin_worker_pb2*.py"): - generated.unlink() - - canonical_proto = workspace_root / "crates/worker-proto/proto/nemo/relay/worker/v1/plugin_worker.proto" - canonical_proto.parent.mkdir(parents=True) - source_proto = repository_root / "crates/worker-proto/proto/nemo/relay/worker/v1/plugin_worker.proto" - shutil.copy2(source_proto, canonical_proto) - - repository_wheel_dir = tmp_path / "repository-wheel" - _run( - ["uv", "build", "--wheel", "--out-dir", str(repository_wheel_dir), str(project_root)], - ) - _assert_wheel_contains_worker_bindings(next(repository_wheel_dir.glob("*.whl"))) - assert not (project_root / "proto").exists() - - distribution_dir = tmp_path / "dist" - _run( - ["uv", "build", "--sdist", "--out-dir", str(distribution_dir), str(project_root)], - ) - sdist = next(distribution_dir.glob("*.tar.gz")) - with tarfile.open(sdist) as archive: - names = archive.getnames() - assert any(name.endswith("/proto/plugin_worker.proto") for name in names) - assert not any(name.endswith("plugin_worker_pb2.py") for name in names) - assert not any(name.endswith("plugin_worker_pb2_grpc.py") for name in names) - extraction_root = (tmp_path / "extracted").resolve() - for member in archive.getmembers(): - destination = (extraction_root / member.name).resolve() - assert destination.is_relative_to(extraction_root) - archive.extract(member, extraction_root) - - extracted_project = next(extraction_root.iterdir()) - wheel_dir = tmp_path / "wheel" - _run( - ["uv", "build", "--wheel", "--out-dir", str(wheel_dir), str(extracted_project)], - ) - _assert_wheel_contains_worker_bindings(next(wheel_dir.glob("*.whl"))) - - venv = tmp_path / "venv" - _run( - ["uv", "venv", "--python", sys.executable, str(venv)], - ) - python = venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") - _run( - ["uv", "pip", "install", "--python", str(python), "-e", str(extracted_project)], - ) - _run( - [str(python), "-c", "import nemo_relay_plugin._proto.plugin_worker_pb2_grpc"], - ) - - -def _ignore_build_outputs(directory: str, names: list[str]) -> set[str]: - del directory - return { - name - for name in names - if name in {".ruff_cache", ".venv", "__pycache__", "build", "dist", "proto"} or name.endswith(".egg-info") - } - - -def _assert_wheel_contains_worker_bindings(wheel: Path) -> None: - with ZipFile(wheel) as archive: - names = set(archive.namelist()) - assert "nemo_relay_plugin/_proto/plugin_worker_pb2.py" in names - assert "nemo_relay_plugin/_proto/plugin_worker_pb2_grpc.py" in names - - -def _run(command: list[str]) -> None: - completed = subprocess.run(command, check=False, capture_output=True, text=True) - if completed.returncode: - raise AssertionError( - f"command failed ({completed.returncode}): {shlex.join(command)}\n" - f"stdout:\n{completed.stdout}\n" - f"stderr:\n{completed.stderr}" - ) diff --git a/python/tests/test_dynamic_plugin_host.py b/python/tests/test_dynamic_plugin_host.py index 92ab7806a..54550f755 100644 --- a/python/tests/test_dynamic_plugin_host.py +++ b/python/tests/test_dynamic_plugin_host.py @@ -49,6 +49,22 @@ def _relay_version() -> str: return str(tomllib.load(file)["workspace"]["package"]["version"]) +def _prepared_plugin_fixture(environment: str) -> Path: + value = os.environ.get(environment) + if value is not None: + path = Path(value) + else: + filename = ( + _native_library_name() + if environment == "NEMO_RELAY_TEST_NATIVE_PLUGIN" + else "nemo-relay-worker-plugin-fixture" + (".exe" if sys.platform == "win32" else "") + ) + path = _repo_root() / "target/test-plugin-fixtures/debug" / filename + if not path.is_file(): + raise RuntimeError(f"missing plugin test fixture; run `just build-test-plugin-fixtures`: {path}") + return path + + def _native_library_name() -> str: if sys.platform == "win32": return "nemo_relay_plugin_fixture.dll" @@ -59,24 +75,8 @@ def _native_library_name() -> str: @pytest.fixture(scope="session") def native_dynamic_plugin(tmp_path_factory: pytest.TempPathFactory) -> _BuiltPlugin: - root = _repo_root() - target = tmp_path_factory.mktemp("native-plugin-target") manifest_dir = tmp_path_factory.mktemp("native-plugin-manifest") - subprocess.run( - [ - os.environ.get("CARGO", "cargo"), - "build", - "--quiet", - "--manifest-path", - str(root / "crates/core/tests/fixtures/native_plugin/Cargo.toml"), - "--target-dir", - str(target), - ], - cwd=root, - check=True, - ) - library = target / "debug" / _native_library_name() - assert library.is_file() + library = _prepared_plugin_fixture("NEMO_RELAY_TEST_NATIVE_PLUGIN") digest = hashlib.sha256(library.read_bytes()).hexdigest() manifest = manifest_dir / "relay-plugin.toml" manifest.write_text( @@ -112,25 +112,8 @@ def native_dynamic_plugin(tmp_path_factory: pytest.TempPathFactory) -> _BuiltPlu @pytest.fixture(scope="session") def worker_dynamic_plugin(tmp_path_factory: pytest.TempPathFactory) -> _BuiltPlugin: - root = _repo_root() - target = tmp_path_factory.mktemp("worker-plugin-target") manifest_dir = tmp_path_factory.mktemp("worker-plugin-manifest") - subprocess.run( - [ - os.environ.get("CARGO", "cargo"), - "build", - "--quiet", - "--locked", - "--manifest-path", - str(root / "crates/core/tests/fixtures/worker_plugin/Cargo.toml"), - "--target-dir", - str(target), - ], - cwd=root, - check=True, - ) - executable = target / "debug" / ("nemo-relay-worker-plugin-fixture" + (".exe" if sys.platform == "win32" else "")) - assert executable.is_file() + executable = _prepared_plugin_fixture("NEMO_RELAY_TEST_WORKER_PLUGIN") manifest = manifest_dir / "relay-plugin.toml" manifest.write_text( textwrap.dedent( diff --git a/scripts/validate_python_plugin_package.py b/scripts/validate_python_plugin_package.py new file mode 100644 index 000000000..383c5ca68 --- /dev/null +++ b/scripts/validate_python_plugin_package.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Validate Python worker plugin package code generation and installation.""" + +from __future__ import annotations + +import shlex +import shutil +import subprocess +import sys +import tarfile +import tempfile +from pathlib import Path +from zipfile import ZipFile + + +def main() -> None: + repository_root = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory(prefix="nemo-relay-plugin-package-") as temporary_directory: + _validate_package(repository_root, Path(temporary_directory)) + + +def _validate_package(repository_root: Path, temporary_root: Path) -> None: + plugin_source = repository_root / "python/plugin" + workspace_root = temporary_root / "workspace" + project_root = workspace_root / "python/plugin" + shutil.copytree(plugin_source, project_root, ignore=_ignore_build_outputs) + for generated in (project_root / "src/nemo_relay_plugin/_proto").glob("plugin_worker_pb2*.py"): + generated.unlink() + + canonical_proto = workspace_root / "crates/worker-proto/proto/nemo/relay/worker/v1/plugin_worker.proto" + canonical_proto.parent.mkdir(parents=True) + shutil.copy2( + repository_root / "crates/worker-proto/proto/nemo/relay/worker/v1/plugin_worker.proto", + canonical_proto, + ) + + repository_wheel_dir = temporary_root / "repository-wheel" + _run(["uv", "build", "--wheel", "--out-dir", str(repository_wheel_dir), str(project_root)]) + _assert_wheel_contains_worker_bindings(next(repository_wheel_dir.glob("*.whl"))) + if (project_root / "proto").exists(): + raise AssertionError("wheel build left generated proto sources in the project tree") + + distribution_dir = temporary_root / "dist" + _run(["uv", "build", "--sdist", "--out-dir", str(distribution_dir), str(project_root)]) + sdist = next(distribution_dir.glob("*.tar.gz")) + extraction_root = (temporary_root / "extracted").resolve() + with tarfile.open(sdist) as archive: + names = archive.getnames() + if not any(name.endswith("/proto/plugin_worker.proto") for name in names): + raise AssertionError("source distribution is missing plugin_worker.proto") + if any(name.endswith(("plugin_worker_pb2.py", "plugin_worker_pb2_grpc.py")) for name in names): + raise AssertionError("source distribution contains generated worker bindings") + for member in archive.getmembers(): + destination = (extraction_root / member.name).resolve() + if not destination.is_relative_to(extraction_root): + raise AssertionError(f"unsafe source distribution path: {member.name}") + archive.extract(member, extraction_root) + + extracted_project = next(extraction_root.iterdir()) + wheel_dir = temporary_root / "wheel" + _run(["uv", "build", "--wheel", "--out-dir", str(wheel_dir), str(extracted_project)]) + _assert_wheel_contains_worker_bindings(next(wheel_dir.glob("*.whl"))) + + venv = temporary_root / "venv" + _run(["uv", "venv", "--python", sys.executable, str(venv)]) + python = venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + _run(["uv", "pip", "install", "--python", str(python), "-e", str(extracted_project)]) + _run([str(python), "-c", "import nemo_relay_plugin._proto.plugin_worker_pb2_grpc"]) + + +def _ignore_build_outputs(directory: str, names: list[str]) -> set[str]: + del directory + return { + name + for name in names + if name in {".ruff_cache", ".venv", "__pycache__", "build", "dist", "proto"} or name.endswith(".egg-info") + } + + +def _assert_wheel_contains_worker_bindings(wheel: Path) -> None: + with ZipFile(wheel) as archive: + names = set(archive.namelist()) + required = { + "nemo_relay_plugin/_proto/plugin_worker_pb2.py", + "nemo_relay_plugin/_proto/plugin_worker_pb2_grpc.py", + } + missing = required - names + if missing: + raise AssertionError(f"wheel is missing generated worker bindings: {sorted(missing)}") + + +def _run(command: list[str]) -> None: + completed = subprocess.run(command, check=False, capture_output=True, text=True) + if completed.returncode: + raise AssertionError( + f"command failed ({completed.returncode}): {shlex.join(command)}\n" + f"stdout:\n{completed.stdout}\n" + f"stderr:\n{completed.stderr}" + ) + + +if __name__ == "__main__": + main() From f9433ccb5429a3d6285191de31859cbd51f36049 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Thu, 6 Aug 2026 09:58:59 -0400 Subject: [PATCH 2/3] test: address runtime review feedback Signed-off-by: Will Killian --- .agents/skills/test-ffi-surface/SKILL.md | 1 - .agents/skills/validate-change/SKILL.md | 1 - crates/cli/tests/cli_tests.rs | 9 +++++---- scripts/validate_python_plugin_package.py | 5 +++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/skills/test-ffi-surface/SKILL.md b/.agents/skills/test-ffi-surface/SKILL.md index 06db67d75..c79ec2f54 100644 --- a/.agents/skills/test-ffi-surface/SKILL.md +++ b/.agents/skills/test-ffi-surface/SKILL.md @@ -36,7 +36,6 @@ just build-go # Required Rust validation cargo fmt --all just test-rust -just build-test-plugin-fixtures cargo test -p nemo-relay-ffi cargo clippy --workspace --all-targets -- -D warnings diff --git a/.agents/skills/validate-change/SKILL.md b/.agents/skills/validate-change/SKILL.md index 02085c8a8..2b4eb7106 100644 --- a/.agents/skills/validate-change/SKILL.md +++ b/.agents/skills/validate-change/SKILL.md @@ -78,7 +78,6 @@ just test-node ```bash # Rust only just build-rust -just build-test-plugin-fixtures just test-rust just ci=true test-rust cargo fmt --all diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 9be2f27c2..1aad25818 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -25,6 +25,7 @@ fn gateway_bin() -> &'static str { const ACTIVE_GENERATION_TOKEN: &str = "active-generation"; const BOOTSTRAP_PROTOCOL_VERSION: u64 = 3; +const CHILD_PROCESS_TIMEOUT_SECONDS: u64 = 5; const SIDECAR_PUBLICATION_TIMEOUT: Duration = Duration::from_secs(5); fn write_active_generation(temp: &std::path::Path) -> std::path::PathBuf { @@ -1466,7 +1467,7 @@ fn find_runtime_files_matching( } fn wait_child(child: &mut Child) -> ExitStatus { - let deadline = Instant::now() + Duration::from_secs(5); + let deadline = Instant::now() + Duration::from_secs(CHILD_PROCESS_TIMEOUT_SECONDS); loop { if let Some(status) = child.try_wait().unwrap() { return status; @@ -1474,7 +1475,7 @@ fn wait_child(child: &mut Child) -> ExitStatus { if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); - panic!("child process did not exit within 10 seconds"); + panic!("child process did not exit within {CHILD_PROCESS_TIMEOUT_SECONDS} seconds"); } thread::sleep(Duration::from_millis(20)); } @@ -1523,7 +1524,7 @@ fn wait_child_with_output(mut child: Child) -> Output { let stdout = read_pipe(child.stdout.take()); let stderr = read_pipe(child.stderr.take()); - let deadline = Instant::now() + Duration::from_secs(5); + let deadline = Instant::now() + Duration::from_secs(CHILD_PROCESS_TIMEOUT_SECONDS); let status = loop { if let Some(status) = child.try_wait().unwrap() { break status; @@ -1531,7 +1532,7 @@ fn wait_child_with_output(mut child: Child) -> Output { if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); - panic!("child process did not exit within 10 seconds"); + panic!("child process did not exit within {CHILD_PROCESS_TIMEOUT_SECONDS} seconds"); } thread::sleep(Duration::from_millis(20)); }; diff --git a/scripts/validate_python_plugin_package.py b/scripts/validate_python_plugin_package.py index 383c5ca68..6377b31b1 100644 --- a/scripts/validate_python_plugin_package.py +++ b/scripts/validate_python_plugin_package.py @@ -61,12 +61,13 @@ def _validate_package(repository_root: Path, temporary_root: Path) -> None: extracted_project = next(extraction_root.iterdir()) wheel_dir = temporary_root / "wheel" _run(["uv", "build", "--wheel", "--out-dir", str(wheel_dir), str(extracted_project)]) - _assert_wheel_contains_worker_bindings(next(wheel_dir.glob("*.whl"))) + rebuilt_wheel = next(wheel_dir.glob("*.whl")) + _assert_wheel_contains_worker_bindings(rebuilt_wheel) venv = temporary_root / "venv" _run(["uv", "venv", "--python", sys.executable, str(venv)]) python = venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") - _run(["uv", "pip", "install", "--python", str(python), "-e", str(extracted_project)]) + _run(["uv", "pip", "install", "--python", str(python), str(rebuilt_wheel)]) _run([str(python), "-c", "import nemo_relay_plugin._proto.plugin_worker_pb2_grpc"]) From 96d8f48dd977c590d21b3d4c545b5d87c6fd6843 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Thu, 6 Aug 2026 10:01:46 -0400 Subject: [PATCH 3/3] docs: prepare fixtures before focused Python tests Signed-off-by: Will Killian --- docs/contribute/testing-and-docs.mdx | 1 + docs/resources/support-and-faqs.mdx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/contribute/testing-and-docs.mdx b/docs/contribute/testing-and-docs.mdx index 4b03e8e75..f14604c7c 100644 --- a/docs/contribute/testing-and-docs.mdx +++ b/docs/contribute/testing-and-docs.mdx @@ -71,6 +71,7 @@ or docs tooling. ```bash uv sync +just build-test-plugin-fixtures uv run pytest ``` diff --git a/docs/resources/support-and-faqs.mdx b/docs/resources/support-and-faqs.mdx index 37cf44214..54ecfe391 100644 --- a/docs/resources/support-and-faqs.mdx +++ b/docs/resources/support-and-faqs.mdx @@ -511,7 +511,7 @@ agent skills under `skills/` and keep examples aligned with the public docs. Choose the smallest validation set that covers the touched surface: - Rust core or adaptive changes: `cargo test --workspace` or focused crate tests. -- Python binding changes: `uv run pytest`. +- Python binding changes: `just build-test-plugin-fixtures && uv run pytest`. - Node.js binding changes: `npm test --workspace=nemo-relay-node`. - Go binding changes: build the release FFI library first, then run Go tests under `go/nemo_relay`. - Documentation changes: run `./scripts/build-docs.sh html`.