From b5d222a3b237f8b93968f9805acd148773401074 Mon Sep 17 00:00:00 2001 From: Santiago Date: Fri, 7 Aug 2026 09:13:40 -0300 Subject: [PATCH 1/2] test: restructure the local suite into unit/cli/contract layers The old e2e suite needed a real tx3c (and for one test, dolos + cshell) on the machine, so CI could never run half of it, two tests passed vacuously whenever every command failed, and parallel tests raced on the real ~/.tx3/trix/config.toml. Restructure around the process boundary: each assertion moves to the innermost layer that can make it, and no test in this repo requires a real helper binary. Real cross-binary interop belongs to the umbrella's DX e2e journeys, which gate releases. - Unit layer: extract `interfaces::verify_cache_at` so the cache integrity rules are testable against any directory; add unit tests for it, `interfaces::validate`, and the reference resolver. The formerly vacuous alias-only/latest lockfile tests become real assertions here. - CLI layer (tests/cli): spawns only the trix binary. init scaffolding and preservation, plus one wiring probe per fail-before-spawn error path (clap ref rejection, unknown alias, [interfaces] validation, digest tamper). - Contract layer (tests/contract): trix's side of the tx3c process contract against a fake tx3c compiled at test time with plain rustc (never a cargo target, so it can't leak into dist artifacts). Asserts argv, artifact routing (project source lowered vs cached interface TII decoded), output interpretation, failure surfacing, and the version gate against arbitrary reported versions. - Hermeticity: new TX3_HOME override for the ~/.tx3 root (works on every OS, unlike faking $HOME); the harness gives each test a throwaway root, an empty PATH, and scrubbed TX3_* vars, eliminating the global-config race and any dependence on installed toolchains. - Delete devnet_starts_and_cshell_connects (journey 04 covers the real round-trip; its pkill -f dolos also killed unrelated processes) and the now-unused predicates/insta/libc dev-deps. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 57 --- Cargo.toml | 3 - src/home.rs | 12 +- src/interfaces/mod.rs | 215 ++++++++++ src/interfaces/resolve.rs | 93 +++++ tests/README.md | 352 ++++------------- tests/cli.rs | 19 + tests/cli/init.rs | 142 +++++++ tests/cli/interfaces.rs | 52 +++ tests/cli/refs.rs | 58 +++ tests/contract.rs | 21 + tests/contract/check.rs | 101 +++++ tests/contract/codegen.rs | 82 ++++ tests/contract/compat.rs | 77 ++++ tests/contract/inspect.rs | 123 ++++++ tests/e2e/codegen_deps.rs | 81 ---- tests/e2e/edge_cases.rs | 42 -- tests/e2e/happy_path.rs | 195 ---------- tests/e2e/mod.rs | 324 ---------------- tests/e2e/smoke.rs | 10 - tests/e2e/use_command.rs | 228 ----------- tests/e2e_tests.rs | 1 - .../codegen-template/bindings.txt.hbs | 0 .../use-stub/acme/widget/0.1.0/README.md | 0 .../use-stub/acme/widget/0.1.0/main.tii | 0 .../use-stub/acme/widget/0.1.0/main.tx3 | 0 .../use-stub/acme/widget/0.1.0/metadata.json | 0 tests/harness/fake_tx3c.rs | 116 ++++++ tests/harness/mod.rs | 367 ++++++++++++++++++ 29 files changed, 1545 insertions(+), 1226 deletions(-) create mode 100644 tests/cli.rs create mode 100644 tests/cli/init.rs create mode 100644 tests/cli/interfaces.rs create mode 100644 tests/cli/refs.rs create mode 100644 tests/contract.rs create mode 100644 tests/contract/check.rs create mode 100644 tests/contract/codegen.rs create mode 100644 tests/contract/compat.rs create mode 100644 tests/contract/inspect.rs delete mode 100644 tests/e2e/codegen_deps.rs delete mode 100644 tests/e2e/edge_cases.rs delete mode 100644 tests/e2e/happy_path.rs delete mode 100644 tests/e2e/mod.rs delete mode 100644 tests/e2e/smoke.rs delete mode 100644 tests/e2e/use_command.rs delete mode 100644 tests/e2e_tests.rs rename tests/{e2e => }/fixtures/codegen-template/bindings.txt.hbs (100%) rename tests/{e2e => }/fixtures/use-stub/acme/widget/0.1.0/README.md (100%) rename tests/{e2e => }/fixtures/use-stub/acme/widget/0.1.0/main.tii (100%) rename tests/{e2e => }/fixtures/use-stub/acme/widget/0.1.0/main.tx3 (100%) rename tests/{e2e => }/fixtures/use-stub/acme/widget/0.1.0/metadata.json (100%) create mode 100644 tests/harness/fake_tx3c.rs create mode 100644 tests/harness/mod.rs 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..12aeb4d --- /dev/null +++ b/tests/harness/mod.rs @@ -0,0 +1,367 @@ +//! 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. + 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") + .to_string() + } + + /// 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 + }) +} From 06e793f4525362062d25766a2bb0610f4bef0d67 Mon Sep 17 00:00:00 2001 From: Santiago Date: Fri, 7 Aug 2026 09:20:49 -0300 Subject: [PATCH 2/2] fix(tests): forward-slash the codegen fixture path for TOML on Windows Backslashes in the interpolated repo path are escape sequences inside a TOML basic string, so trix.toml failed to parse on the Windows runner. Co-Authored-By: Claude Opus 5 (1M context) --- tests/harness/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index 12aeb4d..602ff91 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -262,12 +262,14 @@ impl TestContext { } /// 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") - .to_string() + .replace('\\', "/") } /// Append a `[[codegen]]` entry pointing at the local template fixture.