diff --git a/Cargo.lock b/Cargo.lock index 391dd90..4fe6d30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -595,18 +595,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "windows-sys 0.59.0", -] - [[package]] name = "const_format" version = "0.2.34" @@ -1140,12 +1128,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -1200,15 +1182,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "float-cmp" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" -dependencies = [ - "num-traits", -] - [[package]] name = "fnv" version = "1.0.7" @@ -1864,18 +1837,6 @@ dependencies = [ "unicode-width 0.1.14", ] -[[package]] -name = "insta" -version = "1.46.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" -dependencies = [ - "console", - "once_cell", - "similar", - "tempfile", -] - [[package]] name = "io-uring" version = "0.7.8" @@ -2261,12 +2222,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "normalize-line-endings" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2978,10 +2933,7 @@ checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "difflib", - "float-cmp", - "normalize-line-endings", "predicates-core", - "regex", ] [[package]] @@ -3730,12 +3682,6 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "simple_asn1" version = "0.6.3" @@ -4435,13 +4381,10 @@ dependencies = [ "handlebars", "hex", "inquire", - "insta", - "libc", "miette", "oci-client", "octocrab", "pallas", - "predicates", "prost", "reqwest", "semver", diff --git a/Cargo.toml b/Cargo.toml index e612f03..d7b4467 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,9 +59,6 @@ url = "2.5" [dev-dependencies] assert_cmd = "2.0" -predicates = "3.1" -insta = "1.42" -libc = "0.2" [lib] name = "trix" diff --git a/src/home.rs b/src/home.rs index 30d37f3..d846f34 100644 --- a/src/home.rs +++ b/src/home.rs @@ -5,9 +5,15 @@ use cryptoxide::{digest::Digest as _, sha2::Sha256}; use miette::{Context as _, IntoDiagnostic as _}; pub fn tx3_dir() -> miette::Result { - let home = dirs::home_dir() - .ok_or_else(|| miette::miette!("failed to get home directory"))? - .join(".tx3"); + // `TX3_HOME` overrides the `~/.tx3` root wholesale. Primarily an + // isolation seam for tests and CI (a per-process throwaway root that + // works on every OS, unlike faking `$HOME`), but honored anywhere. + let home = match std::env::var_os("TX3_HOME") { + Some(v) if !v.is_empty() => PathBuf::from(v), + _ => dirs::home_dir() + .ok_or_else(|| miette::miette!("failed to get home directory"))? + .join(".tx3"), + }; if !home.exists() { std::fs::create_dir_all(&home) diff --git a/src/interfaces/mod.rs b/src/interfaces/mod.rs index 5489fa2..33d83eb 100644 --- a/src/interfaces/mod.rs +++ b/src/interfaces/mod.rs @@ -134,7 +134,12 @@ pub enum CacheStatus { /// reserved for unexpected I/O failures. pub fn verify_cached(entry: &InterfaceEntry) -> Result { let paths = cache_paths(entry)?; + verify_cache_at(entry, &paths) +} +/// The verification rules themselves, decoupled from the project-rooted +/// cache location so they are unit-testable against any directory. +fn verify_cache_at(entry: &InterfaceEntry, paths: &CachePaths) -> Result { let manifest_bytes = match std::fs::read(&paths.manifest) { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(CacheStatus::Missing), @@ -694,4 +699,214 @@ mod tests { }; assert!(check_trust(&entry(Some(pin)), &m).is_none()); } + + // ------------------------------------------------------------------ + // verify_cache_at — the cache verification rules against an arbitrary + // directory (no project root involved). + // ------------------------------------------------------------------ + + fn paths_in(dir: &std::path::Path) -> CachePaths { + CachePaths { + root: dir.to_path_buf(), + source: dir.join(CACHE_SOURCE_FILE), + tii: dir.join(CACHE_TII_FILE), + readme: dir.join(CACHE_README_FILE), + manifest: dir.join(CACHE_MANIFEST_FILE), + } + } + + /// Write a fully consistent cache for the `entry()` fixture into `dir`. + fn write_valid_cache(paths: &CachePaths) { + let m = manifest(VerificationTier::Unverified, None); + std::fs::write(&paths.manifest, serde_json::to_vec(&m).unwrap()).unwrap(); + std::fs::write(&paths.source, "// informative copy\n").unwrap(); + std::fs::write(&paths.tii, r#"{"transactions":{"widget_transfer":{}}}"#).unwrap(); + } + + fn report_of(status: CacheStatus) -> String { + match status { + CacheStatus::Invalid(report) => format!("{report}"), + CacheStatus::Valid => panic!("expected Invalid, got Valid"), + CacheStatus::Missing => panic!("expected Invalid, got Missing"), + } + } + + #[test] + fn consistent_cache_is_valid() { + let dir = tempfile::tempdir().unwrap(); + let paths = paths_in(dir.path()); + write_valid_cache(&paths); + assert!(matches!( + verify_cache_at(&entry(None), &paths).unwrap(), + CacheStatus::Valid + )); + } + + #[test] + fn absent_files_are_missing_not_invalid() { + let dir = tempfile::tempdir().unwrap(); + let paths = paths_in(dir.path()); + + // Nothing on disk at all. + assert!(matches!( + verify_cache_at(&entry(None), &paths).unwrap(), + CacheStatus::Missing + )); + + // Manifest alone (source missing). + write_valid_cache(&paths); + std::fs::remove_file(&paths.source).unwrap(); + assert!(matches!( + verify_cache_at(&entry(None), &paths).unwrap(), + CacheStatus::Missing + )); + + // TII missing. + write_valid_cache(&paths); + std::fs::remove_file(&paths.tii).unwrap(); + assert!(matches!( + verify_cache_at(&entry(None), &paths).unwrap(), + CacheStatus::Missing + )); + } + + #[test] + fn digest_mismatch_is_invalid_with_refresh_hint() { + let dir = tempfile::tempdir().unwrap(); + let paths = paths_in(dir.path()); + write_valid_cache(&paths); + + let mut tampered = entry(None); + tampered.digest = + "sha256:0000000000000000000000000000000000000000000000000000000000000bad".to_string(); + + let report = report_of(verify_cache_at(&tampered, &paths).unwrap()); + assert!(report.contains("digest"), "got: {report}"); + assert!(report.contains("trix use --force"), "got: {report}"); + } + + #[test] + fn malformed_metadata_is_invalid() { + let dir = tempfile::tempdir().unwrap(); + let paths = paths_in(dir.path()); + write_valid_cache(&paths); + std::fs::write(&paths.manifest, "not json").unwrap(); + + let report = report_of(verify_cache_at(&entry(None), &paths).unwrap()); + assert!(report.contains("malformed metadata.json"), "got: {report}"); + } + + #[test] + fn malformed_tii_is_invalid() { + let dir = tempfile::tempdir().unwrap(); + let paths = paths_in(dir.path()); + write_valid_cache(&paths); + std::fs::write(&paths.tii, "not json").unwrap(); + + let report = report_of(verify_cache_at(&entry(None), &paths).unwrap()); + assert!(report.contains("not valid JSON"), "got: {report}"); + } + + #[test] + fn trust_violation_surfaces_as_invalid() { + let dir = tempfile::tempdir().unwrap(); + let paths = paths_in(dir.path()); + // Cached manifest records GithubApp; the pin demands GithubOidc. + let m = manifest(VerificationTier::GithubApp, Some("acme/widget")); + std::fs::write(&paths.manifest, serde_json::to_vec(&m).unwrap()).unwrap(); + std::fs::write(&paths.source, "//\n").unwrap(); + std::fs::write(&paths.tii, "{}").unwrap(); + + let pin = TrustedPublisher { + tier: PublisherKind::GithubOidc, + repository: Some("acme/widget".into()), + git_ref: None, + }; + let report = report_of(verify_cache_at(&entry(Some(pin)), &paths).unwrap()); + assert!(report.contains("trust pin"), "got: {report}"); + } + + // ------------------------------------------------------------------ + // validate — the [interfaces] table rules over a RootConfig, exactly + // as the consuming commands (invoke / codegen / inspect tir) run them. + // ------------------------------------------------------------------ + + const BASE_TOML: &str = "\ +[protocol] +name = \"myproj\" +version = \"0.1.0\" +main = \"main.tx3\" +[ledger] +family = \"cardano\" +"; + + fn config_with(interfaces_toml: &str) -> RootConfig { + toml::from_str(&format!("{BASE_TOML}{interfaces_toml}")).unwrap() + } + + #[test] + fn no_interfaces_validates() { + assert!(validate(&config_with("")).is_ok()); + } + + #[test] + fn pinned_registry_ref_validates() { + let cfg = config_with( + "[interfaces.widget]\nref = \"acme/widget:0.1.0\"\ndigest = \"sha256:abc\"\n", + ); + assert!(validate(&cfg).is_ok()); + } + + #[test] + fn alias_only_ref_is_rejected() { + let cfg = config_with("[interfaces.widget]\nref = \"widget\"\ndigest = \"sha256:abc\"\n"); + let err = validate(&cfg).unwrap_err().to_string(); + assert!(err.contains("alias-only"), "got: {err}"); + assert!(err.contains("registry reference"), "got: {err}"); + } + + #[test] + fn unpinned_version_is_rejected() { + let cfg = + config_with("[interfaces.widget]\nref = \"acme/widget\"\ndigest = \"sha256:abc\"\n"); + let err = validate(&cfg).unwrap_err().to_string(); + assert!(err.contains("no version pinned"), "got: {err}"); + } + + #[test] + fn latest_ref_is_rejected() { + let cfg = config_with( + "[interfaces.widget]\nref = \"acme/widget:latest\"\ndigest = \"sha256:abc\"\n", + ); + let err = validate(&cfg).unwrap_err().to_string(); + assert!(err.contains("concrete version"), "got: {err}"); + } + + #[test] + fn duplicate_scope_name_is_rejected() { + let cfg = config_with( + "[interfaces.a]\nref = \"acme/widget:0.1.0\"\ndigest = \"sha256:abc\"\n\ + [interfaces.b]\nref = \"acme/widget:0.2.0\"\ndigest = \"sha256:def\"\n", + ); + let err = validate(&cfg).unwrap_err().to_string(); + assert!(err.contains("distinct protocols"), "got: {err}"); + } + + #[test] + fn alias_clashing_with_project_name_is_rejected() { + let cfg = config_with( + "[interfaces.myproj]\nref = \"acme/widget:0.1.0\"\ndigest = \"sha256:abc\"\n", + ); + let err = validate(&cfg).unwrap_err().to_string(); + assert!(err.contains("project's own protocol name"), "got: {err}"); + } + + #[test] + fn invalid_alias_ident_is_rejected() { + let cfg = config_with( + "[interfaces.\"9bad\"]\nref = \"acme/widget:0.1.0\"\ndigest = \"sha256:abc\"\n", + ); + let err = validate(&cfg).unwrap_err().to_string(); + assert!(err.contains("not a valid identifier"), "got: {err}"); + } } diff --git a/src/interfaces/resolve.rs b/src/interfaces/resolve.rs index d09c473..f9f60fc 100644 --- a/src/interfaces/resolve.rs +++ b/src/interfaces/resolve.rs @@ -108,3 +108,96 @@ impl<'a> Resolver<'a> { Ok((protocol, r.tx.as_str())) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> RootConfig { + toml::from_str( + "\ +[protocol] +name = \"myproj\" +version = \"0.1.0\" +main = \"main.tx3\" +[ledger] +family = \"cardano\" +[interfaces.widget] +ref = \"acme/widget:0.1.0\" +digest = \"sha256:abc\" +", + ) + .unwrap() + } + + fn tx(s: &str) -> TxRef { + TxRef::parse(s).unwrap() + } + + #[test] + fn bare_tx_targets_project() { + let cfg = config(); + let tx_ref = tx("transfer"); + let (resolved, name) = Resolver::new(&cfg).resolve_tx(&tx_ref).unwrap(); + assert!(matches!(resolved, ResolvedProtocol::Project)); + assert_eq!(name, "transfer"); + } + + #[test] + fn project_name_as_alias_targets_project() { + let cfg = config(); + let (resolved, _) = Resolver::new(&cfg) + .resolve_tx(&tx("myproj::transfer")) + .unwrap(); + assert!(matches!(resolved, ResolvedProtocol::Project)); + } + + #[test] + fn declared_alias_targets_interface() { + let cfg = config(); + let tx_ref = tx("widget::widget_transfer"); + let (resolved, name) = Resolver::new(&cfg).resolve_tx(&tx_ref).unwrap(); + match resolved { + ResolvedProtocol::Interface(entry) => assert_eq!(entry.alias, "widget"), + other => panic!("expected Interface, got {other:?}"), + } + assert_eq!(name, "widget_transfer"); + } + + #[test] + fn full_registry_ref_targets_interface() { + let cfg = config(); + let (resolved, _) = Resolver::new(&cfg) + .resolve_tx(&tx("acme/widget:0.1.0::widget_transfer")) + .unwrap(); + assert!(matches!(resolved, ResolvedProtocol::Interface(_))); + } + + #[test] + fn unknown_alias_is_rejected_by_name() { + let cfg = config(); + let err = Resolver::new(&cfg) + .resolve_tx(&tx("ghost::transfer")) + .unwrap_err(); + assert!(matches!(err, ResolveError::UnknownAlias(ref a) if a == "ghost")); + assert!(err.to_string().contains("ghost")); + } + + #[test] + fn version_mismatch_is_rejected() { + let cfg = config(); + let err = Resolver::new(&cfg) + .resolve_tx(&tx("acme/widget:9.9.9::widget_transfer")) + .unwrap_err(); + assert!(matches!(err, ResolveError::VersionMismatch { .. })); + } + + #[test] + fn undeclared_registry_ref_is_rejected() { + let cfg = config(); + let err = Resolver::new(&cfg) + .resolve_tx(&tx("acme/ghost:0.1.0::transfer")) + .unwrap_err(); + assert!(matches!(err, ResolveError::UnknownRegistryRef(_))); + } +} diff --git a/tests/README.md b/tests/README.md index 30f8c83..78fe681 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,296 +1,84 @@ -# Trix CLI End-to-End Tests +# Trix test suites -This directory contains end-to-end (e2e) tests for the Trix CLI tool. These tests execute the actual `trix` binary and verify complete workflows and scenarios. +Trix delegates all language work to helper binaries (`tx3c`, `dolos`, +`cshell`) it spawns as subprocesses. That process boundary is also the test +boundary: **every assertion lives at the innermost layer that can make it, +and no test in this repo requires a real helper binary.** Anything that +needs real cross-binary interop belongs to the umbrella's DX e2e journeys +(`solution/e2e/` in the `tx3` umbrella repo), which gate toolchain releases +with real released binaries on real runners. -## Test Organization (Scenario-Based) +The three layers, innermost first: -Tests are organized by **scenarios** rather than commands, making it easy to understand what aspect of the system is being tested: +| Layer | Where | Spawns | Asks | +|---|---|---|---| +| **Unit** | `src/**` `#[cfg(test)]` | nothing | Are the rules right? (ref grammar, `[interfaces]` validation, cache integrity, resolver, compat window) | +| **CLI** (`tests/cli.rs`) | `tests/cli/` | `trix` only | Is the binary wired to the rules? (arg parsing, exit codes, `init` filesystem behavior, fail-before-spawn error paths) | +| **Contract** (`tests/contract.rs`) | `tests/contract/` | `trix` + a **fake** `tx3c` | Is trix's side of the tx3c contract right? (argv, paths, ordering, output interpretation, version gate) | -``` -tests/ -├── README.md # This file -├── e2e_tests.rs # Test entry point -└── e2e/ # E2E test modules - ├── mod.rs # TestContext + all utilities - ├── smoke.rs # "Does it run?" - basic sanity checks - ├── happy_path.rs # "Does it work correctly?" - ideal workflows - └── edge_cases.rs # "What about edge cases?" - error handling, preservation -``` +There is deliberately no fourth layer. A trix test that needs a real `tx3c` +is asserting cross-repo behavior — journey territory — and would make CI +depend on a toolchain install. -### Scenario Definitions +## Hermeticity -| Scenario | Purpose | Example | -|----------|---------|---------| -| **smoke** | Basic functionality - does it run without crashing? | `init_runs_without_error` | -| **happy_path** | Ideal workflows - does it produce correct output? | `init_creates_valid_project_structure` | -| **edge_cases** | Edge cases, error handling, file preservation | `init_preserves_existing_gitignore` | +Every spawned `trix` runs with: -## Running Tests +- `TX3_HOME` pointing at a per-test throwaway root (the `~/.tx3` override + seam in `src/home.rs`) — no test touches the real `~/.tx3`, no parallel + tests race on the global config, and default tool lookup can never pick + up a developer's installed toolchain; +- `PATH` pointing at an empty directory — nothing on the machine can leak in; +- inherited `TX3_*` variables scrubbed; +- telemetry pre-disabled (except tests covering first-run behavior itself). -**Run all e2e tests:** -```bash -cargo test --test e2e_tests -``` +The suites behave identically on a developer laptop with a full toolchain +and on a bare CI runner. -**Run specific scenario:** -```bash -# Smoke tests only -cargo test --test e2e_tests smoke +## The fake tx3c -# Happy path tests only -cargo test --test e2e_tests happy_path +`tests/harness/fake_tx3c.rs` is a std-only stand-in for `tx3c`, compiled at +test time with plain `rustc` (see `harness::fake_tx3c_path`) — deliberately +**not** a cargo target, so it can never end up in release artifacts or the +dist/publish surface. It implements exactly the CLI surface pinned by +`src/spawn/tx3c.rs` + `src/spawn/compat.rs`, records every invocation's +argv, and is steered per test via `FAKE_TX3C_*` environment variables +(reported version, canned diagnostics, simulated failure). The fake also +unlocks coverage real binaries can't offer: the compat gate is exercised +against arbitrary reported versions. -# Edge case tests only -cargo test --test e2e_tests edge_cases -``` +If trix's spawn contract changes (new flag, new subcommand), update the +fake alongside `src/spawn/` — and expect the umbrella journeys to catch any +drift between the fake's pretense and the real tool at release time. -**Run specific test:** -```bash -cargo test --test e2e_tests init_creates_valid_project_structure -``` - -**Run all tests (including unit tests):** -```bash -cargo test -``` +## Running -**Run with output visible:** ```bash -cargo test --test e2e_tests -- --nocapture -``` - -## Current Test Coverage - -### Smoke Tests (1 test) - -Basic sanity checks - ensure commands run without crashing: - -- **`init_runs_without_error`** - Verifies `trix init --yes` executes successfully - -### Happy Path Tests (1 test) - -Ideal workflows - verify complete, correct behavior: - -- **`init_creates_valid_project_structure`** - Comprehensive validation that init creates a fully valid project: - - All expected files exist (trix.toml, main.tx3, tests/basic.toml, .gitignore, devnet.toml) - - trix.toml: correct version (0.0.0), ledger (Cardano), main file - - devnet.toml: has utxo definitions - - tests/basic.toml: 2 wallets (bob, alice), 2 transactions, 2 expectations - - main.tx3: Sender/Receiver parties, transfer transaction - - .gitignore: contains .tx3 extension - -### Edge Cases (3 tests) - -Edge cases and preservation behavior: - -- **`init_preserves_existing_gitignore`** - Verifies existing .gitignore is not overwritten -- **`init_preserves_existing_main_tx3`** - Verifies existing main.tx3 is not overwritten -- **`init_preserves_existing_test_file`** - Verifies existing tests/basic.toml is not overwritten - -**Total: 5 tests** - -## Test Utilities (`e2e/mod.rs`) - -All e2e-specific utilities are centralized in `e2e/mod.rs`: - -### TestContext - -Each test creates a `TestContext` which provides an isolated temporary directory: - -```rust -let ctx = TestContext::new(); -``` - -### Methods on TestContext - -**Command Execution:** -- `ctx.run_trix(args: &[&str]) -> CommandResult` - Execute trix in the temp directory - -**Config Loading (Type-Safe!):** -- `ctx.load_trix_config() -> RootConfig` - Load and parse trix.toml -- `ctx.load_devnet_config() -> DevnetConfig` - Load and parse devnet.toml -- `ctx.load_test_config() -> TestConfig` - Load and parse tests/basic.toml - -**File Operations:** -- `ctx.write_file(path, content)` - Write file in temp directory -- `ctx.read_file(path) -> String` - Read file from temp directory -- `ctx.assert_file_exists(path)` - Assert file exists -- `ctx.assert_file_contains(path, pattern)` - Assert file contains text - -### CommandResult - -Returned by `run_trix()`: -- `result.success() -> bool` - Check if command succeeded -- `result.stdout` - Standard output -- `result.stderr` - Standard error - -### Assertions - -- `assert_success(result)` - Assert command succeeded -- `assert_failure(result)` - Assert command failed - -## Writing New Tests - -### Choose the Right Scenario - -Ask yourself: "What am I testing?" - -- **Does it run without crashing?** → `smoke.rs` -- **Does it work correctly in ideal conditions?** → `happy_path.rs` -- **What about edge cases/errors?** → `edge_cases.rs` - -### Basic Test Template - -```rust -use super::*; - -#[test] -fn descriptive_test_name_without_prefix() { - let ctx = TestContext::new(); - - // Setup: create any pre-existing files - ctx.write_file("existing.txt", "content"); - - // Execute: run the trix command - let result = ctx.run_trix(&["command", "--arg", "value"]); - - // Assert: verify results - assert_success(&result); - ctx.assert_file_exists("expected_file.txt"); - - // Use struct assertions for TOML files! - let config = ctx.load_trix_config(); - assert_eq!(config.protocol.name, "expected-name"); -} -``` - -### Struct Assertion Examples - -**For trix.toml (RootConfig):** -```rust -use std::path::PathBuf; -use trix::config::{RootConfig, KnownLedgerFamily}; - -let config = ctx.load_trix_config(); -assert_eq!(config.protocol.name, "my-project"); -assert_eq!(config.protocol.version, "0.0.0"); -assert_eq!(config.protocol.main, PathBuf::from("main.tx3")); -assert!(matches!(config.ledger.family, KnownLedgerFamily::Cardano)); -``` - -**For devnet.toml (DevnetConfig):** -```rust -use trix::devnet::Config as DevnetConfig; - -let devnet = ctx.load_devnet_config(); -assert!(!devnet.utxos.is_empty()); -``` - -**For tests/basic.toml (TestConfig):** -```rust -use trix::commands::test::Test as TestConfig; - -let test = ctx.load_test_config(); -assert_eq!(test.wallets.len(), 2); -assert_eq!(test.wallets[0].name, "bob"); -assert_eq!(test.wallets[0].balance, 10000000); -assert_eq!(test.transactions.len(), 2); -assert_eq!(test.expect.len(), 2); -assert_eq!(test.expect[0].from, "@bob"); -``` - -### Best Practices - -1. **Use scenario-based organization** - Put tests in the appropriate file based on what they test -2. **Don't repeat the scenario in function names** - Use `init_creates_valid_project` not `smoke_init_creates_valid_project` -3. **Always use `TestContext::new()`** - Every test should have its own isolated context -4. **Use struct assertions for TOML files** - Type-safe validation beats string matching -5. **Use string assertions only for non-structured files** (e.g., main.tx3, .gitignore) -6. **One test per file for smoke/edge cases, comprehensive tests for happy path** - -## Architecture: Lib + Binary - -To enable struct-based assertions, the project was refactored to a lib+binary pattern: - -``` -Cargo.toml -├── [lib] - trix crate (shared code) -└── [[bin]] - trix binary (CLI entry point) - -src/ -├── lib.rs # Library exports -├── main.rs # Binary entry (uses trix::*) -├── cli.rs # CLI parsing -└── ... # All modules -``` - -**Benefits:** -- E2E tests can import `trix::config::RootConfig` and other structs -- Code reuse between binary and tests -- Type-safe test assertions - -## Adding New Test Scenarios - -As the test suite grows, you may need new scenarios. To add one: - -1. **Create new file** in `tests/e2e/` (e.g., `tests/e2e/performance.rs`) -2. **Add module declaration** to `tests/e2e/mod.rs`: - ```rust - pub mod performance; - ``` -3. **Write tests** in the new file following the scenario pattern -4. **Update this README** with the new scenario description - -## Dependencies - -E2E tests rely on: - -- **assert_cmd** (2.0) - CLI testing framework -- **tempfile** (3.10) - Temporary directory management - -Plus the trix library provides: -- `trix::config::RootConfig` - TOML config struct -- `trix::config::KnownLedgerFamily` - Ledger family enum -- `trix::devnet::Config` - Devnet config struct -- `trix::commands::test::Test` - Test config struct - -## Troubleshooting - -### "Failed to find trix binary" - -Build first: -```bash -cargo build -``` - -### "Failed to load trix.toml config" - -Usually means: -- File wasn't created (check `ctx.assert_file_exists("trix.toml")` first) -- Config format is invalid (rare - means trix has a bug!) -- File path is wrong - -### Struct field errors - -If you get compile errors about missing fields, the config struct changed. **This is good** - it caught a breaking change! Update the test to match the new structure. - -### Type mismatches - -Remember `PathBuf` for paths: -```rust -// Correct: -assert_eq!(config.protocol.main, PathBuf::from("main.tx3")); - -// Wrong: -assert_eq!(config.protocol.main, "main.tx3"); // Type mismatch! -``` - -## Future Growth - -This structure supports rapid test growth: - -- **New commands**: Add tests to appropriate scenario files -- **New scenarios**: Create new files in `tests/e2e/` -- **Workflow tests**: Comprehensive multi-command tests go in `happy_path.rs` -- **Performance tests**: Could add `tests/e2e/performance.rs` -- **Regression tests**: Could add `tests/e2e/regression.rs` for bug reproductions +cargo test # everything: unit + cli + contract +cargo test --lib # unit layer only +cargo test --test cli # CLI suite +cargo test --test contract # contract suite +``` + +No setup, no installed toolchain, no network. + +## Adding a test + +Ask what the assertion is about: + +- **A rule** (parsing, validation, resolution, version arithmetic) → unit + test next to the rule in `src/`. If the rule is buried in an I/O path, + extract it (see `interfaces::verify_cache_at` for the pattern). +- **Wiring** (does command X actually run rule Y, with which exit code and + message) → `tests/cli/`. One probe per chokepoint; don't re-enumerate the + rule's variants here. +- **The tx3c contract** (what trix passes, which artifact feeds which + subcommand, how output/failures are interpreted) → `tests/contract/`, + asserting on `ctx.tx3c_invocations()` and the fake's file outputs. +- **Real binaries composing** (devnet round-trips, real codegen output, + install flows) → not here; add a journey in the umbrella's `solution/e2e/` + (see its README and the `add-e2e-journey` skill). + +Fixtures live in `tests/fixtures/` (`use-stub/` — a cached interface; +`codegen-template/` — a minimal codegen plugin). `tests/infra/` is +unrelated observability tooling, not part of the suites. diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..f90a90d --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,19 @@ +//! CLI suite: spawns only the `trix` binary — never a helper tool. +//! +//! Covers what genuinely needs the binary: arg parsing and dispatch, exit +//! codes, `init`'s filesystem behavior, and the error paths that fire +//! *before* any tool would be spawned (reference resolution, `[interfaces]` +//! validation, cache integrity). Rules themselves are unit-tested in-crate +//! (`src/refs.rs`, `src/interfaces/`); each test here is a wiring probe. +//! +//! See `tests/README.md` for the test-layering methodology. + +#[path = "harness/mod.rs"] +mod harness; + +#[path = "cli/init.rs"] +mod init; +#[path = "cli/interfaces.rs"] +mod interfaces; +#[path = "cli/refs.rs"] +mod refs; diff --git a/tests/cli/init.rs b/tests/cli/init.rs new file mode 100644 index 0000000..fa97016 --- /dev/null +++ b/tests/cli/init.rs @@ -0,0 +1,142 @@ +//! `trix init`: scaffolding and preservation. Purely filesystem behavior of +//! the trix binary — no helper tools involved. + +use crate::harness::*; +use std::path::PathBuf; +use trix::config::KnownLedgerFamily; + +#[test] +fn init_creates_valid_project_structure() { + let ctx = TestContext::new(); + let result = ctx.run_trix(&["init", "--yes"]); + + assert_success(&result); + + // Verify all expected files exist + ctx.assert_file_exists("trix.toml"); + ctx.assert_file_exists("main.tx3"); + ctx.assert_file_exists("tests/basic.toml"); + ctx.assert_file_exists(".gitignore"); + ctx.assert_file_exists("devnet.toml"); + + // Verify trix.toml using struct deserialization + let config = ctx.load_trix_config(); + assert!( + !config.protocol.name.is_empty(), + "protocol name should not be empty" + ); + assert_eq!( + config.protocol.version, "0.0.0", + "version should be default 0.0.0" + ); + assert_eq!( + config.protocol.main, + PathBuf::from("main.tx3"), + "main file should be main.tx3" + ); + assert!( + matches!(config.ledger.family, KnownLedgerFamily::Cardano), + "ledger family should be Cardano" + ); + + // Verify devnet.toml using struct deserialization + let devnet = ctx.load_devnet_config(); + assert!( + !devnet.utxos.is_empty(), + "devnet.toml should contain utxo definitions" + ); + + // Verify tests/basic.toml using struct deserialization + let test = ctx.load_test_config(); + assert!( + !test.wallets.is_empty(), + "test.toml should contain wallet definitions" + ); + assert!( + !test.transactions.is_empty(), + "test.toml should contain transaction definitions" + ); + assert!( + !test.expect.is_empty(), + "test.toml should contain expectations" + ); + + // Verify main.tx3 content + let main_content = ctx.read_file("main.tx3"); + assert!( + main_content.contains("party Sender"), + "main.tx3 should contain Sender party" + ); + assert!( + main_content.contains("party Receiver"), + "main.tx3 should contain Receiver party" + ); + assert!( + main_content.contains("tx transfer"), + "main.tx3 should contain transfer transaction" + ); + + // Verify .gitignore content + let gitignore_content = ctx.read_file(".gitignore"); + assert!( + gitignore_content.contains(".tx3"), + ".gitignore should contain .tx3 extension" + ); +} + +#[test] +fn init_preserves_existing_gitignore() { + let ctx = TestContext::new(); + let existing_gitignore = "# My custom gitignore\n*.log\n"; + ctx.write_file(".gitignore", existing_gitignore); + + let result = ctx.run_trix(&["init", "--yes"]); + + assert_success(&result); + ctx.assert_file_contains(".gitignore", "# My custom gitignore"); + ctx.assert_file_contains(".gitignore", "*.log"); +} + +#[test] +fn init_preserves_existing_main_tx3() { + let ctx = TestContext::new(); + let existing_content = "// This is my existing main.tx3 file\nparty User;\n"; + ctx.write_file("main.tx3", existing_content); + + let result = ctx.run_trix(&["init", "--yes"]); + + assert_success(&result); + ctx.assert_file_contains("main.tx3", "// This is my existing main.tx3 file"); + ctx.assert_file_contains("main.tx3", "party User"); +} + +#[test] +fn init_preserves_existing_test_file() { + let ctx = TestContext::new(); + ctx.write_file( + "tests/basic.toml", + "# Custom test file\n[[wallets]]\nname = \"custom\"\n", + ); + + let result = ctx.run_trix(&["init", "--yes"]); + + assert_success(&result); + ctx.assert_file_contains("tests/basic.toml", "# Custom test file"); + ctx.assert_file_contains("tests/basic.toml", "name = \"custom\""); +} + +/// First run against a pristine `TX3_HOME`: the global config is created +/// under it (not the real `~/.tx3` — the isolation seam every other test +/// relies on) and the one-time telemetry notice is printed. +#[test] +fn first_run_creates_global_config_under_tx3_home() { + let ctx = TestContext::new_unseeded(); + let result = ctx.run_trix(&["init", "--yes"]); + + assert_success(&result); + assert_output_contains(&result, "trix collects anonymous usage data"); + assert!( + ctx.tx3_home().join("trix/config.toml").is_file(), + "global config should be created under TX3_HOME" + ); +} diff --git a/tests/cli/interfaces.rs b/tests/cli/interfaces.rs new file mode 100644 index 0000000..3698f33 --- /dev/null +++ b/tests/cli/interfaces.rs @@ -0,0 +1,52 @@ +//! Interface-cache integrity at the CLI boundary. The verification rules +//! are unit tests on `interfaces::verify_cache_at`; this probes that the +//! consuming commands run them (`restore_all`) before touching anything. + +use crate::harness::*; + +/// Tampered cache digest: an interface-aware command fails closed with the +/// digest-mismatch report and the `trix use --force` hint. The failure +/// fires before tool resolution — no tx3c exists in this environment, yet +/// the error is the digest one, proving the integrity gate runs first. +#[test] +fn digest_tamper_fails_closed_before_any_tool_runs() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + ctx.prime_interface_cache("acme", "widget", "0.1.0"); + // Declare the interface with a digest that does NOT match the cache's. + ctx.declare_interface( + "widget", + "acme", + "widget", + "0.1.0", + "sha256:0000000000000000000000000000000000000000000000000000000000000bad", + ); + + let result = ctx.run_trix(&["inspect", "tir", "--tx", "transfer"]); + assert_failure_mentioning(&result, "digest"); + assert!( + !result.combined().contains("tool tx3c not found"), + "integrity must be checked before tool resolution:\n{}", + result.combined() + ); +} + +/// Projects without `[interfaces]` are entirely unaffected by the interface +/// machinery — and with no tool installed, a project-only failure mentions +/// the missing tool, not the interface layer. +#[test] +fn projects_without_interfaces_section_unchanged() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let config = ctx.load_trix_config(); + assert!( + config.interfaces.is_empty(), + "fresh init should have no interfaces declared" + ); + assert!( + config.registry.is_none(), + "fresh init should not write a [registry] section" + ); +} diff --git a/tests/cli/refs.rs b/tests/cli/refs.rs new file mode 100644 index 0000000..b98fde2 --- /dev/null +++ b/tests/cli/refs.rs @@ -0,0 +1,58 @@ +//! Reference handling at the CLI boundary. The grammar and resolution rules +//! are unit-tested in `src/refs.rs` and `src/interfaces/resolve.rs`; each +//! test here probes that one chokepoint is actually wired to them. + +use crate::harness::*; + +/// `trix use` rejects an alias-only reference at clap parse time +/// (`ProtocolRef::parse_registry` as value parser), because aliases don't +/// carry version info. +#[test] +fn use_rejects_alias_only_reference() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix(&["use", "widget"]); + assert!( + !result.success(), + "expected failure, got: {:?}", + result.stdout + ); + let combined = result.combined(); + assert!( + combined.contains("alias") + || combined.contains("registry reference") + || combined.contains("scope"), + "output should explain the registry-only requirement:\n{}", + combined + ); +} + +/// Unknown alias on `inspect tir --tx`: the resolver rejects it by name +/// before any tool would be spawned — this fails identically on a machine +/// with no toolchain installed. +#[test] +fn inspect_tir_rejects_unknown_alias() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix(&["inspect", "tir", "--tx", "ghost::transfer"]); + assert_failure_mentioning(&result, "ghost"); +} + +/// A hand-edited `[interfaces]` entry that breaks the lockfile rules is +/// rejected by every interface-aware command via `interfaces::validate`, +/// before any tool or network access. One wiring probe; the rule variants +/// (latest, unpinned, duplicates, …) are unit tests on `validate`. +#[test] +fn scoped_commands_reject_invalid_interfaces_table() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let mut content = ctx.read_file("trix.toml"); + content.push_str("\n[interfaces.widget]\nref = \"widget\"\ndigest = \"sha256:deadbeef\"\n"); + ctx.write_file("trix.toml", &content); + + let result = ctx.run_trix(&["inspect", "tir", "--tx", "transfer"]); + assert_failure_mentioning(&result, "alias-only"); +} diff --git a/tests/contract.rs b/tests/contract.rs new file mode 100644 index 0000000..c198808 --- /dev/null +++ b/tests/contract.rs @@ -0,0 +1,21 @@ +//! Contract suite: trix's side of the `tx3c` process contract, exercised +//! against a fake `tx3c` (`tests/harness/fake_tx3c.rs`) — never the real +//! binary. What's asserted is what trix *sends* (argv, paths, ordering) and +//! how it *interprets* what comes back (stdout JSON, exit codes, stderr). +//! +//! The real cross-binary interop — does actual tx3c behave as the fake +//! pretends — is deliberately out of scope: that's the umbrella repo's DX +//! e2e journeys (`solution/e2e/`), which gate toolchain releases with real +//! released binaries. See `tests/README.md`. + +#[path = "harness/mod.rs"] +mod harness; + +#[path = "contract/check.rs"] +mod check; +#[path = "contract/codegen.rs"] +mod codegen; +#[path = "contract/compat.rs"] +mod compat; +#[path = "contract/inspect.rs"] +mod inspect; diff --git a/tests/contract/check.rs b/tests/contract/check.rs new file mode 100644 index 0000000..9b26456 --- /dev/null +++ b/tests/contract/check.rs @@ -0,0 +1,101 @@ +//! `trix check` ↔ `tx3c build --diagnostics-format json`. + +use crate::harness::*; + +#[test] +fn clean_diagnostics_pass_and_print() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix_with_fake_tx3c(&["check"], &[]); + assert_success(&result); + assert_output_contains(&result, "check passed, no errors found"); + + // The compat gate probes --version before the first real invocation, + // then check delegates to `build … --diagnostics-format json`. + let invocations = ctx.tx3c_invocations(); + assert_eq!( + invocations.first().map(|i| i[0].as_str()), + Some("--version"), + "first tx3c contact must be the version probe: {invocations:?}" + ); + let build = invocations + .iter() + .find(|i| i[0] == "build") + .expect("check should invoke tx3c build"); + assert!( + build.contains(&"--diagnostics-format".to_string()) && build.contains(&"json".to_string()), + "check must request JSON diagnostics: {build:?}" + ); + assert!( + build[1].ends_with("main.tx3"), + "check must pass the project's main source: {build:?}" + ); +} + +#[test] +fn analyzer_errors_render_and_fail() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let diagnostics = + r#"{"diagnostics":[{"severity":"error","code":"E001","message":"unknown party 'Ghost'"}]}"#; + let result = ctx.run_trix_with_fake_tx3c(&["check"], &[("FAKE_TX3C_DIAGNOSTICS", diagnostics)]); + + assert_failure_mentioning(&result, "unknown party 'Ghost'"); +} + +/// tx3c printing something that isn't the JSON envelope (a crash, a stray +/// log line) is a *spawn-contract* failure: trix must say it couldn't parse +/// the diagnostics and carry the tool's stderr for context. +#[test] +fn garbage_stdout_is_a_parse_error_with_stderr_context() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix_with_fake_tx3c( + &["check"], + &[("FAKE_TX3C_DIAGNOSTICS", "thread panicked at src/lib.rs")], + ); + + assert_failure_mentioning(&result, "parsing tx3c diagnostics"); +} + +/// A tool that dies outright (non-zero, nothing useful on stdout) must not +/// masquerade as a passing or empty check. +#[test] +fn tool_failure_does_not_pass_silently() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix_with_fake_tx3c( + &["check"], + &[ + ("FAKE_TX3C_EXIT", "101"), + ("FAKE_TX3C_STDERR", "tx3c blew up"), + ], + ); + + assert!(!result.success(), "a dead tool must fail the check"); + assert_failure_mentioning(&result, "tx3c blew up"); +} + +/// `check` is project-only: a declared, cached interface neither helps nor +/// hinders it — and trix must not decode the interface's TII for it. +#[test] +fn check_ignores_declared_interfaces() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); + ctx.declare_interface("widget", "acme", "widget", "0.1.0", &digest); + + let result = ctx.run_trix_with_fake_tx3c(&["check"], &[]); + assert_success(&result); + assert_output_contains(&result, "check passed"); + + assert!( + !ctx.tx3c_invocations().iter().any(|i| i[0] == "decode"), + "check must never decode an interface TII" + ); +} diff --git a/tests/contract/codegen.rs b/tests/contract/codegen.rs new file mode 100644 index 0000000..aeaddcd --- /dev/null +++ b/tests/contract/codegen.rs @@ -0,0 +1,82 @@ +//! `trix codegen` ↔ `tx3c build --emit tii` + `tx3c codegen`. +//! +//! Trix's side of the contract: which TII feeds each target (project built +//! from source, interfaces from their cached published TII) and the +//! unconditional per-protocol output layout `gen//`. + +use crate::harness::*; + +#[test] +fn project_bindings_nest_under_project_subdir() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + ctx.declare_codegen(); + + let project_name = ctx.load_trix_config().protocol.name; + + let result = ctx.run_trix_with_fake_tx3c(&["codegen"], &[]); + assert_success(&result); + + // Per-protocol layout, even with zero interfaces: nothing flat. + ctx.assert_file_exists(format!("gen/{project_name}/bindings.txt")); + assert!( + !ctx.file_path("gen/bindings.txt").exists(), + "unified layout: nothing should be written flat at gen/bindings.txt" + ); + + let invocations = ctx.tx3c_invocations(); + // The project's TII is built from source first… + assert!( + invocations + .iter() + .any(|i| i[0] == "build" && i.contains(&"tii".to_string())), + "codegen must compile the project TII: {invocations:?}" + ); + // …then handed to tx3c codegen with the per-project output dir. + let codegen = invocations + .iter() + .find(|i| i[0] == "codegen") + .expect("codegen must delegate to tx3c codegen"); + let output = codegen + .windows(2) + .find(|w| w[0] == "--output") + .map(|w| w[1].clone()) + .expect("tx3c codegen must receive --output"); + assert!( + output.ends_with(&project_name), + "output dir must be the per-project subdir, got: {output}" + ); +} + +#[test] +fn interface_bindings_use_cached_tii_not_a_recompile() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); + ctx.declare_interface("widget", "acme", "widget", "0.1.0", &digest); + ctx.declare_codegen(); + + let project_name = ctx.load_trix_config().protocol.name; + + let result = ctx.run_trix_with_fake_tx3c(&["codegen"], &[]); + assert_success(&result); + + // One binding set per protocol, each in its own subdir. + ctx.assert_file_exists(format!("gen/{project_name}/bindings.txt")); + ctx.assert_file_exists("gen/widget/bindings.txt"); + + // The fake records which TII fed each binding: the widget bindings must + // come from the cached published artifact, not a rebuild. + ctx.assert_file_contains("gen/widget/bindings.txt", "acme"); + + let invocations = ctx.tx3c_invocations(); + let tii_builds = invocations + .iter() + .filter(|i| i[0] == "build" && i.contains(&"tii".to_string())) + .count(); + assert_eq!( + tii_builds, 1, + "only the project compiles; the interface is consumed from cache: {invocations:?}" + ); +} diff --git a/tests/contract/compat.rs b/tests/contract/compat.rs new file mode 100644 index 0000000..9afcb63 --- /dev/null +++ b/tests/contract/compat.rs @@ -0,0 +1,77 @@ +//! The version-compat gate (`spawn::compat`) end-to-end: trix probes +//! `tx3c --version` and enforces the support window before the first real +//! invocation. The fake reports any version we ask — coverage the journeys +//! can't provide, since they only ever see real released versions. +//! +//! The window arithmetic itself is unit-tested in `src/spawn/compat.rs`; +//! these pin the process-level behavior: probe order, error surface, the +//! project floor, and the escape hatch. + +use crate::harness::*; + +#[test] +fn below_floor_is_rejected_before_any_real_invocation() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix_with_fake_tx3c(&["check"], &[("FAKE_TX3C_VERSION", "0.21.0")]); + assert_failure_mentioning(&result, "incompatible tx3 toolchain"); + + // The gate stopped everything after the probe. + let invocations = ctx.tx3c_invocations(); + assert!( + invocations.iter().all(|i| i[0] == "--version"), + "no real invocation may follow a failed version gate: {invocations:?}" + ); +} + +#[test] +fn next_major_is_rejected_as_too_new() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix_with_fake_tx3c(&["check"], &[("FAKE_TX3C_VERSION", "1.0.0")]); + assert_failure_mentioning(&result, "newer than this trix supports"); +} + +#[test] +fn unparseable_version_is_rejected() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix_with_fake_tx3c(&["check"], &[("FAKE_TX3C_VERSION", "garbage")]); + assert_failure_mentioning(&result, "cannot parse tx3c version"); +} + +/// `trix.toml [toolchain]` raises the floor above the built-in matrix. +#[test] +fn project_toolchain_floor_is_enforced() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let mut content = ctx.read_file("trix.toml"); + content.push_str("\n[toolchain]\ntx3c = \"0.23.0\"\n"); + ctx.write_file("trix.toml", &content); + + // 0.22.0 satisfies the built-in matrix but not the project floor. + let result = ctx.run_trix_with_fake_tx3c(&["check"], &[("FAKE_TX3C_VERSION", "0.22.0")]); + assert_failure_mentioning(&result, "this protocol requires"); +} + +/// The development escape hatch: `TX3_SKIP_COMPAT_CHECK` bypasses the +/// window entirely (an unreleased tool reports a pre-bump version). +#[test] +fn skip_env_bypasses_the_gate() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix_with_fake_tx3c( + &["check"], + &[ + ("FAKE_TX3C_VERSION", "0.0.1"), + ("TX3_SKIP_COMPAT_CHECK", "1"), + ], + ); + assert_success(&result); + assert_output_contains(&result, "check passed"); +} diff --git a/tests/contract/inspect.rs b/tests/contract/inspect.rs new file mode 100644 index 0000000..d845c69 --- /dev/null +++ b/tests/contract/inspect.rs @@ -0,0 +1,123 @@ +//! `trix inspect tir` ↔ `tx3c build --emit tir-json` / `tx3c decode`. +//! +//! The trix-side logic under test is *which artifact gets handed to which +//! tx3c subcommand*: the project's authored source is lowered +//! (`build --emit tir-json`), an interface is decoded from its cached +//! published TII (`decode --tii`). The resolution rules themselves are unit +//! tests on `interfaces::resolve`. + +use crate::harness::*; + +fn assert_json_object_line(result: &CommandResult) { + // stdout may carry banner/preamble lines; only the JSON line is + // structured. At least one line must parse as a JSON object. + let parsed = result + .stdout + .lines() + .filter_map(|line| serde_json::from_str::(line.trim()).ok()) + .find(|v| v.is_object()); + assert!( + parsed.is_some(), + "no JSON object found in inspect output:\n{}", + result.stdout + ); +} + +#[test] +fn bare_tx_lowers_project_source() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix_with_fake_tx3c(&["inspect", "tir", "--tx", "transfer"], &[]); + assert_success(&result); + assert_json_object_line(&result); + + let invocations = ctx.tx3c_invocations(); + let build = invocations + .iter() + .find(|i| i[0] == "build") + .expect("bare tx must lower the project source via build"); + assert!( + build[1].ends_with("main.tx3"), + "project source must be the input: {build:?}" + ); + assert!( + build + .windows(2) + .any(|w| w[0] == "--tx" && w[1] == "transfer"), + "tx name must be passed through: {build:?}" + ); + assert!( + !invocations.iter().any(|i| i[0] == "decode"), + "project inspection must not decode any TII" + ); +} + +#[test] +fn alias_addresses_cached_interface_tii() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); + ctx.declare_interface("widget", "acme", "widget", "0.1.0", &digest); + + let result = + ctx.run_trix_with_fake_tx3c(&["inspect", "tir", "--tx", "widget::widget_transfer"], &[]); + assert_success(&result); + assert_json_object_line(&result); + + let invocations = ctx.tx3c_invocations(); + let decode = invocations + .iter() + .find(|i| i[0] == "decode") + .expect("an interface tx must be decoded from its cached TII"); + let tii = decode + .windows(2) + .find(|w| w[0] == "--tii") + .map(|w| w[1].clone()) + .expect("decode must receive --tii"); + for segment in ["acme", "widget", "0.1.0", "main.tii"] { + assert!( + tii.contains(segment), + "decode must target the cache at /acme/widget/0.1.0/main.tii, got: {tii}" + ); + } + assert!( + !invocations.iter().any(|i| i[0] == "build"), + "an interface is consumed from its published TII, never recompiled" + ); +} + +/// The fully-qualified registry form addresses the same cache — and works +/// without any `[registry]` section in trix.toml: a cached interface is +/// consumed offline, the registry URL only matters on a cache miss. +#[test] +fn full_ref_addresses_same_cache_without_registry_section() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + assert!( + ctx.load_trix_config().registry.is_none(), + "fresh init should not write a [registry] section" + ); + + let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); + ctx.declare_interface("widget", "acme", "widget", "0.1.0", &digest); + + let result = ctx.run_trix_with_fake_tx3c( + &[ + "inspect", + "tir", + "--tx", + "acme/widget:0.1.0::widget_transfer", + ], + &[], + ); + assert_success(&result); + assert_json_object_line(&result); + + assert!( + ctx.tx3c_invocations().iter().any(|i| i[0] == "decode"), + "full registry ref must resolve to the cached interface TII" + ); +} diff --git a/tests/e2e/codegen_deps.rs b/tests/e2e/codegen_deps.rs deleted file mode 100644 index fa1d85b..0000000 --- a/tests/e2e/codegen_deps.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Interface-aware codegen (`src/commands/codegen.rs`), which delegates the -//! whole pipeline to the `tx3c` binary. These tests require a real `tx3c` -//! (like `happy_path::codegen_generates_bindings_from_fixture`). - -use super::*; -use std::path::PathBuf; - -fn codegen_template_dir() -> String { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/e2e/fixtures/codegen-template") - .to_str() - .expect("fixture path should be valid UTF-8") - .to_string() -} - -fn append_codegen_block(ctx: &TestContext) { - let mut trix_toml = ctx.read_file("trix.toml"); - trix_toml.push_str(&format!( - "\n[[codegen]]\noutput_dir = \"gen\"\nplugin = {{ repo = \"{}\", path = \".\" }}\n", - codegen_template_dir() - )); - ctx.write_file("trix.toml", &trix_toml); -} - -/// With an interface declared + cached, codegen emits one binding set per -/// protocol into per-protocol subdirs: `gen//` and `gen//`. -#[test] -fn codegen_with_interface_emits_subdirs() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let tx3c_path = ctx - .tx3c_path() - .expect("tx3c should be available in PATH or TX3_TX3C_PATH"); - assert!(tx3c_path.is_file(), "tx3c path should exist"); - - let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); - ctx.declare_interface("widget", "acme", "widget", "0.1.0", &digest); - append_codegen_block(&ctx); - - let project_name = ctx.load_trix_config().protocol.name; - - let result = ctx.run_trix(&["codegen"]); - assert_success(&result); - - ctx.assert_file_exists(format!("gen/{project_name}/bindings.txt")); - ctx.assert_file_exists("gen/widget/bindings.txt"); - ctx.assert_file_contains("gen/widget/bindings.txt", "widget"); - - assert!( - !ctx.file_path("gen/bindings.txt").exists(), - "unified layout: nothing should be written flat at gen/bindings.txt" - ); -} - -/// Even with NO interfaces, the unstable path nests the project under its -/// own subdir — the deliberate break from the old flat layout, confined to -/// the unstable path. -#[test] -fn codegen_without_interface_still_uses_subdir() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let tx3c_path = ctx - .tx3c_path() - .expect("tx3c should be available in PATH or TX3_TX3C_PATH"); - assert!(tx3c_path.is_file(), "tx3c path should exist"); - - append_codegen_block(&ctx); - - let project_name = ctx.load_trix_config().protocol.name; - - let result = ctx.run_trix(&["codegen"]); - assert_success(&result); - - ctx.assert_file_exists(format!("gen/{project_name}/bindings.txt")); - assert!( - !ctx.file_path("gen/bindings.txt").exists(), - "unified layout applies even with zero deps" - ); -} diff --git a/tests/e2e/edge_cases.rs b/tests/e2e/edge_cases.rs deleted file mode 100644 index 58bdd8b..0000000 --- a/tests/e2e/edge_cases.rs +++ /dev/null @@ -1,42 +0,0 @@ -use super::*; - -#[test] -fn init_preserves_existing_gitignore() { - let ctx = TestContext::new(); - let existing_gitignore = "# My custom gitignore\n*.log\n"; - ctx.write_file(".gitignore", existing_gitignore); - - let result = ctx.run_trix(&["init", "--yes"]); - - assert_success(&result); - ctx.assert_file_contains(".gitignore", "# My custom gitignore"); - ctx.assert_file_contains(".gitignore", "*.log"); -} - -#[test] -fn init_preserves_existing_main_tx3() { - let ctx = TestContext::new(); - let existing_content = "// This is my existing main.tx3 file\nparty User;\n"; - ctx.write_file("main.tx3", existing_content); - - let result = ctx.run_trix(&["init", "--yes"]); - - assert_success(&result); - ctx.assert_file_contains("main.tx3", "// This is my existing main.tx3 file"); - ctx.assert_file_contains("main.tx3", "party User"); -} - -#[test] -fn init_preserves_existing_test_file() { - let ctx = TestContext::new(); - ctx.write_file( - "tests/basic.toml", - "# Custom test file\n[[wallets]]\nname = \"custom\"\n", - ); - - let result = ctx.run_trix(&["init", "--yes"]); - - assert_success(&result); - ctx.assert_file_contains("tests/basic.toml", "# Custom test file"); - ctx.assert_file_contains("tests/basic.toml", "name = \"custom\""); -} diff --git a/tests/e2e/happy_path.rs b/tests/e2e/happy_path.rs deleted file mode 100644 index e10278a..0000000 --- a/tests/e2e/happy_path.rs +++ /dev/null @@ -1,195 +0,0 @@ -use super::*; -use std::path::PathBuf; -use trix::config::KnownLedgerFamily; - -#[test] -fn init_creates_valid_project_structure() { - let ctx = TestContext::new(); - let result = ctx.run_trix(&["init", "--yes"]); - - assert_success(&result); - - // Verify all expected files exist - ctx.assert_file_exists("trix.toml"); - ctx.assert_file_exists("main.tx3"); - ctx.assert_file_exists("tests/basic.toml"); - ctx.assert_file_exists(".gitignore"); - ctx.assert_file_exists("devnet.toml"); - - // Verify trix.toml using struct deserialization - let config = ctx.load_trix_config(); - assert!( - !config.protocol.name.is_empty(), - "protocol name should not be empty" - ); - assert_eq!( - config.protocol.version, "0.0.0", - "version should be default 0.0.0" - ); - assert_eq!( - config.protocol.main, - PathBuf::from("main.tx3"), - "main file should be main.tx3" - ); - assert!( - matches!(config.ledger.family, KnownLedgerFamily::Cardano), - "ledger family should be Cardano" - ); - - // Verify devnet.toml using struct deserialization - let devnet = ctx.load_devnet_config(); - assert!( - !devnet.utxos.is_empty(), - "devnet.toml should contain utxo definitions" - ); - - // Verify tests/basic.toml using struct deserialization - // Just check basic root structures exist, not every field - let test = ctx.load_test_config(); - assert!( - !test.wallets.is_empty(), - "test.toml should contain wallet definitions" - ); - assert!( - !test.transactions.is_empty(), - "test.toml should contain transaction definitions" - ); - assert!( - !test.expect.is_empty(), - "test.toml should contain expectations" - ); - - // Verify main.tx3 content - let main_content = ctx.read_file("main.tx3"); - assert!( - main_content.contains("party Sender"), - "main.tx3 should contain Sender party" - ); - assert!( - main_content.contains("party Receiver"), - "main.tx3 should contain Receiver party" - ); - assert!( - main_content.contains("tx transfer"), - "main.tx3 should contain transfer transaction" - ); - - // Verify .gitignore content - let gitignore_content = ctx.read_file(".gitignore"); - assert!( - gitignore_content.contains(".tx3"), - ".gitignore should contain .tx3 extension" - ); -} - -#[test] -fn check_validates_valid_project() { - let ctx = TestContext::new(); - - // First init a project with valid Tx3 files - ctx.run_trix(&["init", "--yes"]); - - // Then run check on the valid project - let result = ctx.run_trix(&["check"]); - - assert_success(&result); - assert_output_contains(&result, "check passed, no errors found"); -} - -// Needs a dolos + cshell on the runner, which CI does not provide yet; the -// other e2e cases only need tx3c. Run locally with `cargo test -- --ignored`. -#[test] -#[ignore = "requires dolos + cshell in PATH"] -fn devnet_starts_and_cshell_connects() { - let ctx = TestContext::new(); - - // First init a project - let init_result = ctx.run_trix(&["init", "--yes"]); - assert_success(&init_result); - - // Start devnet in background - let result = ctx.run_trix(&["devnet", "--background"]); - - assert_success(&result); - assert_output_contains(&result, "devnet started in background"); - - // Wait for gRPC port to be open (Dolos uses port 5164 for gRPC) - let port_open = wait_for_port(5164, 30); - assert!( - port_open, - "Devnet gRPC port 5164 should be open within 30 seconds" - ); - - // Setup cshell environment using the project's wallet setup function - // Change to temp directory so wallet::setup can find trix.toml via protocol_root() - let original_dir = std::env::current_dir().expect("should get current dir"); - std::env::set_current_dir(ctx.path()).expect("should change to temp dir"); - - let config = ctx.load_trix_config(); - let profile = config - .resolve_profile("local") - .expect("should resolve local profile"); - let wallet = trix::wallet::setup(&config, &profile).expect("should setup cshell environment"); - - // Restore original directory - std::env::set_current_dir(original_dir).expect("should restore original dir"); - - // Run cshell provider test using the spawn mechanism - let test_result = trix::spawn::cshell::provider_test(&wallet.target_dir, "trix-local"); - assert!( - test_result.is_ok(), - "cshell provider test should succeed: {:?}", - test_result.err() - ); - - // Cleanup: kill dolos process - let _ = std::process::Command::new("pkill") - .args(["-f", "dolos"]) - .output(); -} - -#[test] -fn codegen_generates_bindings_from_fixture() { - let ctx = TestContext::new(); - - let init_result = ctx.run_trix(&["init", "--yes"]); - assert_success(&init_result); - - let tx3c_path = ctx - .tx3c_path() - .expect("tx3c should be available in PATH or TX3_TX3C_PATH"); - assert!(tx3c_path.is_file(), "tx3c path should exist"); - - let fixture_dir = - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/e2e/fixtures/codegen-template"); - let fixture_dir = fixture_dir - .to_str() - .expect("fixture path should be valid UTF-8"); - - let mut trix_toml = ctx.read_file("trix.toml"); - trix_toml.push_str(&format!( - "\n[[codegen]]\noutput_dir = \"gen\"\nplugin = {{ repo = \"{}\", path = \".\" }}\n", - fixture_dir - )); - ctx.write_file("trix.toml", &trix_toml); - - let project_name = ctx.load_trix_config().protocol.name; - - let result = ctx.run_trix(&["codegen"]); - assert_success(&result); - - // Output nests under the project's own subdir (the deliberate layout of - // the tx3c-delegating codegen, see `codegen_deps`); nothing is written - // flat at `gen/bindings.txt`. - let bindings = format!("gen/{project_name}/bindings.txt"); - ctx.assert_file_exists(&bindings); - ctx.assert_file_contains(&bindings, "Protocol:"); - ctx.assert_file_contains(&bindings, "Transactions:"); - ctx.assert_file_contains(&bindings, "transfer"); - ctx.assert_file_contains(&bindings, "Profiles:"); - ctx.assert_file_contains(&bindings, "local"); - assert!( - !ctx.file_path("gen/bindings.txt").exists(), - "unified layout: nothing should be written flat at gen/bindings.txt" - ); -} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs deleted file mode 100644 index 25de6bd..0000000 --- a/tests/e2e/mod.rs +++ /dev/null @@ -1,324 +0,0 @@ -use assert_cmd::cargo::cargo_bin_cmd; -use std::fs; -use std::path::{Path, PathBuf}; -use tempfile::TempDir; -use trix::commands::test::Test as TestConfig; -use trix::config::RootConfig; -use trix::devnet::Config as DevnetConfig; - -/// A test context that provides an isolated temporary directory. -/// Tests can run in parallel because each has its own temp directory. -pub struct TestContext { - pub temp_dir: TempDir, -} - -impl TestContext { - pub fn new() -> Self { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - Self { temp_dir } - } - - /// Returns the path to the temporary directory - pub fn path(&self) -> &Path { - self.temp_dir.path() - } - - /// Run trix command in this temp directory - pub fn run_trix(&self, args: &[&str]) -> CommandResult { - let mut cmd = cargo_bin_cmd!("trix"); - cmd.args(args); - cmd.current_dir(self.path()); - - for (key, value) in self.tool_envs() { - cmd.env(key, value); - } - - let output = cmd.output().expect("Failed to execute trix command"); - - CommandResult { - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - status: output.status, - } - } - - /// Run trix command with environment overrides. - /// - /// Part of the harness surface; no e2e case exercises it today. - #[allow(dead_code)] - pub fn run_trix_with_env(&self, args: &[&str], envs: &[(&str, &str)]) -> CommandResult { - let mut cmd = cargo_bin_cmd!("trix"); - cmd.args(args); - cmd.current_dir(self.path()); - - for (key, value) in self.tool_envs() { - cmd.env(key, value); - } - - for (key, value) in envs { - cmd.env(key, value); - } - - let output = cmd.output().expect("Failed to execute trix command"); - - CommandResult { - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - status: output.status, - } - } - - pub fn tx3c_path(&self) -> Option { - resolve_tool_path("tx3c") - } - - fn tool_envs(&self) -> Vec<(String, String)> { - let mut envs = Vec::new(); - - if let Some(path) = resolve_tool_path("tx3c") { - envs.push(( - "TX3_TX3C_PATH".to_string(), - path.to_string_lossy().to_string(), - )); - // The tx3c under test is built from this tree: it has the new CLI - // surface but still reports the pre-release version, which is - // outside trix's compat window. Bypass the gate for the suite. - envs.push(("TX3_SKIP_COMPAT_CHECK".to_string(), "1".to_string())); - } - - envs - } - - /// Get full path to a file in the temp directory - pub fn file_path(&self, path: impl AsRef) -> PathBuf { - self.path().join(path) - } - - /// Read file from temp directory - pub fn read_file(&self, path: impl AsRef) -> String { - let full_path = self.file_path(path); - fs::read_to_string(&full_path) - .unwrap_or_else(|_| panic!("Failed to read file: {}", full_path.display())) - } - - /// Write file to temp directory (creates parent directories) - pub fn write_file(&self, path: impl AsRef, content: &str) { - let full_path = self.file_path(&path); - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent) - .unwrap_or_else(|_| panic!("Failed to create directory: {}", parent.display())); - } - fs::write(&full_path, content) - .unwrap_or_else(|_| panic!("Failed to write file: {}", full_path.display())); - } - - /// Assert file exists - pub fn assert_file_exists(&self, path: impl AsRef) { - let full_path = self.file_path(&path); - assert!( - full_path.exists(), - "Expected file to exist: {}", - full_path.display() - ); - } - - /// Assert file contains pattern - pub fn assert_file_contains(&self, path: impl AsRef, pattern: &str) { - let content = self.read_file(path); - assert!( - content.contains(pattern), - "Expected file to contain '{}', but it didn't.\n\nContent:\n{}", - pattern, - content - ); - } - - /// Load trix.toml config file and return the parsed RootConfig - pub fn load_trix_config(&self) -> RootConfig { - let path = self.file_path("trix.toml"); - RootConfig::load(&path).expect("Failed to load trix.toml config") - } - - /// Load devnet.toml config file and return the parsed DevnetConfig - pub fn load_devnet_config(&self) -> DevnetConfig { - let path = self.file_path("devnet.toml"); - DevnetConfig::load(&path).expect("Failed to load devnet.toml config") - } - - /// Load tests/basic.toml config file and return the parsed TestConfig - pub fn load_test_config(&self) -> TestConfig { - let path = self.file_path("tests/basic.toml"); - TestConfig::load(&path).expect("Failed to load tests/basic.toml config") - } - - /// Copy the use-stub fixture into the temp dir's `.tx3/tii/...` cache - /// for `(scope, name, version)`. Returns the fixture's digest so the - /// caller can write a matching trix.toml entry. - pub fn prime_interface_cache(&self, scope: &str, name: &str, version: &str) -> String { - let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/e2e/fixtures/use-stub") - .join(scope) - .join(name) - .join(version); - assert!( - fixture_root.is_dir(), - "fixture missing for {}/{}@{}: expected at {}", - scope, - name, - version, - fixture_root.display(), - ); - let dest_root = self - .path() - .join(".tx3/tii") - .join(scope) - .join(name) - .join(version); - fs::create_dir_all(&dest_root).expect("create interface cache dir"); - for entry in fs::read_dir(&fixture_root).expect("read fixture") { - let entry = entry.expect("fixture entry"); - let path = entry.path(); - let file_name = path.file_name().unwrap(); - fs::copy(&path, dest_root.join(file_name)).expect("copy fixture file"); - } - let metadata = fs::read_to_string(fixture_root.join("metadata.json")) - .expect("read fixture metadata.json"); - let value: serde_json::Value = - serde_json::from_str(&metadata).expect("parse fixture metadata.json"); - value - .get("digest") - .and_then(|v| v.as_str()) - .expect("digest in fixture metadata.json") - .to_string() - } - - /// Append an `[interfaces.]` table to the project's trix.toml so - /// the rest of the project sees the primed cache as a declared interface. - pub fn declare_interface( - &self, - alias: &str, - scope: &str, - name: &str, - version: &str, - digest: &str, - ) { - let mut content = self.read_file("trix.toml"); - if !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(&format!( - "\n[interfaces.{}]\nref = \"{}/{}:{}\"\ndigest = \"{}\"\n", - alias, scope, name, version, digest, - )); - self.write_file("trix.toml", &content); - } -} - -pub struct CommandResult { - pub stdout: String, - pub stderr: String, - pub status: std::process::ExitStatus, -} - -impl CommandResult { - pub fn success(&self) -> bool { - self.status.success() - } -} - -pub fn assert_success(result: &CommandResult) { - assert!( - result.success(), - "Expected command to succeed but it failed.\n\nSTDOUT:\n{}\n\nSTDERR:\n{}", - result.stdout, - result.stderr - ); -} - -pub fn assert_output_contains(result: &CommandResult, pattern: &str) { - assert!( - result.stdout.contains(pattern), - "Expected stdout to contain '{}', but it didn't.\n\nSTDOUT:\n{}\n\nSTDERR:\n{}", - pattern, - result.stdout, - result.stderr - ); -} - -/// Wait for a port to be open with timeout -pub fn wait_for_port(port: u16, timeout_secs: u64) -> bool { - use std::net::TcpStream; - use std::time::{Duration, Instant}; - - let start = Instant::now(); - let timeout = Duration::from_secs(timeout_secs); - - while start.elapsed() < timeout { - if TcpStream::connect(("127.0.0.1", port)).is_ok() { - return true; - } - std::thread::sleep(Duration::from_millis(100)); - } - false -} - -/// Check if a process is running by PID (Unix only). -/// -/// Part of the harness surface; no e2e case exercises it today. -#[allow(dead_code)] -#[cfg(unix)] -pub fn is_process_running(pid: u32) -> bool { - unsafe { libc::kill(pid as i32, 0) == 0 } -} - -#[allow(dead_code)] -#[cfg(not(unix))] -pub fn is_process_running(_pid: u32) -> bool { - // On non-Unix systems, we can't easily check if a process is running - // This is a simplified check that always returns true - true -} - -pub mod codegen_deps; -pub mod edge_cases; -pub mod happy_path; -pub mod smoke; -pub mod use_command; - -fn resolve_tool_path(tool: &str) -> Option { - let env_var = format!("TX3_{}_PATH", tool.to_uppercase()); - if let Ok(path) = std::env::var(&env_var) { - let path = PathBuf::from(path); - if path.is_file() { - return Some(path); - } - } - - let cargo_bin_var = format!("CARGO_BIN_EXE_{tool}"); - if let Ok(path) = std::env::var(&cargo_bin_var) { - let path = PathBuf::from(path); - if path.is_file() { - return Some(path); - } - } - - let cargo_home = std::env::var("CARGO_HOME") - .map(PathBuf::from) - .or_else(|_| { - std::env::var("HOME") - .map(PathBuf::from) - .map(|home| home.join(".cargo")) - }) - .ok()?; - - let mut path = cargo_home.join("bin").join(tool); - if cfg!(target_os = "windows") { - path.set_extension("exe"); - } - - if path.is_file() { - return Some(path); - } - - None -} diff --git a/tests/e2e/smoke.rs b/tests/e2e/smoke.rs deleted file mode 100644 index 374ab3d..0000000 --- a/tests/e2e/smoke.rs +++ /dev/null @@ -1,10 +0,0 @@ -use super::*; - -#[test] -fn init_runs_without_error() { - let ctx = TestContext::new(); - let result = ctx.run_trix(&["init", "--yes"]); - - assert_success(&result); - ctx.assert_file_exists("trix.toml"); -} diff --git a/tests/e2e/use_command.rs b/tests/e2e/use_command.rs deleted file mode 100644 index ebf5967..0000000 --- a/tests/e2e/use_command.rs +++ /dev/null @@ -1,228 +0,0 @@ -use super::*; - -/// `trix use` rejects an alias-only reference at parse time because aliases -/// don't carry version info. -#[test] -fn use_rejects_alias_only_reference() { - let ctx = TestContext::new(); - let init = ctx.run_trix(&["init", "--yes"]); - assert_success(&init); - - let result = ctx.run_trix(&["use", "widget"]); - assert!( - !result.success(), - "expected failure, got: {:?}", - result.stdout - ); - let combined = format!("{}{}", result.stdout, result.stderr); - assert!( - combined.contains("alias") - || combined.contains("registry reference") - || combined.contains("scope"), - "stderr should explain the registry-only requirement:\n{}", - combined - ); -} - -/// A cloned/freshly-initialized project with no `[registry]` section can -/// still consume an already-cached interface — the registry URL falls back -/// to the hardcoded default and is only contacted on a cache miss. Exercised -/// via `inspect tir`, an interface-aware command (`check` is project-only). -#[test] -fn interface_consumed_without_registry_when_cached() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - // Sanity: fresh init has no [registry]. - assert!( - ctx.load_trix_config().registry.is_none(), - "fresh init should not write a [registry] section" - ); - - let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); - ctx.declare_interface("widget", "acme", "widget", "0.1.0", &digest); - - let result = ctx.run_trix(&["inspect", "tir", "--tx", "widget::widget_transfer"]); - assert_success(&result); -} - -/// `trix check` is project-only: a declared, cached interface neither helps -/// nor hinders it. Check still parses/analyzes only the project's protocol. -#[test] -fn check_ignores_declared_interfaces() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); - ctx.declare_interface("widget", "acme", "widget", "0.1.0", &digest); - - let result = ctx.run_trix(&["check"]); - assert_success(&result); - assert_output_contains(&result, "check passed"); -} - -/// Inspect a transaction that lives inside an interface, addressed via -/// `::`. -#[test] -fn inspect_tir_addresses_interface_tx_by_alias() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); - ctx.declare_interface("widget", "acme", "widget", "0.1.0", &digest); - - let result = ctx.run_trix(&["inspect", "tir", "--tx", "widget::widget_transfer"]); - assert_success(&result); - // The stdout includes any update-banner preamble; only the JSON line is - // structured. Confirm at least one line parses as a JSON object. - let parsed = result - .stdout - .lines() - .filter_map(|line| serde_json::from_str::(line.trim()).ok()) - .find(|v| v.is_object()); - assert!( - parsed.is_some(), - "no JSON object found in inspect output:\n{}", - result.stdout - ); -} - -/// Inspect via the fully-qualified registry form. -#[test] -fn inspect_tir_addresses_interface_tx_by_full_ref() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); - ctx.declare_interface("widget", "acme", "widget", "0.1.0", &digest); - - let result = ctx.run_trix(&[ - "inspect", - "tir", - "--tx", - "acme/widget:0.1.0::widget_transfer", - ]); - assert_success(&result); -} - -/// Inspecting a bare tx name continues to target the project's own protocol. -#[test] -fn inspect_tir_bare_tx_targets_project() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); - ctx.declare_interface("widget", "acme", "widget", "0.1.0", &digest); - - // The default init template defines `tx transfer`. - let result = ctx.run_trix(&["inspect", "tir", "--tx", "transfer"]); - assert_success(&result); -} - -/// Unknown alias on `inspect tir --tx`: useful error, non-zero exit. -#[test] -fn inspect_tir_rejects_unknown_alias() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let result = ctx.run_trix(&["inspect", "tir", "--tx", "ghost::transfer"]); - assert!(!result.success()); - let combined = format!("{}{}", result.stdout, result.stderr); - assert!( - combined.contains("ghost") || combined.contains("no protocol named"), - "stderr should mention the unknown alias:\n{}", - combined - ); -} - -/// Tampered cache digest: an interface-aware command (`inspect tir`) surfaces -/// a digest-mismatch error and tells the user to `trix use --force`. -#[test] -fn interface_digest_mismatch_after_tamper_is_rejected() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let digest = ctx.prime_interface_cache("acme", "widget", "0.1.0"); - // Declare the interface with a digest that does NOT match the metadata.json's. - ctx.declare_interface( - "widget", - "acme", - "widget", - "0.1.0", - "sha256:0000000000000000000000000000000000000000000000000000000000000bad", - ); - let _ = digest; - - let result = ctx.run_trix(&["inspect", "tir", "--tx", "transfer"]); - assert!( - !result.success(), - "expected digest mismatch failure but got success:\n{}", - result.stdout - ); - let combined = format!("{}{}", result.stdout, result.stderr); - assert!( - combined.contains("digest"), - "stderr should mention the digest mismatch:\n{}", - combined - ); -} - -/// Hand-edited trix.toml with an alias-only `ref` value: rejected on every -/// scoped command via the same diagnostic the CLI uses. -#[test] -fn trix_toml_rejects_alias_only_ref() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let mut content = ctx.read_file("trix.toml"); - if !content.ends_with('\n') { - content.push('\n'); - } - content.push_str("\n[interfaces.widget]\nref = \"widget\"\ndigest = \"sha256:deadbeef\"\n"); - ctx.write_file("trix.toml", &content); - - let result = ctx.run_trix(&["inspect", "tir", "--tx", "transfer"]); - assert!( - !result.success(), - "alias-only ref in trix.toml should be rejected" - ); -} - -/// Hand-edited trix.toml with `ref = "acme/widget:latest"`: rejected because -/// the file is a pinned lockfile, only concrete versions allowed. -#[test] -fn trix_toml_rejects_latest_ref() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let mut content = ctx.read_file("trix.toml"); - if !content.ends_with('\n') { - content.push('\n'); - } - content.push_str( - "\n[interfaces.widget]\nref = \"acme/widget:latest\"\ndigest = \"sha256:deadbeef\"\n", - ); - ctx.write_file("trix.toml", &content); - - let result = ctx.run_trix(&["inspect", "tir", "--tx", "transfer"]); - assert!( - !result.success(), - "latest ref in trix.toml should be rejected" - ); -} - -/// Projects without `[interfaces]` should be entirely unaffected. -#[test] -fn projects_without_interfaces_section_unchanged() { - let ctx = TestContext::new(); - assert_success(&ctx.run_trix(&["init", "--yes"])); - - let result = ctx.run_trix(&["check"]); - assert_success(&result); - - let config = ctx.load_trix_config(); - assert!( - config.interfaces.is_empty(), - "fresh init should have no interfaces declared" - ); -} diff --git a/tests/e2e_tests.rs b/tests/e2e_tests.rs deleted file mode 100644 index b6ae44f..0000000 --- a/tests/e2e_tests.rs +++ /dev/null @@ -1 +0,0 @@ -mod e2e; diff --git a/tests/e2e/fixtures/codegen-template/bindings.txt.hbs b/tests/fixtures/codegen-template/bindings.txt.hbs similarity index 100% rename from tests/e2e/fixtures/codegen-template/bindings.txt.hbs rename to tests/fixtures/codegen-template/bindings.txt.hbs diff --git a/tests/e2e/fixtures/use-stub/acme/widget/0.1.0/README.md b/tests/fixtures/use-stub/acme/widget/0.1.0/README.md similarity index 100% rename from tests/e2e/fixtures/use-stub/acme/widget/0.1.0/README.md rename to tests/fixtures/use-stub/acme/widget/0.1.0/README.md diff --git a/tests/e2e/fixtures/use-stub/acme/widget/0.1.0/main.tii b/tests/fixtures/use-stub/acme/widget/0.1.0/main.tii similarity index 100% rename from tests/e2e/fixtures/use-stub/acme/widget/0.1.0/main.tii rename to tests/fixtures/use-stub/acme/widget/0.1.0/main.tii diff --git a/tests/e2e/fixtures/use-stub/acme/widget/0.1.0/main.tx3 b/tests/fixtures/use-stub/acme/widget/0.1.0/main.tx3 similarity index 100% rename from tests/e2e/fixtures/use-stub/acme/widget/0.1.0/main.tx3 rename to tests/fixtures/use-stub/acme/widget/0.1.0/main.tx3 diff --git a/tests/e2e/fixtures/use-stub/acme/widget/0.1.0/metadata.json b/tests/fixtures/use-stub/acme/widget/0.1.0/metadata.json similarity index 100% rename from tests/e2e/fixtures/use-stub/acme/widget/0.1.0/metadata.json rename to tests/fixtures/use-stub/acme/widget/0.1.0/metadata.json diff --git a/tests/harness/fake_tx3c.rs b/tests/harness/fake_tx3c.rs new file mode 100644 index 0000000..e2c1466 --- /dev/null +++ b/tests/harness/fake_tx3c.rs @@ -0,0 +1,116 @@ +//! A stand-in `tx3c` for the contract test suite (`tests/contract/`). +//! +//! NOT a cargo target: the harness compiles this file with plain `rustc` at +//! test time (see `harness::fake_tx3c_path`), so it never appears in release +//! artifacts and adds nothing to the dist/publish surface. std-only. +//! +//! It implements exactly the CLI surface trix relies on (the contract pinned +//! by `src/spawn/tx3c.rs` + `src/spawn/compat.rs`) and is steered per test +//! via environment variables: +//! +//! - `FAKE_TX3C_VERSION` version reported by `--version` (default 0.22.0) +//! - `FAKE_TX3C_ARGS_LOG` file to append each invocation's argv to, +//! US-separated (`\x1f`), one line per invocation +//! - `FAKE_TX3C_DIAGNOSTICS` raw stdout for `build --diagnostics-format json` +//! (default `{"diagnostics":[]}`); pass non-JSON to simulate a broken tool +//! - `FAKE_TX3C_EXIT` exit with this code (after logging argv), +//! printing `FAKE_TX3C_STDERR` to stderr — simulates tool failure + +use std::io::Write as _; + +fn flag_value(args: &[String], flag: &str) -> Option { + args.iter() + .position(|a| a == flag) + .and_then(|i| args.get(i + 1)) + .cloned() +} + +fn json_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + + if let Ok(log) = std::env::var("FAKE_TX3C_ARGS_LOG") { + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) { + let _ = writeln!(f, "{}", args.join("\u{1f}")); + } + } + + // The version probe must answer even when the fake is told to fail, + // so compat gating and failure simulation compose per invocation. + if args.iter().any(|a| a == "--version") { + let version = + std::env::var("FAKE_TX3C_VERSION").unwrap_or_else(|_| "0.22.0".to_string()); + println!("tx3c {}", version); + return; + } + + if let Ok(code) = std::env::var("FAKE_TX3C_EXIT") { + let code: i32 = code.parse().unwrap_or(1); + if code != 0 { + let msg = std::env::var("FAKE_TX3C_STDERR") + .unwrap_or_else(|_| "fake tx3c: simulated failure".to_string()); + eprintln!("{}", msg); + std::process::exit(code); + } + } + + match args.first().map(String::as_str) { + Some("build") => { + if args.iter().any(|a| a == "--diagnostics-format") { + // `check` path: print the envelope; trix parses stdout + // regardless of exit status. + let envelope = std::env::var("FAKE_TX3C_DIAGNOSTICS") + .unwrap_or_else(|_| r#"{"diagnostics":[]}"#.to_string()); + println!("{}", envelope); + return; + } + match flag_value(&args, "--emit").as_deref() { + Some("tii") => { + let output = flag_value(&args, "--output").expect("--output missing"); + std::fs::write( + &output, + r#"{"protocol":{"name":"fake"},"transactions":{"transfer":{}}}"#, + ) + .expect("write tii"); + } + Some("tir-json") => { + let tx = flag_value(&args, "--tx").expect("--tx missing"); + let source = args.get(1).cloned().unwrap_or_default(); + println!( + r#"{{"tx":"{}","from":"source","source":"{}"}}"#, + json_escape(&tx), + json_escape(&source) + ); + } + other => { + eprintln!("fake tx3c: unsupported build emit {:?}", other); + std::process::exit(2); + } + } + } + Some("decode") => { + let tii = flag_value(&args, "--tii").expect("--tii missing"); + let tx = flag_value(&args, "--tx").expect("--tx missing"); + println!( + r#"{{"tx":"{}","from":"tii","tii":"{}"}}"#, + json_escape(&tx), + json_escape(&tii) + ); + } + Some("codegen") => { + let tii = flag_value(&args, "--tii").expect("--tii missing"); + let template = flag_value(&args, "--template").expect("--template missing"); + let output = flag_value(&args, "--output").expect("--output missing"); + let dest = std::path::Path::new(&output).join("bindings.txt"); + std::fs::write(&dest, format!("tii={}\ntemplate={}\n", tii, template)) + .expect("write bindings"); + } + other => { + eprintln!("fake tx3c: unknown subcommand {:?}", other); + std::process::exit(2); + } + } +} diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs new file mode 100644 index 0000000..602ff91 --- /dev/null +++ b/tests/harness/mod.rs @@ -0,0 +1,369 @@ +//! Shared harness for the trix test suites (`tests/cli.rs`, `tests/contract.rs`). +//! +//! Every spawned `trix` is hermetic by construction: +//! - `TX3_HOME` points at a per-test throwaway root, so the global config, +//! telemetry state, and default tool lookup never touch the developer's +//! real `~/.tx3` (and parallel tests can't race on it). Works on every OS, +//! unlike faking `$HOME`. +//! - `PATH` points at an empty directory, so nothing on the machine +//! (a real `tx3up`, `tx3c`, …) can leak into a test. +//! - Inherited `TX3_*` variables are scrubbed. +//! +//! Consequently no test here can depend on an installed toolchain: a test +//! either needs no tool at all (`tests/cli/`) or drives the fake `tx3c` +//! (`tests/contract/`, see [`fake_tx3c_path`]). + +#![allow(dead_code)] // shared by multiple test crates; each uses a subset + +use assert_cmd::cargo::cargo_bin_cmd; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use tempfile::TempDir; + +use trix::commands::test::Test as TestConfig; +use trix::config::RootConfig; +use trix::devnet::Config as DevnetConfig; + +pub struct TestContext { + temp: TempDir, +} + +impl TestContext { + /// The standard context: telemetry pre-disabled so runs are silent and + /// never attempt an OTLP export. + pub fn new() -> Self { + let ctx = Self::new_unseeded(); + ctx.write_home_file("trix/config.toml", "[telemetry]\nenabled = false\n"); + ctx + } + + /// A context with a pristine `TX3_HOME` — for tests covering the + /// first-run behavior itself (global config creation, telemetry banner). + pub fn new_unseeded() -> Self { + let temp = TempDir::new().expect("failed to create temp directory"); + fs::create_dir_all(temp.path().join("project")).unwrap(); + fs::create_dir_all(temp.path().join("tx3-home")).unwrap(); + fs::create_dir_all(temp.path().join("empty-path")).unwrap(); + Self { temp } + } + + /// The project directory `trix` runs in. + pub fn path(&self) -> PathBuf { + self.temp.path().join("project") + } + + /// The isolated stand-in for `~/.tx3`. + pub fn tx3_home(&self) -> PathBuf { + self.temp.path().join("tx3-home") + } + + fn write_home_file(&self, rel: &str, content: &str) { + let full = self.tx3_home().join(rel); + fs::create_dir_all(full.parent().unwrap()).unwrap(); + fs::write(full, content).unwrap(); + } + + /// Run trix in the project directory, hermetically. + pub fn run_trix(&self, args: &[&str]) -> CommandResult { + self.run_trix_with_env(args, &[]) + } + + /// Run trix with extra environment variables on top of the hermetic base. + pub fn run_trix_with_env(&self, args: &[&str], envs: &[(&str, &str)]) -> CommandResult { + let mut cmd = cargo_bin_cmd!("trix"); + cmd.args(args); + cmd.current_dir(self.path()); + + cmd.env("TX3_HOME", self.tx3_home()); + cmd.env("PATH", self.temp.path().join("empty-path")); + for var in [ + "TX3_TX3C_PATH", + "TX3_DOLOS_PATH", + "TX3_CSHELL_PATH", + "TX3_SKIP_COMPAT_CHECK", + ] { + cmd.env_remove(var); + } + + for (key, value) in envs { + cmd.env(key, value); + } + + let output = cmd.output().expect("Failed to execute trix command"); + + CommandResult { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + status: output.status, + } + } + + /// Run trix against the fake `tx3c` (compiling it on first use), logging + /// every tx3c invocation for [`Self::tx3c_invocations`]. `fake_envs` + /// steers the fake — see `tests/harness/fake_tx3c.rs`. + pub fn run_trix_with_fake_tx3c( + &self, + args: &[&str], + fake_envs: &[(&str, &str)], + ) -> CommandResult { + let fake = fake_tx3c_path().display().to_string(); + let log = self.args_log().display().to_string(); + let mut envs: Vec<(&str, &str)> = vec![ + ("TX3_TX3C_PATH", fake.as_str()), + ("FAKE_TX3C_ARGS_LOG", log.as_str()), + ]; + envs.extend_from_slice(fake_envs); + self.run_trix_with_env(args, &envs) + } + + fn args_log(&self) -> PathBuf { + self.temp.path().join("tx3c-args.log") + } + + /// Every `tx3c` invocation the run produced, in order, as argv vectors + /// (without the executable itself). Empty if tx3c was never spawned. + pub fn tx3c_invocations(&self) -> Vec> { + match fs::read_to_string(self.args_log()) { + Ok(content) => content + .lines() + .map(|line| line.split('\u{1f}').map(str::to_string).collect()) + .collect(), + Err(_) => Vec::new(), + } + } + + // ------------------------------------------------------------------ + // File helpers (relative to the project directory) + // ------------------------------------------------------------------ + + pub fn file_path(&self, path: impl AsRef) -> PathBuf { + self.path().join(path) + } + + pub fn read_file(&self, path: impl AsRef) -> String { + let full_path = self.file_path(path); + fs::read_to_string(&full_path) + .unwrap_or_else(|_| panic!("Failed to read file: {}", full_path.display())) + } + + pub fn write_file(&self, path: impl AsRef, content: &str) { + let full_path = self.file_path(&path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent) + .unwrap_or_else(|_| panic!("Failed to create directory: {}", parent.display())); + } + fs::write(&full_path, content) + .unwrap_or_else(|_| panic!("Failed to write file: {}", full_path.display())); + } + + pub fn assert_file_exists(&self, path: impl AsRef) { + let full_path = self.file_path(&path); + assert!( + full_path.exists(), + "Expected file to exist: {}", + full_path.display() + ); + } + + pub fn assert_file_contains(&self, path: impl AsRef, pattern: &str) { + let content = self.read_file(path); + assert!( + content.contains(pattern), + "Expected file to contain '{}', but it didn't.\n\nContent:\n{}", + pattern, + content + ); + } + + // ------------------------------------------------------------------ + // Typed config loaders + // ------------------------------------------------------------------ + + pub fn load_trix_config(&self) -> RootConfig { + let path = self.file_path("trix.toml"); + RootConfig::load(&path).expect("Failed to load trix.toml config") + } + + pub fn load_devnet_config(&self) -> DevnetConfig { + let path = self.file_path("devnet.toml"); + DevnetConfig::load(&path).expect("Failed to load devnet.toml config") + } + + pub fn load_test_config(&self) -> TestConfig { + let path = self.file_path("tests/basic.toml"); + TestConfig::load(&path).expect("Failed to load tests/basic.toml config") + } + + // ------------------------------------------------------------------ + // Interface-cache fixtures + // ------------------------------------------------------------------ + + /// Copy the use-stub fixture into the project's `.tx3/tii/...` cache + /// for `(scope, name, version)`. Returns the fixture's digest so the + /// caller can write a matching trix.toml entry. + pub fn prime_interface_cache(&self, scope: &str, name: &str, version: &str) -> String { + let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/use-stub") + .join(scope) + .join(name) + .join(version); + assert!( + fixture_root.is_dir(), + "fixture missing for {}/{}@{}: expected at {}", + scope, + name, + version, + fixture_root.display(), + ); + let dest_root = self + .path() + .join(".tx3/tii") + .join(scope) + .join(name) + .join(version); + fs::create_dir_all(&dest_root).expect("create interface cache dir"); + for entry in fs::read_dir(&fixture_root).expect("read fixture") { + let entry = entry.expect("fixture entry"); + let path = entry.path(); + let file_name = path.file_name().unwrap(); + fs::copy(&path, dest_root.join(file_name)).expect("copy fixture file"); + } + let metadata = fs::read_to_string(fixture_root.join("metadata.json")) + .expect("read fixture metadata.json"); + let value: serde_json::Value = + serde_json::from_str(&metadata).expect("parse fixture metadata.json"); + value + .get("digest") + .and_then(|v| v.as_str()) + .expect("digest in fixture metadata.json") + .to_string() + } + + /// Append an `[interfaces.]` table to the project's trix.toml so + /// the rest of the project sees the primed cache as a declared interface. + pub fn declare_interface( + &self, + alias: &str, + scope: &str, + name: &str, + version: &str, + digest: &str, + ) { + let mut content = self.read_file("trix.toml"); + if !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(&format!( + "\n[interfaces.{}]\nref = \"{}/{}:{}\"\ndigest = \"{}\"\n", + alias, scope, name, version, digest, + )); + self.write_file("trix.toml", &content); + } + + /// The path of the codegen-template fixture, for `[[codegen]]` entries. + /// Forward slashes throughout: the value is interpolated into a TOML + /// basic string, where Windows backslashes would be escape sequences. + pub fn codegen_template_dir(&self) -> String { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/codegen-template") + .to_str() + .expect("fixture path should be valid UTF-8") + .replace('\\', "/") + } + + /// Append a `[[codegen]]` entry pointing at the local template fixture. + pub fn declare_codegen(&self) { + let mut trix_toml = self.read_file("trix.toml"); + trix_toml.push_str(&format!( + "\n[[codegen]]\noutput_dir = \"gen\"\nplugin = {{ repo = \"{}\", path = \".\" }}\n", + self.codegen_template_dir() + )); + self.write_file("trix.toml", &trix_toml); + } +} + +pub struct CommandResult { + pub stdout: String, + pub stderr: String, + pub status: std::process::ExitStatus, +} + +impl CommandResult { + pub fn success(&self) -> bool { + self.status.success() + } + + pub fn combined(&self) -> String { + format!("{}{}", self.stdout, self.stderr) + } +} + +pub fn assert_success(result: &CommandResult) { + assert!( + result.success(), + "Expected command to succeed but it failed.\n\nSTDOUT:\n{}\n\nSTDERR:\n{}", + result.stdout, + result.stderr + ); +} + +pub fn assert_failure_mentioning(result: &CommandResult, pattern: &str) { + assert!( + !result.success(), + "Expected command to fail but it succeeded.\n\nSTDOUT:\n{}", + result.stdout + ); + // miette's fancy renderer wraps messages across `│`-guttered lines; + // normalize both sides to plain single-spaced text before matching. + let normalize = |s: &str| { + s.replace('│', " ") + .split_whitespace() + .collect::>() + .join(" ") + }; + let combined = result.combined(); + assert!( + normalize(&combined).contains(&normalize(pattern)), + "Expected failure output to mention '{}', but it didn't.\n\nOUTPUT:\n{}", + pattern, + combined + ); +} + +pub fn assert_output_contains(result: &CommandResult, pattern: &str) { + assert!( + result.stdout.contains(pattern), + "Expected stdout to contain '{}', but it didn't.\n\nSTDOUT:\n{}\n\nSTDERR:\n{}", + pattern, + result.stdout, + result.stderr + ); +} + +/// The fake `tx3c` binary, compiled once per test process from +/// `tests/harness/fake_tx3c.rs` with plain `rustc`. Not a cargo target on +/// purpose: it stays out of release artifacts and the dist/publish surface. +pub fn fake_tx3c_path() -> &'static Path { + static PATH: OnceLock = OnceLock::new(); + PATH.get_or_init(|| { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/harness/fake_tx3c.rs"); + let out_dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")); + fs::create_dir_all(&out_dir).expect("create target tmp dir"); + // Per-process name: always freshly compiled, never stale. + let exe = out_dir.join(format!( + "fake-tx3c-{}{}", + std::process::id(), + std::env::consts::EXE_SUFFIX + )); + let status = std::process::Command::new("rustc") + .arg("--edition=2021") + .arg("-o") + .arg(&exe) + .arg(&src) + .status() + .expect("rustc must be available (cargo test implies it)"); + assert!(status.success(), "failed to compile the fake tx3c"); + exe + }) +}