diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 6ce538f72..48aac23c3 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -3610,6 +3610,14 @@ The locator's unit tests live beside it in executable layouts, an `out` directory that is *not* the Cargo build layout, both fallback paths, and the missing-binary case. +The private, test-only child module +`test_support/src/netsuke/locator/tests/locator_property_tests.rs` owns the +generated property coverage. `locator.rs` retains the fixed Cargo-layout and +named presence-mask regression tests, while the child verifies candidate-list +content and order, first-present lookup selection across optional +`CARGO_TARGET_DIR`/profile/target-triple layouts, and missing-candidate +diagnostics. + ## Digest rendering `src/hex.rs` (`netsuke::hex`) is the single owner of lowercase hexadecimal diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index c00699286..3130b31a7 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2308,6 +2308,42 @@ rule, so its view of the environment always matches the child's. The developers' guide documents the module layout and the `PATH` composition helper under "Module: `runner::process::command_env`". +Integration-test support finds the already-built `netsuke` executable before +spawning it. Its locator derives an ordered candidate list from the test +executable's layout and injected `CARGO_TARGET_DIR`, then selects the first +existing candidate. The property tests construct the filesystem layout and the +expected result independently, so they validate both successful selection and +the diagnostic that records every attempted candidate. + +For screen readers: The following sequence shows a property test creating +candidate paths, asking the locator to inspect them in order, and comparing the +result with its independently reconstructed expectation. When a candidate +exists, the locator returns the first matching path. When none exists, it +returns a diagnostic listing every candidate it checked. + +```mermaid +sequenceDiagram + participant PropertyTest + participant Locator + participant Filesystem + participant Diagnostic + + PropertyTest->>Filesystem: create candidate paths from generated layout + PropertyTest->>Locator: netsuke_executable_from() + Locator->>Filesystem: check candidates in lookup order + Filesystem-->>Locator: candidate presence + alt executable candidate exists + Locator-->>PropertyTest: first matching path + else no candidate exists + Locator->>Diagnostic: build missing-candidate diagnostic + Diagnostic-->>PropertyTest: missing diagnostic + end + PropertyTest->>PropertyTest: verify independently reconstructed result +``` + +Figure: Property-based test support resolves the first executable candidate or +reports every missing candidate. + ### 6.2 The Criticality of Shell Escaping A primary security responsibility for Netsuke is the prevention of command diff --git a/docs/snapshot-testing-in-netsuke-using-insta.md b/docs/snapshot-testing-in-netsuke-using-insta.md index 2cd3011c1..d03d5c8b9 100644 --- a/docs/snapshot-testing-in-netsuke-using-insta.md +++ b/docs/snapshot-testing-in-netsuke-using-insta.md @@ -301,6 +301,11 @@ field named `version` remains visible in snapshot diffs. The generator name and `schema_version` are deliberately excluded from this redaction and remain asserted structurally. +The fixed diagnostic example exercises every structural filter path because the +regular expression has no data-dependent branches. The +`snapshot_test_support` property test separately varies valid `SemVer` +generator versions. + ## Running and Updating Snapshot Tests > In this repository the canonical runner is cargo-nextest: `make test`, or diff --git a/src/diagnostic_json_tests.rs b/src/diagnostic_json_tests.rs index 5ad66f4e4..5230046a1 100644 --- a/src/diagnostic_json_tests.rs +++ b/src/diagnostic_json_tests.rs @@ -1,4 +1,9 @@ //! Tests for Netsuke's JSON diagnostics schema. +//! +//! The diagnostic snapshot filter is one anchored regular expression with no +//! data-dependent branching. A fixed example that places unrelated `version` +//! fields on both sides of the matching generator block therefore covers every +//! filter path; property generation would not exercise a distinct behaviour. use super::{render_diagnostic_json, render_error_json}; use crate::ir::IrGenError; @@ -28,6 +33,9 @@ fn parse_json_value(document: &str) -> Result { fn snapshot_filter_preserves_versions_outside_the_generator_block() { let rendered = concat!( "{\n", + " \"schema\": {\n", + " \"version\": \"3.4.5\"\n", + " },\n", " \"generator\": {\n", " \"name\": \"netsuke\",\n", " \"version\": \"9.9.9\"\n", @@ -42,6 +50,9 @@ fn snapshot_filter_preserves_versions_outside_the_generator_block() { snapshot_settings().bind(|| { assert_snapshot!(rendered, @r#" { + "schema": { + "version": "3.4.5" + }, "generator": { "name": "netsuke", "version": "[version]" diff --git a/test_support/src/netsuke/locator.rs b/test_support/src/netsuke/locator.rs index 720f68b68..3dcda1c2d 100644 --- a/test_support/src/netsuke/locator.rs +++ b/test_support/src/netsuke/locator.rs @@ -119,6 +119,9 @@ mod tests { use mockable::MockEnv; use rstest::{fixture, rstest}; + #[path = "locator_property_tests.rs"] + mod locator_property_tests; + fn utf8_root(temp: &tempfile::TempDir) -> Result { Utf8PathBuf::from_path_buf(temp.path().to_path_buf()) .map_err(|path| anyhow::anyhow!("temp dir {} is not UTF-8", path.display())) diff --git a/test_support/src/netsuke/locator/tests/locator_property_tests.rs b/test_support/src/netsuke/locator/tests/locator_property_tests.rs new file mode 100644 index 000000000..b8e51d702 --- /dev/null +++ b/test_support/src/netsuke/locator/tests/locator_property_tests.rs @@ -0,0 +1,150 @@ +//! Property tests for generated Netsuke executable locator layouts. +//! +//! The table tests in `locator.rs` pin Cargo's named layouts and exhaust every +//! three-candidate presence mask. These properties complement them by stating +//! the same candidate ordering and selection invariants over arbitrary valid +//! UTF-8 root components, profiles, target triples, and target directories. + +use super::super::{candidate_paths, netsuke_executable_from}; +use super::{binary_name, env_with_target_dir, touch, utf8_root}; +use proptest::prelude::*; +use proptest::test_runner::TestCaseError; + +/// List the DOS device names that cannot form Windows path components. +const WINDOWS_RESERVED_DEVICE_NAMES: &[&str] = &[ + "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", + "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", +]; + +/// Determine whether `component` is reserved as a Windows device name. +fn is_windows_reserved_device_name(component: &str) -> bool { + WINDOWS_RESERVED_DEVICE_NAMES + .iter() + .any(|name| component.eq_ignore_ascii_case(name)) +} + +/// Generate a valid UTF-8 path component that is safe on Windows. +fn safe_component() -> impl Strategy { + "[a-z][a-z0-9_-]{0,8}".prop_filter("component must not be a Windows device name", |component| { + !is_windows_reserved_device_name(component) + }) +} + +/// Generate a valid UTF-8 path component for a temporary-root child. +fn root_component() -> impl Strategy { + safe_component() +} + +/// Generate a valid Cargo profile component distinct from `deps`. +fn profile_component() -> impl Strategy { + safe_component().prop_filter("profile component must not be `deps`", |component| { + component != "deps" + }) +} + +/// Generate a valid target-triple component distinct from `deps`. +fn target_triple() -> impl Strategy { + safe_component().prop_filter("target triple must not be `deps`", |component| { + component != "deps" + }) +} + +/// Generate an optional valid UTF-8 `CARGO_TARGET_DIR` component. +fn target_dir_component() -> impl Strategy> { + proptest::option::of(root_component()) +} + +/// Generate target directories that cannot alias the primary candidate. +fn lookup_target_dir_component() -> impl Strategy> { + target_dir_component().prop_filter("target directory must not be `build`", |component| { + component.as_deref() != Some("build") + }) +} + +/// Build an absolute UTF-8 root under a newly allocated temporary directory. +fn generated_root( + root_component: String, +) -> Result<(tempfile::TempDir, camino::Utf8PathBuf), TestCaseError> { + let temp = tempfile::tempdir().map_err(|error| TestCaseError::fail(error.to_string()))?; + let root = utf8_root(&temp).map_err(|error| TestCaseError::fail(error.to_string()))?; + Ok((temp, root.join(root_component))) +} + +proptest! { + /// Keep candidate contents and order stable for every generated layout. + #[test] + fn candidate_paths_match_the_documented_order( + root_component in root_component(), + profile in profile_component(), + triple in target_triple(), + target_dir_component in target_dir_component(), + ) { + let (_temp, root) = generated_root(root_component)?; + let exe_dir = root.join("build").join(&triple).join(&profile); + let target_dir = target_dir_component + .as_deref() + .map(|component| root.join(component)); + let env = env_with_target_dir(target_dir.as_deref()); + let binary = binary_name(); + + let candidates = candidate_paths(&env, &exe_dir, &binary); + let mut expected = vec![exe_dir.join(&binary)]; + if let Some(target_root) = &target_dir { + expected.push(target_root.join(&profile).join(&binary)); + expected.push(target_root.join(&triple).join(&profile).join(&binary)); + } + + prop_assert_eq!(&candidates, &expected); + if target_dir.is_none() { + prop_assert_eq!(candidates.len(), 1); + } + } + + /// Resolve the first staged candidate and report every missing path. + #[test] + fn executable_lookup_honours_generated_candidate_order( + root_component in root_component(), + profile in profile_component(), + triple in target_triple(), + target_dir_component in lookup_target_dir_component(), + presence in 0u8..8, + ) { + let (_temp, root) = generated_root(root_component)?; + let exe_dir = root.join("build").join(&triple).join(&profile); + let executable = exe_dir.join("deps").join("test-exe"); + touch(&executable).map_err(|error| TestCaseError::fail(error.to_string()))?; + + let target_dir = target_dir_component + .as_deref() + .map(|component| root.join(component)); + let env = env_with_target_dir(target_dir.as_deref()); + let binary = binary_name(); + let candidates = candidate_paths(&env, &exe_dir, &binary); + for (slot, candidate) in candidates.iter().enumerate() { + if presence & (1 << slot) != 0 { + touch(candidate).map_err(|error| TestCaseError::fail(error.to_string()))?; + } + } + + let located = netsuke_executable_from(&env, &executable); + let first_present = candidates + .iter() + .enumerate() + .find(|(slot, _)| presence & (1 << slot) != 0); + if let Some((_, expected)) = first_present { + let resolved = located.map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!(resolved.as_path(), expected.as_path()); + } else { + let error = located + .err() + .ok_or_else(|| TestCaseError::fail("missing candidates should fail"))?; + let message = error.to_string(); + for candidate in candidates { + prop_assert!( + message.contains(candidate.as_str()), + "missing-candidate diagnostic should list {candidate}; got: {message}" + ); + } + } + } +}