diff --git a/Cargo.lock b/Cargo.lock index 4fe6d30..00abb9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4391,6 +4391,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "sha2", "tempfile", "termimad", "thiserror 2.0.17", diff --git a/Cargo.toml b/Cargo.toml index d7b4467..65a4756 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,10 @@ url = "2.5" [dev-dependencies] assert_cmd = "2.0" +# Real sha256 digests for the in-process OCI registry stub +# (`tests/harness/oci_stub.rs`): `oci_client` verifies every blob against +# its descriptor, so the stub cannot fake them. +sha2 = "0.10" [lib] name = "trix" diff --git a/src/commands/codegen.rs b/src/commands/codegen.rs index a338f52..e58b199 100644 --- a/src/commands/codegen.rs +++ b/src/commands/codegen.rs @@ -298,10 +298,14 @@ pub async fn run( let templates_dir = extract_github_templates(&github_url, &template_temp, &plugin.path).await?; + // Plugin defaults merged with the entry's `options`, user values + // winning — the same options for every protocol in this entry. + let options = codegen.resolved_options(); + for (name, tii_path) in &targets { let dest = base_output_dir.join(name); std::fs::create_dir_all(&dest).into_diagnostic()?; - crate::spawn::tx3c::codegen(tii_path, &templates_dir, &dest)?; + crate::spawn::tx3c::codegen(tii_path, &templates_dir, &dest, &options)?; println!("Bindgen successful for '{}'", name); } } diff --git a/src/config/convention.rs b/src/config/convention.rs index 81d0ccb..3aed22d 100644 --- a/src/config/convention.rs +++ b/src/config/convention.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, path::PathBuf, }; @@ -317,6 +317,41 @@ impl std::fmt::Display for KnownCodegenPlugin { // `bindgen-v1alpha2` ref went with the now-removed legacy in-process codegen.) const CURRENT_CODEGEN_VERSION: &str = "codegen-v1beta0"; +/// Template options every built-in plugin gets unless the project says +/// otherwise. +/// +/// Plugin knowledge lives here, in trix's config layer: `tx3c codegen` is +/// generic — it forwards whatever options it is handed into the template +/// data and has no idea which plugin they came from. The one default today +/// is `ts-client`'s `standalone`, which drives the `package.json.hbs` / +/// `tsconfig.json.hbs` gate in the `codegen-v1beta0` templates: a generated +/// TS binding is a self-contained package by default, and a project +/// consuming the bindings from inside a host package opts out with +/// `options = { standalone = false }`. +impl KnownCodegenPlugin { + pub fn default_options(&self) -> BTreeMap { + match self { + KnownCodegenPlugin::TsClient => { + BTreeMap::from([("standalone".to_string(), serde_json::Value::Bool(true))]) + } + KnownCodegenPlugin::RustClient + | KnownCodegenPlugin::PythonClient + | KnownCodegenPlugin::GoClient => BTreeMap::new(), + } + } +} + +impl CodegenPlugin { + /// Defaults for this entry's plugin. A custom plugin is unknown to trix, + /// so it contributes none — its options are whatever the project writes. + pub fn default_options(&self) -> BTreeMap { + match self { + CodegenPlugin::Known(plugin) => plugin.default_options(), + CodegenPlugin::Custom(_) => BTreeMap::new(), + } + } +} + impl From for CodegenPluginConfig { fn from(plugin: KnownCodegenPlugin) -> Self { match plugin { @@ -372,6 +407,20 @@ impl CodegenConfig { self.plugin.name() } + /// The template options this entry sends to `tx3c codegen`: the + /// plugin's defaults with the project's `[[codegen]].options` merged on + /// top, key by key — the user always wins, including when they set a + /// default's key back to `false`. + /// + /// Ordered (`BTreeMap`) so the JSON trix forwards is stable across runs. + pub fn resolved_options(&self) -> BTreeMap { + let mut options = self.plugin.default_options(); + for (key, value) in self.options.iter().flatten() { + options.insert(key.clone(), value.clone()); + } + options + } + pub fn output_dir(&self) -> miette::Result { if let Some(explicit) = &self.output_dir { return Ok(explicit.clone()); @@ -495,6 +544,95 @@ mod tests { assert!(err.contains("ts-client") && err.contains("rust-client")); } + fn codegen_entry(toml_src: &str) -> CodegenConfig { + let config: RootConfig = toml::from_str(toml_src).unwrap(); + config.codegen.into_iter().next().expect("a [[codegen]]") + } + + const PROJECT_HEAD: &str = r#" + [protocol] + name = "demo" + version = "0.0.0" + main = "main.tx3" + + [ledger] + family = "cardano" + "#; + + #[test] + fn ts_client_defaults_to_standalone() { + let entry = codegen_entry(&format!( + "{PROJECT_HEAD}\n[[codegen]]\nplugin = \"ts-client\"\n" + )); + assert_eq!( + entry.resolved_options(), + BTreeMap::from([("standalone".to_string(), serde_json::Value::Bool(true))]) + ); + } + + #[test] + fn explicit_options_win_over_plugin_defaults() { + let entry = codegen_entry(&format!( + "{PROJECT_HEAD}\n[[codegen]]\nplugin = \"ts-client\"\noptions = {{ standalone = false }}\n" + )); + assert_eq!( + entry.resolved_options(), + BTreeMap::from([("standalone".to_string(), serde_json::Value::Bool(false))]) + ); + } + + #[test] + fn explicit_options_merge_alongside_defaults() { + let entry = codegen_entry(&format!( + "{PROJECT_HEAD}\n[[codegen]]\nplugin = \"ts-client\"\noptions = {{ package_name = \"acme\" }}\n" + )); + assert_eq!( + entry.resolved_options(), + BTreeMap::from([ + ("standalone".to_string(), serde_json::Value::Bool(true)), + ( + "package_name".to_string(), + serde_json::Value::String("acme".to_string()) + ), + ]) + ); + } + + #[test] + fn plugins_without_defaults_resolve_to_user_options_only() { + for plugin in ["rust-client", "python-client", "go-client"] { + let bare = codegen_entry(&format!( + "{PROJECT_HEAD}\n[[codegen]]\nplugin = \"{plugin}\"\n" + )); + assert!( + bare.resolved_options().is_empty(), + "{plugin} should contribute no defaults" + ); + + let with_options = codegen_entry(&format!( + "{PROJECT_HEAD}\n[[codegen]]\nplugin = \"{plugin}\"\noptions = {{ flavor = \"lean\" }}\n" + )); + assert_eq!( + with_options.resolved_options(), + BTreeMap::from([( + "flavor".to_string(), + serde_json::Value::String("lean".to_string()) + )]) + ); + } + } + + #[test] + fn custom_plugin_contributes_no_defaults() { + let entry = codegen_entry(&format!( + "{PROJECT_HEAD}\n[[codegen]]\nplugin = {{ repo = \"acme/lib\", path = \".\" }}\noptions = {{ standalone = true }}\n" + )); + assert_eq!( + entry.resolved_options(), + BTreeMap::from([("standalone".to_string(), serde_json::Value::Bool(true))]) + ); + } + #[test] fn registry_url_prefers_explicit() { let toml = r#" diff --git a/src/spawn/tx3c.rs b/src/spawn/tx3c.rs index 0d15b5d..5ec07a9 100644 --- a/src/spawn/tx3c.rs +++ b/src/spawn/tx3c.rs @@ -1,4 +1,4 @@ -use std::{path::Path, process::Command}; +use std::{collections::BTreeMap, path::Path, process::Command}; use miette::{Context as _, IntoDiagnostic as _, bail}; use serde::Deserialize; @@ -74,13 +74,33 @@ pub fn build_tii(source: &Path, output: &Path, config: &RootConfig) -> miette::R Ok(()) } -pub fn codegen(tii_path: &Path, templates: &Path, output: &Path) -> miette::Result<()> { +/// Render `templates` against `tii_path` into `output`. +/// +/// `options` is the template-options channel: the merged +/// `[[codegen]].options` (see [`crate::config::CodegenConfig::resolved_options`]), +/// forwarded as one JSON object under `--options` and surfacing in the +/// templates as `options.*`. An empty map is omitted entirely, so a project +/// that asks for nothing produces exactly the argv trix sent before the +/// channel existed. +pub fn codegen( + tii_path: &Path, + templates: &Path, + output: &Path, + options: &BTreeMap, +) -> miette::Result<()> { let mut cmd = tx3c()?; cmd.args(["codegen", "--tii", tii_path.to_str().unwrap()]); cmd.args(["--template", templates.to_str().unwrap()]); cmd.args(["--output", output.to_str().unwrap()]); + if !options.is_empty() { + let json = serde_json::to_string(options) + .into_diagnostic() + .context("serializing codegen options")?; + cmd.args(["--options", json.as_str()]); + } + let output = cmd .status() .into_diagnostic() diff --git a/tests/README.md b/tests/README.md index 78fe681..4c50063 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,7 +13,7 @@ The three layers, innermost first: | 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) | +| **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, registry pulls against the in-process OCI stub) | | **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) | There is deliberately no fourth layer. A trix test that needs a real `tx3c` @@ -51,6 +51,20 @@ 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. +## The OCI registry stub + +`tests/harness/oci_stub.rs` is an in-process OCI Distribution registry +serving the read side of an anonymous pull (`/v2/` probe, manifest, blobs) +on `127.0.0.1:`, with real sha256 digests because `oci_client` +verifies every blob against its descriptor. A test builds a +`StubProtocolImage`, serves its routes, and points the project at it with +`TestContext::set_registry_url`. It records every request path, so a test +can assert *which* repository path the client addressed — the way the +lowercase-addressing regression is locked. + +Pulls are trix's own code, not a helper binary, so registry tests belong to +the CLI layer and the suite stays offline. + ## Running ```bash @@ -71,7 +85,8 @@ Ask what the assertion is about: 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. + rule's variants here. A pull-path assertion goes here too, against the + OCI stub. - **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. @@ -79,6 +94,7 @@ Ask what the assertion is about: 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 +Fixtures live in `tests/fixtures/` (`use-stub/` — a cached interface, also +the layer payload the OCI stub serves; `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 index f90a90d..fb5624b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -17,3 +17,5 @@ mod init; mod interfaces; #[path = "cli/refs.rs"] mod refs; +#[path = "cli/registry.rs"] +mod registry; diff --git a/tests/cli/refs.rs b/tests/cli/refs.rs index b98fde2..5b16427 100644 --- a/tests/cli/refs.rs +++ b/tests/cli/refs.rs @@ -56,3 +56,30 @@ fn scoped_commands_reject_invalid_interfaces_table() { let result = ctx.run_trix(&["inspect", "tir", "--tx", "transfer"]); assert_failure_mentioning(&result, "alias-only"); } + +/// Regression (re-cut from PR #128): npm-style `@` version separators are +/// forbidden in protocol references. The canonical grammar puts the version +/// after `:` and `@` is not a valid identifier character, so `trix use` +/// rejects the reference at clap parse time — before any registry traffic, +/// which is why this belongs at the CLI layer and needs no stub. +#[test] +fn use_rejects_npm_style_at_version_separator() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let result = ctx.run_trix(&["use", "acme/widget@0.1.0"]); + assert!( + !result.success(), + "npm-style '@' ref should be rejected, got success:\n{}", + result.stdout + ); + let combined = result.combined(); + assert!( + combined.contains("invalid"), + "error should call the reference invalid:\n{combined}" + ); + assert!( + combined.contains("widget@0.1.0"), + "error should echo the offending reference:\n{combined}" + ); +} diff --git a/tests/cli/registry.rs b/tests/cli/registry.rs new file mode 100644 index 0000000..9adf07b --- /dev/null +++ b/tests/cli/registry.rs @@ -0,0 +1,152 @@ +//! `trix use` against the in-process OCI registry stub +//! (`tests/harness/oci_stub.rs`). +//! +//! No helper binary is involved — the pull path is trix's own code talking +//! the distribution protocol — so this stays in the CLI layer. The stub is +//! bound to `127.0.0.1:` and the project is pointed at it through +//! `[registry].url`, keeping the suite offline (`tests/README.md`). +//! +//! Both tests here are regressions re-cut from PR #128 onto the #129 +//! layout: they lock defects that were fixed once and must not return. + +use crate::harness::oci_stub::{OciRegistryStub, StubProtocolImage}; +use crate::harness::*; + +use std::path::PathBuf; +use trix::interfaces::oci::{ + LOGO_PNG_MEDIA_TYPE, MARKDOWN_MEDIA_TYPE, PNG_MAGIC, PROTOCOL_MEDIA_TYPE, TII_MEDIA_TYPE, +}; + +fn fixture_bytes(relative: &str) -> Vec { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(relative); + std::fs::read(&path).unwrap_or_else(|e| panic!("reading fixture {}: {e}", path.display())) +} + +/// Layer set mirroring what `trix publish` pushes: source + TII + README, +/// plus (optionally) the PNG logo layer PR #118 taught the pull side to +/// accept. +fn protocol_layers(with_logo: bool) -> Vec<(String, Vec)> { + let mut layers = vec![ + ( + PROTOCOL_MEDIA_TYPE.to_string(), + fixture_bytes("use-stub/acme/widget/0.1.0/main.tx3"), + ), + ( + TII_MEDIA_TYPE.to_string(), + fixture_bytes("use-stub/acme/widget/0.1.0/main.tii"), + ), + ( + MARKDOWN_MEDIA_TYPE.to_string(), + fixture_bytes("use-stub/acme/widget/0.1.0/README.md"), + ), + ]; + if with_logo { + let mut png = PNG_MAGIC.to_vec(); + png.extend_from_slice(b"stub-logo-payload"); + layers.push((LOGO_PNG_MEDIA_TYPE.to_string(), png)); + } + layers +} + +/// JSON in the shape of `trix::interfaces::oci::ImageMetadata`, as written +/// by `trix publish` into the OCI config blob. `version` must be concrete +/// so `trix use` can pin. +fn image_metadata(scope: &str, name: &str, version: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "scope": scope, + "published_date": 1700000000, + "repository_url": null, + "description": "stub protocol for regression tests", + "version": version, + }) +} + +/// Regression for PR #118: a published image carrying an `image/png` logo +/// layer must still pull. Before the fix, the accepted media types did not +/// include `image/png`, so the whole manifest was rejected for consumers as +/// soon as the publisher attached a logo. +#[test] +fn use_accepts_image_with_png_logo_layer() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let image = StubProtocolImage { + repo: "acme/widget".to_string(), + tag: "0.1.0".to_string(), + metadata: image_metadata("acme", "widget", "0.1.0"), + layers: protocol_layers(true), + }; + let stub = OciRegistryStub::serve(image.routes()); + ctx.set_registry_url(&stub.url()); + + let result = ctx.run_trix(&["use", "acme/widget:0.1.0"]); + assert_success(&result); + + // The pull must succeed and cache the protocol artifacts; the logo + // layer itself is not part of the interface cache. + ctx.assert_file_exists(".tx3/tii/acme/widget/0.1.0/main.tii"); + ctx.assert_file_exists(".tx3/tii/acme/widget/0.1.0/main.tx3"); + ctx.assert_file_exists(".tx3/tii/acme/widget/0.1.0/README.md"); + + let config = ctx.load_trix_config(); + assert!( + config.interfaces.get("widget").is_some(), + "interface should be pinned despite the logo layer" + ); +} + +/// Regression for PR #120: scopes mirror GitHub owners and may carry +/// capitals (`SundaeSwap-finance`), but OCI repository paths must be +/// lowercase. The registry is addressed with the lowercased path while +/// `trix.toml` and the cache keep the original case for identity. The stub +/// serves the image ONLY under the lowercase path, so any uppercase request +/// 404s and fails the test. +#[test] +fn use_addresses_registry_repo_path_in_lowercase() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + + let image = StubProtocolImage { + repo: "sundaeswap-finance/sundae-v3".to_string(), + tag: "0.1.0".to_string(), + metadata: image_metadata("SundaeSwap-finance", "sundae-v3", "0.1.0"), + layers: protocol_layers(false), + }; + let stub = OciRegistryStub::serve(image.routes()); + ctx.set_registry_url(&stub.url()); + + let result = ctx.run_trix(&["use", "SundaeSwap-finance/sundae-v3:0.1.0"]); + assert_success(&result); + + // Every repository-scoped request must have used the lowercase path. + let repo_requests: Vec = stub + .requested_paths() + .into_iter() + .filter(|line| line.contains("/manifests/") || line.contains("/blobs/")) + .collect(); + assert!( + !repo_requests.is_empty(), + "expected manifest/blob requests against the stub" + ); + for line in &repo_requests { + assert!( + line.contains("/v2/sundaeswap-finance/sundae-v3/"), + "registry request should use the lowercase repository path: {line}" + ); + } + + // Identity keeps the original case: the pinned ref and the cache path. + let config = ctx.load_trix_config(); + let entry = config + .interfaces + .get("sundae-v3") + .expect("interface 'sundae-v3' should be pinned in trix.toml"); + assert_eq!( + entry.reference.to_string(), + "SundaeSwap-finance/sundae-v3:0.1.0" + ); + ctx.assert_file_exists(".tx3/tii/SundaeSwap-finance/sundae-v3/0.1.0/main.tii"); +} diff --git a/tests/contract/codegen.rs b/tests/contract/codegen.rs index aeaddcd..a19d2a4 100644 --- a/tests/contract/codegen.rs +++ b/tests/contract/codegen.rs @@ -80,3 +80,106 @@ fn interface_bindings_use_cached_tii_not_a_recompile() { "only the project compiles; the interface is consumed from cache: {invocations:?}" ); } + +// --------------------------------------------------------------------- +// The `[[codegen]].options` channel +// +// What trix *sends*: the merged options ride as one JSON object under +// `--options`, and nothing else about the invocation changes. What the +// merge itself resolves to (plugin defaults, user override precedence) is +// a rule, unit-tested on `CodegenConfig::resolved_options` in +// `src/config/convention.rs`; these are the wiring probes. +// --------------------------------------------------------------------- + +/// The `--options` value of the first `tx3c codegen` invocation, parsed. +/// `None` when the flag was not passed at all. +fn forwarded_options(ctx: &TestContext) -> Option { + let invocations = ctx.tx3c_invocations(); + let codegen = invocations + .iter() + .find(|i| i[0] == "codegen") + .unwrap_or_else(|| panic!("codegen must delegate to tx3c codegen: {invocations:?}")); + codegen + .windows(2) + .find(|w| w[0] == "--options") + .map(|w| serde_json::from_str(&w[1]).expect("--options must carry a JSON object")) +} + +/// A project that asks for nothing gets exactly the argv trix sent before +/// the options channel existed: no `--options` at all. That keeps the +/// no-options path compatible with a `tx3c` that predates the flag. +#[test] +fn no_options_and_no_plugin_defaults_forwards_no_options_flag() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + ctx.declare_codegen(); + + assert_success(&ctx.run_trix_with_fake_tx3c(&["codegen"], &[])); + + assert_eq!( + forwarded_options(&ctx), + None, + "an empty option set must not put --options on the command line" + ); +} + +/// A plugin trix knows nothing about contributes no defaults, so the +/// project's own options reach `tx3c` unchanged — including nested values. +#[test] +fn plugin_without_defaults_forwards_user_options_unchanged() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + ctx.declare_codegen_with_options(Some( + r#"{ standalone = false, package_name = "acme", extra = { nested = 1 } }"#, + )); + + assert_success(&ctx.run_trix_with_fake_tx3c(&["codegen"], &[])); + + assert_eq!( + forwarded_options(&ctx), + Some(serde_json::json!({ + "standalone": false, + "package_name": "acme", + "extra": { "nested": 1 }, + })), + ); +} + +/// The built-in `ts-client` default reaching the wire: with no `options` +/// in `trix.toml`, `tx3c codegen` still receives `{"standalone":true}` — +/// the knob the `codegen-v1beta0` templates gate `package.json.hbs` and +/// `tsconfig.json.hbs` on. +#[test] +fn ts_client_default_injects_standalone_on_the_wire() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + ctx.stage_local_ts_client_templates(); + + assert_success(&ctx.run_trix_with_fake_tx3c(&["codegen", "--plugin", "ts-client"], &[])); + + assert_eq!( + forwarded_options(&ctx), + Some(serde_json::json!({ "standalone": true })), + ); +} + +/// …and the project overrides it: an explicit `standalone = false` is +/// forwarded verbatim rather than being masked by the plugin default. This +/// is the host-package consumption mode. +#[test] +fn ts_client_explicit_standalone_false_overrides_the_default() { + let ctx = TestContext::new(); + assert_success(&ctx.run_trix(&["init", "--yes"])); + ctx.stage_local_ts_client_templates(); + + let mut trix_toml = ctx.read_file("trix.toml"); + trix_toml.push_str("\n[[codegen]]\nplugin = \"ts-client\"\noptions = { standalone = false }\n"); + ctx.write_file("trix.toml", &trix_toml); + + assert_success(&ctx.run_trix_with_fake_tx3c(&["codegen"], &[])); + + assert_eq!( + forwarded_options(&ctx), + Some(serde_json::json!({ "standalone": false })), + ); +} diff --git a/tests/harness/fake_tx3c.rs b/tests/harness/fake_tx3c.rs index e2c1466..5403f91 100644 --- a/tests/harness/fake_tx3c.rs +++ b/tests/harness/fake_tx3c.rs @@ -104,9 +104,16 @@ fn main() { 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"); + // Optional: the template-options channel. Absent means `{}` — + // real tx3c defaults it, so trix omits the flag when it has + // nothing to say. + let options = flag_value(&args, "--options").unwrap_or_else(|| "{}".to_string()); let dest = std::path::Path::new(&output).join("bindings.txt"); - std::fs::write(&dest, format!("tii={}\ntemplate={}\n", tii, template)) - .expect("write bindings"); + std::fs::write( + &dest, + format!("tii={}\ntemplate={}\noptions={}\n", tii, template, options), + ) + .expect("write bindings"); } other => { eprintln!("fake tx3c: unknown subcommand {:?}", other); diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index 602ff91..2bca6bf 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -11,7 +11,9 @@ //! //! 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`]). +//! (`tests/contract/`, see [`fake_tx3c_path`]). Registry-facing tests get +//! the same treatment through [`oci_stub`], an in-process stub the project +//! is pointed at with [`TestContext::set_registry_url`]. #![allow(dead_code)] // shared by multiple test crates; each uses a subset @@ -21,6 +23,8 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; use tempfile::TempDir; +pub mod oci_stub; + use trix::commands::test::Test as TestConfig; use trix::config::RootConfig; use trix::devnet::Config as DevnetConfig; @@ -274,11 +278,53 @@ impl TestContext { /// Append a `[[codegen]]` entry pointing at the local template fixture. pub fn declare_codegen(&self) { + self.declare_codegen_with_options(None); + } + + /// Same, with an inline TOML value for `options` (e.g. + /// `Some("{ standalone = false }")`). `None` omits the key entirely. + pub fn declare_codegen_with_options(&self, options: Option<&str>) { 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() )); + if let Some(options) = options { + trix_toml.push_str(&format!("options = {options}\n")); + } + self.write_file("trix.toml", &trix_toml); + } + + /// Stage the codegen-template fixture where the built-in `ts-client` + /// plugin's own coordinates resolve to it, so a *known* plugin can be + /// exercised without leaving the machine. + /// + /// `codegen`'s template resolution treats `plugin.repo` as a local + /// template root whenever it names a directory, falling back to a + /// GitHub archive fetch otherwise — the seam `declare_codegen` already + /// uses. `ts-client` resolves to `repo = "tx3-lang/web-sdk"`, + /// `path = ".trix/client-lib"`, and `repo` is tested relative to the + /// process CWD, which is this project directory. Materializing that + /// path here keeps the run hermetic (`tests/README.md`: no network). + pub fn stage_local_ts_client_templates(&self) { + let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/codegen-template"); + let dest = self.path().join("tx3-lang/web-sdk/.trix/client-lib"); + fs::create_dir_all(&dest).expect("create local ts-client template root"); + for entry in fs::read_dir(&src).expect("read codegen-template fixture") { + let entry = entry.expect("fixture entry"); + let path = entry.path(); + fs::copy(&path, dest.join(path.file_name().unwrap())).expect("copy template file"); + } + } + + /// Point the project at `url` as its OCI registry, so pull paths hit an + /// in-process stub instead of the public one. + pub fn set_registry_url(&self, url: &str) { + let mut trix_toml = self.read_file("trix.toml"); + if !trix_toml.ends_with('\n') { + trix_toml.push('\n'); + } + trix_toml.push_str(&format!("\n[registry]\nurl = \"{url}\"\n")); self.write_file("trix.toml", &trix_toml); } } diff --git a/tests/harness/oci_stub.rs b/tests/harness/oci_stub.rs new file mode 100644 index 0000000..4d634a8 --- /dev/null +++ b/tests/harness/oci_stub.rs @@ -0,0 +1,238 @@ +//! Minimal in-process OCI Distribution registry stub for the offline suites. +//! +//! Serves a pre-built set of routes over plain HTTP on `127.0.0.1:`, +//! which is exactly what `oci::client_for` speaks when the configured +//! registry URL starts with `http://`. Only the read side of the pull flow +//! is implemented — the three requests an anonymous `oci_client::Client::pull` +//! makes: +//! +//! 1. `GET /v2/` → 200 with no `WWW-Authenticate` (anonymous ok) +//! 2. `GET /v2//manifests/` → OCI image manifest JSON +//! 3. `GET /v2//blobs/` → config / layer bytes +//! +//! Digests are real sha256 values because `oci_client` verifies every blob +//! against its descriptor and hashes the manifest body itself when no +//! `Docker-Content-Digest` header is present. +//! +//! Every request path is recorded so tests can assert *which* repository +//! path the client addressed (e.g. the lowercased form of an uppercase +//! scope). Unknown paths get a spec-shaped 404, which surfaces in `trix` as +//! a pull failure. + +#![allow(dead_code)] // only the CLI suite pulls; the contract suite does not + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use sha2::{Digest as _, Sha256}; + +pub const OCI_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json"; +pub const OCI_CONFIG_MEDIA_TYPE: &str = "application/vnd.oci.image.config.v1+json"; + +pub fn sha256_digest(bytes: &[u8]) -> String { + format!("sha256:{:x}", Sha256::digest(bytes)) +} + +pub struct StubRoute { + pub content_type: String, + pub body: Vec, +} + +/// A published protocol image as the stub serves it. `repo` is the exact +/// repository path the manifest and blobs are registered under — tests that +/// exercise case mapping serve only the lowercase path and let the recorded +/// requests prove what the client asked for. +pub struct StubProtocolImage { + pub repo: String, + pub tag: String, + /// JSON in the shape of `trix::interfaces::oci::ImageMetadata`. + pub metadata: serde_json::Value, + /// `(media_type, bytes)` per layer, in manifest order. + pub layers: Vec<(String, Vec)>, +} + +impl StubProtocolImage { + pub fn routes(&self) -> HashMap { + let mut routes = HashMap::new(); + + routes.insert( + "/v2/".to_string(), + StubRoute { + content_type: "application/json".to_string(), + body: b"{}".to_vec(), + }, + ); + + let config_bytes = serde_json::to_vec(&self.metadata).expect("serialize stub metadata"); + let config_digest = sha256_digest(&config_bytes); + + let mut layer_descriptors = Vec::new(); + for (media_type, bytes) in &self.layers { + let digest = sha256_digest(bytes); + layer_descriptors.push(serde_json::json!({ + "mediaType": media_type, + "digest": digest, + "size": bytes.len(), + })); + routes.insert( + format!("/v2/{}/blobs/{}", self.repo, digest), + StubRoute { + content_type: "application/octet-stream".to_string(), + body: bytes.clone(), + }, + ); + } + + routes.insert( + format!("/v2/{}/blobs/{}", self.repo, config_digest), + StubRoute { + content_type: OCI_CONFIG_MEDIA_TYPE.to_string(), + body: config_bytes.clone(), + }, + ); + + let manifest = serde_json::json!({ + "schemaVersion": 2, + "mediaType": OCI_MANIFEST_MEDIA_TYPE, + "config": { + "mediaType": OCI_CONFIG_MEDIA_TYPE, + "digest": config_digest, + "size": config_bytes.len(), + }, + "layers": layer_descriptors, + }); + routes.insert( + format!("/v2/{}/manifests/{}", self.repo, self.tag), + StubRoute { + content_type: OCI_MANIFEST_MEDIA_TYPE.to_string(), + body: serde_json::to_vec(&manifest).expect("serialize stub manifest"), + }, + ); + + routes + } +} + +pub struct OciRegistryStub { + addr: SocketAddr, + requests: Arc>>, + shutdown: Arc, + handle: Option>, +} + +impl OciRegistryStub { + pub fn serve(routes: HashMap) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub registry"); + let addr = listener.local_addr().expect("stub registry local addr"); + let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let shutdown = Arc::new(AtomicBool::new(false)); + + let routes = Arc::new(routes); + let thread_requests = Arc::clone(&requests); + let thread_shutdown = Arc::clone(&shutdown); + + let handle = std::thread::spawn(move || { + for stream in listener.incoming() { + if thread_shutdown.load(Ordering::SeqCst) { + break; + } + let Ok(stream) = stream else { continue }; + handle_connection(stream, &routes, &thread_requests); + } + }); + + Self { + addr, + requests, + shutdown, + handle: Some(handle), + } + } + + /// Registry URL in the form `trix.toml`'s `[registry].url` expects. + pub fn url(&self) -> String { + format!("http://{}", self.addr) + } + + /// Every request line seen so far, as `" "`. + pub fn requested_paths(&self) -> Vec { + self.requests.lock().expect("stub request log").clone() + } +} + +impl Drop for OciRegistryStub { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + // Wake the accept loop so the thread observes the flag. + let _ = TcpStream::connect(self.addr); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +fn handle_connection( + mut stream: TcpStream, + routes: &HashMap, + requests: &Mutex>, +) { + // Read the request head; the pull flow only issues body-less GETs. + let mut buf: Vec = Vec::new(); + let mut chunk = [0u8; 1024]; + while !head_complete(&buf) { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(_) => return, + } + if buf.len() > 64 * 1024 { + break; + } + } + + let head = String::from_utf8_lossy(&buf); + let mut request_line = head.lines().next().unwrap_or("").split_whitespace(); + let method = request_line.next().unwrap_or(""); + let path = request_line + .next() + .unwrap_or("") + .split('?') + .next() + .unwrap_or(""); + + requests + .lock() + .expect("stub request log") + .push(format!("{method} {path}")); + + let response = match routes.get(path) { + Some(route) => http_response(200, "OK", &route.content_type, &route.body), + None => http_response( + 404, + "Not Found", + "application/json", + br#"{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}"#, + ), + }; + + let _ = stream.write_all(&response); + let _ = stream.flush(); +} + +fn head_complete(buf: &[u8]) -> bool { + buf.windows(4).any(|w| w == b"\r\n\r\n") +} + +fn http_response(status: u16, reason: &str, content_type: &str, body: &[u8]) -> Vec { + let mut response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + response.extend_from_slice(body); + response +}