diff --git a/.github/workflows/binding-layer-android.yaml b/.github/workflows/binding-layer-android.yaml index 8115f30f5e..649413e489 100644 --- a/.github/workflows/binding-layer-android.yaml +++ b/.github/workflows/binding-layer-android.yaml @@ -1,12 +1,13 @@ name: Binding Layer for Android -# Builds the Binding Layer's Android libraries and Kotlin bindings on the -# commit the caller names, and packages them into the AAR. The build runs -# the builder of tools/workbench in the builder's own image. The package -# job compiles no Rust. The Gradle library takes the prebuilt output, -# compiles the Kotlin bindings, and bundles the AAR. The caller passes every -# artifact name from one run of `build-binding-layer artifact`, so the jobs -# of one run agree on the names whatever commit they build. +# Builds the Binding Layer's Android libraries and Kotlin Binding Sets on +# the commit the caller names, and packages them into the AAR. The build +# runs the builder of tools/workbench in the builder's own image. The +# package job compiles no Rust. The Gradle library takes the prebuilt +# output, compiles the Kotlin Binding Sets, and bundles the AAR. The +# caller passes every artifact name from one run of `build-binding-layer +# artifact`, so the jobs of one run agree on the names whatever commit +# they build. on: workflow_call: @@ -49,7 +50,7 @@ jobs: # own image, with the libraries, the pinned toolchain, every Android # target, bindgen-cli, and cargo-ndk already installed. The builder runs # its plan here with --in-image instead of starting a container. The - # output holds the ABI's two libraries and the Kotlin bindings. + # output holds the ABI's two libraries and the Kotlin Binding Sets. build: name: Build ${{ matrix.abi }} runs-on: ${{ inputs.runner }} @@ -158,8 +159,13 @@ jobs: with: gradle-version: '8.14.3' - - name: Bundle the AAR - run: gradle --project-dir bindings/android bundleReleaseAar + # One Gradle invocation bundles the AAR and runs the Kotlin + # wire-contract test of the proxy crate in one task graph. The test + # compiles against the Kotlin Binding Set that the AAR ships and + # asserts it against the crate's golden pins, which the Rust and + # Swift suites share. + - name: Bundle the AAR and run the Kotlin wire-contract test + run: gradle --project-dir bindings/android bundleReleaseAar testReleaseUnitTest - name: Verify the AAR carries both libraries for every ABI run: | diff --git a/.github/workflows/binding-layer-ios.yaml b/.github/workflows/binding-layer-ios.yaml index 43e7934d6f..af72efdb46 100644 --- a/.github/workflows/binding-layer-ios.yaml +++ b/.github/workflows/binding-layer-ios.yaml @@ -77,7 +77,12 @@ jobs: "CXXFLAGS_aarch64_apple_ios_sim=$flags" \ >> "$GITHUB_ENV" - - name: Build the Binding Layer + # The plan ends with the Swift wire-contract test of the proxy crate, + # which compiles against the Swift Binding Set the package ships and + # asserts it against the crate's golden pins, which the Rust and + # Kotlin suites share. The XCFrameworks hold iOS slices only, so the + # builder runs the test on an iOS simulator it picks. + - name: Build the Binding Layer and run the Swift wire-contract test run: | cargo run \ --manifest-path tools/workbench/Cargo.toml \ diff --git a/bindings/android/build.gradle.kts b/bindings/android/build.gradle.kts index b456a001f6..54f4a5cc35 100644 --- a/bindings/android/build.gradle.kts +++ b/bindings/android/build.gradle.kts @@ -9,10 +9,14 @@ val bindingNdkVersion = "28.2.13676358" val jvmTargetVersion = JavaVersion.VERSION_17 val jnaDependency = "net.java.dev.jna:jna:5.18.1@aar" val annotationDependency = "androidx.annotation:annotation:1.8.1" +val junitDependency = "junit:junit:4.13.2" group = "org.zingolabs" val zingolibRoot: File = rootDir.resolve("../..") +val proxyCrate: File = zingolibRoot.resolve("zingo-netutils/nym-proxy-ffi") +val contractTests: File = proxyCrate.resolve("contract-tests/kotlin") +val goldenDir: File = proxyCrate.resolve("test-data/golden") val builderOutput: Provider = layout.buildDirectory.dir("binding-layer") val prebuiltOutput: Provider = layout.dir(providers.gradleProperty("bindingLayerPrebuilt").map { file(it) }) @@ -59,6 +63,15 @@ android { java.srcDir(layerOutput.map { it.dir("kotlin") }) jniLibs.srcDir(layerOutput.map { it.dir("jniLibs") }) } + getByName("test") { + java.srcDir(contractTests) + } + } + + testOptions { + unitTests.all { + it.systemProperty("zingo.golden.dir", goldenDir.normalize().absolutePath) + } } } @@ -75,4 +88,5 @@ tasks.named("preBuild") { dependencies { api(jnaDependency) compileOnly(annotationDependency) + testImplementation(junitDependency) } diff --git a/bindings/swift/Package.swift b/bindings/swift/Package.swift index 59ccb6fc92..9d4ea579c8 100644 --- a/bindings/swift/Package.swift +++ b/bindings/swift/Package.swift @@ -21,7 +21,8 @@ let package = Package( .target( name: "ZingoBindings", dependencies: ["Zingolib", "ZingoNymProxyFFI"], - path: "\(builderOutput)/Sources/ZingoBindings" + path: "\(builderOutput)/Sources/ZingoBindings", + linkerSettings: [.linkedFramework("SystemConfiguration")] ), ] ) diff --git a/tools/workbench/src/bin/build-binding-layer.rs b/tools/workbench/src/bin/build-binding-layer.rs index 462f2dd6ce..e7712dee1d 100644 --- a/tools/workbench/src/bin/build-binding-layer.rs +++ b/tools/workbench/src/bin/build-binding-layer.rs @@ -100,6 +100,29 @@ const COMBINED_MODULEMAP: &str = "module.modulemap"; /// The text between the two module maps in the combined module map. const MODULEMAP_SEPARATOR: &str = "\n"; +const SIMCTL_LIST_AVAILABLE_DEVICES: [&str; 5] = + ["xcrun", "simctl", "list", "devices", "available"]; + +const XCODEBUILD_TEST: [&str; 2] = ["xcodebuild", "test"]; + +const SCHEME_FLAG: &str = "-scheme"; + +const DESTINATION_FLAG: &str = "-destination"; + +const SIMULATOR_DESTINATION: &str = "platform=iOS Simulator,id="; +const SIMULATOR_ARCH: &str = ",arch=arm64"; + +fn xcodebuild_test(scheme: &str, udid: &str) -> Vec { + XCODEBUILD_TEST + .into_iter() + .chain([SCHEME_FLAG, scheme, DESTINATION_FLAG]) + .map(String::from) + .chain(iter::once(format!( + "{SIMULATOR_DESTINATION}{udid}{SIMULATOR_ARCH}" + ))) + .collect() +} + /// The platform whose packaging to build. #[derive(Clone, Copy)] enum Platform { @@ -152,6 +175,10 @@ enum Step { command: Vec, to: path::PathBuf, }, + XcodeTest { + workdir: String, + scheme: String, + }, } /// Where `Run` steps execute. @@ -181,7 +208,6 @@ struct Roots { } impl Roots { - /// The roots of a plan whose `Run` steps execute on the host, where any output directory serves. fn on_host(root: path::PathBuf, out: path::PathBuf) -> Result> { refuse_an_output_directory_holding_the_root(&root, &out)?; Ok(Self { @@ -260,6 +286,19 @@ fn refuse_an_output_directory_holding_the_root( } } +fn refuse_an_ios_output_the_swift_package_does_not_read(roots: &Roots) -> Result<(), Vec> { + let consumed = roots.host_path(&binding_layer::swift_package_output_dir()); + if binding_layer::lexical(&roots.out_host) == binding_layer::lexical(&consumed) { + Ok(()) + } else { + Err(vec![format!( + "the Swift contract test compiles the package that reads {}, and this plan would write {}", + consumed.display(), + roots.out_host.display() + )]) + } +} + fn main() { let args: Vec = env::args().skip(PROGRAM_NAME_ARGUMENTS).collect(); match args.split_first() { @@ -332,6 +371,7 @@ fn build(args: &[String]) -> Result> { return Err(vec!["iOS packaging requires macOS with Xcode".to_string()]); } let roots = Roots::on_host(root, out)?; + refuse_an_ios_output_the_swift_package_does_not_read(&roots)?; execute(&Runner::Host, &ios_plan(&roots))?; roots } @@ -981,6 +1021,10 @@ fn ios_plan(roots: &Roots) -> Vec { &[], host(binding_layer::DESCRIPTOR_FILE), )], + vec![Step::XcodeTest { + workdir: roots.run_path(binding_layer::SWIFT_CONTRACT_TESTS_DIR), + scheme: binding_layer::swift_contract_tests_scheme(), + }], ]; fresh_plan( roots, @@ -1069,6 +1113,32 @@ fn execute_step(runner: &Runner, step: &Step) -> Result<(), Vec> { fs::write(to, format!("{descriptor}\n")) .map_err(|e| vec![format!("cannot write {}: {e}", to.display())]) } + Step::XcodeTest { workdir, scheme } => { + let listing = capture_command( + runner, + workdir, + &[], + &SIMCTL_LIST_AVAILABLE_DEVICES.map(String::from), + )?; + let simulator = binding_layer::ios_simulator(&listing, IOS_DEPLOYMENT_TARGET)?; + println!("testing on {} ({})", simulator.name, simulator.udid); + let command = xcodebuild_test(scheme, &simulator.udid); + let output = output_of(runner, workdir, &[], &command)?; + let log = String::from_utf8_lossy(&output.stdout); + print!("{log}"); + if !output.status.success() { + return Err(failure_of(&command, output.status)); + } + let executed = binding_layer::executed_tests(&log)?; + if executed == binding_layer::SWIFT_CONTRACT_TEST_COUNT { + Ok(()) + } else { + Err(vec![format!( + "xcodebuild executed {executed} tests, and the Swift contract suite holds {}", + binding_layer::SWIFT_CONTRACT_TEST_COUNT + )]) + } + } } } @@ -1137,23 +1207,43 @@ fn host_command(started: &Invocation) -> process::Command { command } -fn capture_command( +fn require_host_workdir(runner: &Runner, started: &Invocation) -> Result<(), Vec> { + if matches!(runner, Runner::Host) && !path::Path::new(&started.workdir).is_dir() { + return Err(vec![format!( + "the step's working directory {} is absent", + started.workdir + )]); + } + Ok(()) +} + +fn failure_of(command: &[String], status: process::ExitStatus) -> Vec { + vec![format!("`{}` failed ({status})", command.join(" "))] +} + +fn output_of( runner: &Runner, workdir: &str, env: &[(String, String)], command: &[String], -) -> Result> { +) -> Result> { let started = invocation(runner, workdir, env, command); - let output = host_command(&started) + require_host_workdir(runner, &started)?; + host_command(&started) .stderr(process::Stdio::inherit()) .output() - .map_err(|e| vec![format!("cannot run {}: {e}", started.program)])?; + .map_err(|e| vec![format!("cannot run {}: {e}", started.program)]) +} + +fn capture_command( + runner: &Runner, + workdir: &str, + env: &[(String, String)], + command: &[String], +) -> Result> { + let output = output_of(runner, workdir, env, command)?; if !output.status.success() { - return Err(vec![format!( - "`{}` failed ({})", - command.join(" "), - output.status - )]); + return Err(failure_of(command, output.status)); } String::from_utf8(output.stdout).map_err(|e| { vec![format!( @@ -1171,19 +1261,14 @@ fn run_command( command: &[String], ) -> Result<(), Vec> { let started = invocation(runner, workdir, env, command); - if matches!(runner, Runner::Host) && !path::Path::new(&started.workdir).is_dir() { - return Err(vec![format!( - "the step's working directory {} is absent", - started.workdir - )]); - } + require_host_workdir(runner, &started)?; let status = host_command(&started) .status() .map_err(|e| vec![format!("cannot run {}: {e}", started.program)])?; if status.success() { Ok(()) } else { - Err(vec![format!("`{}` failed ({status})", command.join(" "))]) + Err(failure_of(command, status)) } } @@ -1269,6 +1354,54 @@ mod tests { Ok(()) } + #[test] + fn the_ios_plan_ends_with_the_swift_contract_test() -> Result<(), Vec> { + let plan = ios_plan(&host_roots()?); + let Some(Step::XcodeTest { workdir, scheme }) = plan.last() else { + return Err(vec![ + "the iOS plan does not end with the Swift contract test".to_string(), + ]); + }; + assert_eq!( + workdir, + &format!("{HOST_ROOT}/{}", binding_layer::SWIFT_CONTRACT_TESTS_DIR) + ); + assert_eq!(scheme, &binding_layer::swift_contract_tests_scheme()); + Ok(()) + } + + #[test] + fn an_ios_output_the_swift_package_does_not_read_is_refused_before_the_plan( + ) -> Result<(), Vec> { + let diagnostic = workbench::refused(refuse_an_ios_output_the_swift_package_does_not_read( + &host_roots()?, + ))? + .concat(); + assert!(diagnostic.contains(OUTSIDE_OUT)); + assert!(diagnostic.contains(&binding_layer::swift_package_output_dir())); + let consumed = path::Path::new(HOST_ROOT).join(binding_layer::swift_package_output_dir()); + refuse_an_ios_output_the_swift_package_does_not_read(&Roots::on_host( + path::PathBuf::from(HOST_ROOT), + consumed, + )?)?; + let roundabout = path::Path::new(HOST_ROOT) + .join(binding_layer::SWIFT_CONTRACT_TESTS_DIR) + .join(binding_layer::SWIFT_PACKAGE_FROM_CONTRACT_TESTS) + .join(binding_layer::SWIFT_PACKAGE_OUTPUT_DIR); + refuse_an_ios_output_the_swift_package_does_not_read(&Roots::on_host( + path::PathBuf::from(HOST_ROOT), + roundabout, + )?) + } + + #[test] + fn the_xcodebuild_test_names_the_scheme_and_the_simulator_by_id() { + assert_eq!( + xcodebuild_test("Scheme-Package", "5555").join(" "), + "xcodebuild test -scheme Scheme-Package -destination platform=iOS Simulator,id=5555,arch=arm64" + ); + } + #[test] fn kotlin_plan_adds_no_target() -> Result<(), Vec> { assert!(position_of(&commands(&kotlin_plan(&host_roots()?)), TARGET_ADD).is_none()); @@ -1693,6 +1826,14 @@ mod tests { ))? .concat(); assert!(diagnostic.contains(&step_workdir) && !diagnostic.contains("cargo")); + let captured = workbench::refused(capture_command( + &Runner::Host, + &step_workdir, + &[], + &["cargo".to_string()], + ))? + .concat(); + assert_eq!(captured, diagnostic); Ok(()) } diff --git a/tools/workbench/src/binding_layer.rs b/tools/workbench/src/binding_layer.rs index 8618149843..558a090104 100644 --- a/tools/workbench/src/binding_layer.rs +++ b/tools/workbench/src/binding_layer.rs @@ -100,9 +100,130 @@ pub fn swift_sources_dir() -> String { } pub const SWIFT_PACKAGE_MANIFEST: &str = "bindings/swift/Package.swift"; +pub const SWIFT_LINKED_FRAMEWORK: &str = "SystemConfiguration"; pub const SWIFT_PACKAGE_OUTPUT_DIR: &str = "build"; +pub const SWIFT_CONTRACT_TESTS_DIR: &str = "zingo-netutils/nym-proxy-ffi/contract-tests"; + +pub const SWIFT_CONTRACT_TESTS_MANIFEST: &str = + "zingo-netutils/nym-proxy-ffi/contract-tests/Package.swift"; + +pub const SWIFT_CONTRACT_TESTS_PACKAGE: &str = "ZingoBindingsContractTests"; + +pub const SWIFT_PACKAGE_FROM_CONTRACT_TESTS: &str = "../../../bindings/swift"; + +const SWIFTPM_PACKAGE_SCHEME_SUFFIX: &str = "-Package"; + +pub fn swift_contract_tests_scheme() -> String { + format!("{SWIFT_CONTRACT_TESTS_PACKAGE}{SWIFTPM_PACKAGE_SCHEME_SUFFIX}") +} + +const SIMCTL_RUNTIME_PREFIX: &str = "-- "; + +const SIMCTL_RUNTIME_SUFFIX: &str = " --"; + +const SIMCTL_IOS_RUNTIME: &str = "iOS "; + +const SIMCTL_IPHONE: &str = "iPhone"; + +const SIMCTL_FIELD_OPEN: &str = " ("; + +const SIMCTL_FIELD_CLOSE: &str = ")"; + +const SIMCTL_BOOTED: &str = "Booted"; + +const SIMCTL_DEVICE_FIELDS: usize = 3; + +const VERSION_SEPARATOR: char = '.'; + +pub const SWIFT_CONTRACT_TEST_COUNT: usize = 3; + +const XCODEBUILD_EXECUTED: &str = "Executed "; + +#[derive(Debug, PartialEq, Eq)] +pub struct IosSimulator { + pub name: String, + pub udid: String, +} + +fn version(text: &str) -> Option> { + text.split(VERSION_SEPARATOR) + .map(|part| part.parse().ok()) + .collect() +} + +pub fn ios_simulator(listing: &str, minimum_runtime: &str) -> Result> { + let floor = version(minimum_runtime) + .ok_or_else(|| vec![format!("`{minimum_runtime}` is not a dotted version")])?; + let mut eligible = false; + let mut first = None; + for line in listing.lines() { + if let Some(runtime) = line + .strip_prefix(SIMCTL_RUNTIME_PREFIX) + .and_then(|rest| rest.strip_suffix(SIMCTL_RUNTIME_SUFFIX)) + { + eligible = runtime + .strip_prefix(SIMCTL_IOS_RUNTIME) + .and_then(version) + .is_some_and(|found| found >= floor); + continue; + } + if !eligible { + continue; + } + let fields: Vec<&str> = line + .trim() + .rsplitn(SIMCTL_DEVICE_FIELDS, SIMCTL_FIELD_OPEN) + .collect(); + let [state, udid, name] = fields[..] else { + continue; + }; + if !name.starts_with(SIMCTL_IPHONE) { + continue; + } + let simulator = IosSimulator { + name: name.to_string(), + udid: udid.trim_end_matches(SIMCTL_FIELD_CLOSE).to_string(), + }; + if state.trim_end_matches(SIMCTL_FIELD_CLOSE) == SIMCTL_BOOTED { + return Ok(simulator); + } + first.get_or_insert(simulator); + } + first.ok_or_else(|| { + vec![format!( + "no available {SIMCTL_IPHONE} simulator on an {SIMCTL_IOS_RUNTIME}{minimum_runtime} or newer runtime in:\n{listing}" + )] + }) +} + +pub fn executed_tests(log: &str) -> Result> { + log.lines() + .filter_map(|line| line.trim().strip_prefix(XCODEBUILD_EXECUTED)) + .filter_map(|rest| rest.split_whitespace().next()?.parse().ok()) + .next_back() + .ok_or_else(|| { + vec![format!( + "xcodebuild reported no `{XCODEBUILD_EXECUTED}N tests` line" + )] + }) +} + +pub fn lexical(path: &std::path::Path) -> std::path::PathBuf { + let mut resolved = std::path::PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + resolved.pop(); + } + std::path::Component::CurDir => {} + other => resolved.push(other), + } + } + resolved +} + /// The wallet's generated Swift source. pub const WALLET_SWIFT: &str = "zingo.swift"; @@ -711,9 +832,125 @@ mod tests { "let builderOutput = \"{SWIFT_PACKAGE_OUTPUT_DIR}\"" ))); assert!(manifest.contains(&format!("/{}\"", swift_sources_dir()))); + assert!(manifest.contains(&format!( + "linkerSettings: [.linkedFramework(\"{SWIFT_LINKED_FRAMEWORK}\")]" + ))); assert!(std::path::Path::new(SWIFT_PACKAGE_MANIFEST).ends_with("Package.swift")); } + fn resolved(base: &str, relative: &str) -> std::path::PathBuf { + lexical(&std::path::Path::new(base).join(relative)) + } + + #[test] + fn the_contract_tests_manifest_names_the_shipped_package() -> Result<(), Vec> { + let manifest = + include_str!("../../../zingo-netutils/nym-proxy-ffi/contract-tests/Package.swift"); + assert!(manifest.contains(&format!("name: \"{SWIFT_CONTRACT_TESTS_PACKAGE}\""))); + assert!(manifest.contains(&format!( + ".package(name: \"{SWIFT_PACKAGE}\", path: \"{SWIFT_PACKAGE_FROM_CONTRACT_TESTS}\")" + ))); + assert!(manifest.contains(&format!( + ".product(name: \"{SWIFT_PACKAGE}\", package: \"{SWIFT_PACKAGE}\")" + ))); + assert!(!manifest.contains("products:")); + assert!(std::path::Path::new(SWIFT_CONTRACT_TESTS_MANIFEST) + .starts_with(SWIFT_CONTRACT_TESTS_DIR)); + let shipped_package = crate::required( + std::path::Path::new(SWIFT_PACKAGE_MANIFEST).parent(), + "parent of the shipped manifest", + )?; + assert_eq!( + resolved(SWIFT_CONTRACT_TESTS_DIR, SWIFT_PACKAGE_FROM_CONTRACT_TESTS), + shipped_package + ); + Ok(()) + } + + const SIMCTL_LISTING: &str = "== Devices ==\n\ + -- watchOS 11.2 --\n\ + \x20 Apple Watch Series 10 (46mm) (1111) (Shutdown)\n\ + -- Unavailable: com.apple.CoreSimulator.SimRuntime.iOS-17-0 --\n\ + \x20 iPhone 15 (2222) (Shutdown) (unavailable, runtime profile not found)\n\ + -- iOS 15.5 --\n\ + \x20 iPhone 13 (6666) (Booted)\n\ + -- iOS 18.2 --\n\ + \x20 iPad Pro 13-inch (M4) (3333) (Shutdown)\n\ + \x20 iPhone 16 Pro (4444) (Shutdown)\n\ + \x20 iPhone 16 (5555) (Booted)\n"; + + const MINIMUM_RUNTIME: &str = "16.0"; + + #[test] + fn the_simulator_is_the_booted_iphone_on_a_runtime_at_or_above_the_minimum( + ) -> Result<(), Vec> { + assert_eq!( + ios_simulator(SIMCTL_LISTING, MINIMUM_RUNTIME)?, + IosSimulator { + name: "iPhone 16".to_string(), + udid: "5555".to_string(), + } + ); + Ok(()) + } + + #[test] + fn the_simulator_is_the_first_eligible_iphone_when_none_is_booted() -> Result<(), Vec> { + let listing = SIMCTL_LISTING.replace("(Booted)", "(Shutdown)"); + assert_eq!(ios_simulator(&listing, MINIMUM_RUNTIME)?.udid, "4444"); + Ok(()) + } + + #[test] + fn a_runtime_version_compares_numerically_not_textually() -> Result<(), Vec> { + let listing = SIMCTL_LISTING.replace("iOS 18.2", "iOS 100.0"); + assert_eq!(ios_simulator(&listing, "99.0")?.udid, "5555"); + Ok(()) + } + + #[test] + fn a_listing_without_an_iphone_on_an_eligible_runtime_is_refused_with_the_listing( + ) -> Result<(), Vec> { + let listing = "-- iOS 15.5 --\n iPhone 13 (6666) (Booted)\n"; + let diagnostic = crate::refused(ios_simulator(listing, MINIMUM_RUNTIME))?.concat(); + assert!(diagnostic.contains(listing) && diagnostic.contains(MINIMUM_RUNTIME)); + Ok(()) + } + + #[test] + fn the_executed_test_count_is_the_last_one_xcodebuild_reports() -> Result<(), Vec> { + let log = "Test Suite 'GoldenWireContractTests' passed\n\ + \t Executed 3 tests, with 0 failures (0 unexpected) in 0.012 (0.015) seconds\n\ + Test Suite 'All tests' passed\n\ + \t Executed 3 tests, with 0 failures (0 unexpected) in 0.013 (0.016) seconds\n"; + assert_eq!(executed_tests(log)?, 3); + assert!(crate::refused(executed_tests("** TEST SUCCEEDED **\n"))? + .concat() + .contains(XCODEBUILD_EXECUTED)); + Ok(()) + } + + #[test] + fn the_swift_contract_test_count_is_the_number_of_test_methods() { + let suite = include_str!( + "../../../zingo-netutils/nym-proxy-ffi/contract-tests/swift/GoldenWireContractTests.swift" + ); + assert_eq!( + suite.matches("func test").count(), + SWIFT_CONTRACT_TEST_COUNT + ); + } + + #[test] + fn lexical_resolution_drops_parent_and_current_components() { + assert_eq!( + lexical(std::path::Path::new( + "/host/zingolib/bindings/android/../swift/./build" + )), + std::path::Path::new("/host/zingolib/bindings/swift/build") + ); + } + #[test] fn the_wallet_crate_builds_no_bindgen() { let manifest = include_str!("../../../zingo-ffi/lib/Cargo.toml"); diff --git a/zingo-netutils/nym-proxy-ffi/contract-tests/.gitignore b/zingo-netutils/nym-proxy-ffi/contract-tests/.gitignore new file mode 100644 index 0000000000..2a354d39f5 --- /dev/null +++ b/zingo-netutils/nym-proxy-ffi/contract-tests/.gitignore @@ -0,0 +1,2 @@ +/.build +/.swiftpm diff --git a/zingo-netutils/nym-proxy-ffi/contract-tests/Package.swift b/zingo-netutils/nym-proxy-ffi/contract-tests/Package.swift new file mode 100644 index 0000000000..a481b1110f --- /dev/null +++ b/zingo-netutils/nym-proxy-ffi/contract-tests/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "ZingoBindingsContractTests", + platforms: [.iOS("16.0")], + dependencies: [ + .package(name: "ZingoBindings", path: "../../../bindings/swift"), + ], + targets: [ + .testTarget( + name: "GoldenWireContractTests", + dependencies: [.product(name: "ZingoBindings", package: "ZingoBindings")], + path: "swift" + ), + ] +) diff --git a/zingo-netutils/nym-proxy-ffi/contract-tests/README.md b/zingo-netutils/nym-proxy-ffi/contract-tests/README.md index 36c56929be..e8b956d15f 100644 --- a/zingo-netutils/nym-proxy-ffi/contract-tests/README.md +++ b/zingo-netutils/nym-proxy-ffi/contract-tests/README.md @@ -12,12 +12,19 @@ pinned bytes produces exactly the canonical value — against the same files: workspace. This suite also owns the canonical values; the Kotlin and Swift suites repeat them verbatim. - **Kotlin**: `kotlin/GoldenWireContractTest.kt`, compiled against the - generated bindings and JUnit 4. The app's Gradle unit suite adds this - directory as a test source and sets `zingo.golden.dir` to - `../test-data/golden`. Neither file has a copy under `android/`. -- **Swift**: `swift/GoldenWireContractTests.swift`, an XCTest that joins the - Mac-gated step-7 packaging (#2504). The golden directory resolves relative - to the file, or set `ZINGO_GOLDEN_DIR`. + Kotlin Binding Set and JUnit 4. The Gradle library under `bindings/android` + adds this directory as a test source and sets `zingo.golden.dir` to the + absolute path of `zingo-netutils/nym-proxy-ffi/test-data/golden`, and the + package job of + `.github/workflows/binding-layer-android.yaml` runs it with + `testReleaseUnitTest` against the Binding Set the AAR ships. +- **Swift**: `swift/GoldenWireContractTests.swift`, an XCTest in the + test-only package `Package.swift` beside it, which depends on the shipped + package under `bindings/swift`. The iOS plan of `build-binding-layer` ends + by running it on an iOS simulator, so + `.github/workflows/binding-layer-ios.yaml` runs it on every build. The + golden directory resolves relative to the file. The builder requires the + executed test count that xcodebuild reports to equal the suite's size. A single-language round trip proves only that one side's codec is self-inverse. Sharing the pins is what makes the guarantee two-sided at test diff --git a/zingo-netutils/nym-proxy-ffi/contract-tests/kotlin/GoldenWireContractTest.kt b/zingo-netutils/nym-proxy-ffi/contract-tests/kotlin/GoldenWireContractTest.kt index 8f82d33c71..c7ef2426e3 100644 --- a/zingo-netutils/nym-proxy-ffi/contract-tests/kotlin/GoldenWireContractTest.kt +++ b/zingo-netutils/nym-proxy-ffi/contract-tests/kotlin/GoldenWireContractTest.kt @@ -6,11 +6,11 @@ // values below are shared verbatim across the three suites; the pins under // test-data/golden/ are never-regenerate contract artifacts. // -// Execution: this file compiles against the generated bindings -// (uniffi.zingo_nym_proxy_ffi) and JUnit 4; zingo-mobile's test suite wires -// it in during step-4/step-3 packaging (#2513/#2505). Point -// -Dzingo.golden.dir at the crate's test-data/golden directory if the -// working directory is not the crate root. +// Execution: this file compiles against the Kotlin Binding Set +// (uniffi.zingo_nym_proxy_ffi) and JUnit 4. The Gradle library under +// bindings/android adds this directory as a test source and sets +// -Dzingo.golden.dir to the crate's test-data/golden directory, and +// binding-layer-android.yaml runs it with testReleaseUnitTest. package uniffi.zingo_nym_proxy_ffi.contract diff --git a/zingo-netutils/nym-proxy-ffi/contract-tests/swift/GoldenWireContractTests.swift b/zingo-netutils/nym-proxy-ffi/contract-tests/swift/GoldenWireContractTests.swift index 4b0ef0fe49..b332cb6e42 100644 --- a/zingo-netutils/nym-proxy-ffi/contract-tests/swift/GoldenWireContractTests.swift +++ b/zingo-netutils/nym-proxy-ffi/contract-tests/swift/GoldenWireContractTests.swift @@ -6,21 +6,22 @@ // values below are shared verbatim across the three suites; the pins under // test-data/golden/ are never-regenerate contract artifacts. // -// Execution: compiles against the generated Swift bindings in an XCTest -// bundle; the Mac-gated step-7 packaging (#2504) wires it in. The golden -// directory resolves relative to this file, or set ZINGO_GOLDEN_DIR. +// Execution: compiles against the Swift Binding Set through the test-only +// package beside this directory, which depends on the shipped package under +// bindings/swift. The iOS plan of build-binding-layer runs it on an iOS +// simulator as its last step. The golden directory resolves relative to +// this file. import Foundation import XCTest +@testable import ZingoBindings final class GoldenWireContractTests: XCTestCase { private func goldenDir() -> URL { - if let env = ProcessInfo.processInfo.environment["ZINGO_GOLDEN_DIR"] { - return URL(fileURLWithPath: env) - } return URL(fileURLWithPath: #filePath) .deletingLastPathComponent() // swift/ .deletingLastPathComponent() // contract-tests/ + .deletingLastPathComponent() // nym-proxy-ffi/ .appendingPathComponent("test-data/golden") } diff --git a/zingolib/CONTEXT.md b/zingolib/CONTEXT.md index f6422f55b1..aa6259b63b 100644 --- a/zingolib/CONTEXT.md +++ b/zingolib/CONTEXT.md @@ -323,11 +323,17 @@ **Reference Consumer** — A consumer whose charter is to prove zingolib's consumer surface sufficient, not to serve users: it holds no funds and makes no product promises, and its own code is confined to a typed one-to-one projection of the surface, a provisioning adapter, and a renderer — no wallet logic, no policy, no minted strings. It builds against the workspace at HEAD so that a surface change which breaks the consumer contract fails in the merging pull request's CI rather than weeks later in another repo, and it is for that reason the one consumer exempt from ADR 0024's rev-pinning rule, which disciplines external consumers. The first Reference Consumer is the planned `zingo-tauri` desktop app. Ratified 2026-08-03. See `zingo-adrs zingolib/0028`. -**Binding Layer** — The foreign-language projection of zingolib's consumer surface: the two UniFFI crates (the wallet crate and the Nym proxy crate), the Swift and Kotlin bindings generated from them, thin idiomatic wrappers over those bindings, and the platform packaging that ships them. It knows no UI framework, so any mobile consumer, zingo-mobile today or a future Edge integration, can link it unchanged. Ruled 2026-09-23, pending review. *Avoid*: FFI (ambiguous between one Rust crate and the whole layer), the mobile Rust. See `zingo-adrs zingolib/0054`. +**Binding Layer** — The foreign-language projection of zingolib's consumer surface: the two UniFFI crates (the wallet crate and the Nym proxy crate), the Swift and Kotlin Binding Sets generated from them, thin idiomatic wrappers over those Binding Sets, and the platform packaging that ships them. It knows no UI framework, so any mobile consumer, zingo-mobile today or a future Edge integration, can link it unchanged. Ruled 2026-09-23, pending review. *Avoid*: FFI (ambiguous between one Rust crate and the whole layer), the mobile Rust. See `zingo-adrs zingolib/0054`. **RN Bridge** — A React Native app's native modules and app shell, which marshal calls between JavaScript and the Binding Layer. Beyond marshalling it owns one thing, the app-serviced host of the Mixnet Session: it holds the Nym proxy's handle, watches for the proxy's death, and releases the handle. Each React Native consumer keeps its own RN Bridge in its own repository. Ruled 2026-09-23, pending review. *Avoid*: native modules (names the mechanism, not the role), FFI. See `zingo-adrs zingolib/0054`. -**Prebuilt Bundle** — A compiled native artifact of the Binding Layer, such as an Android `.so` set or an iOS XCFramework, that one repository builds and another consumes without rebuilding it. Ruled 2026-09-23, pending review. *Avoid*: bundle alone, and never "bundle" for grouping changes into one pull request. See `zingo-adrs zingolib/0054`. +**Prebuilt Bundle** — The compiled and packaged Binding Layer for one platform, which zingolib builds and publishes and a consumer fetches without rebuilding it. The Android Prebuilt Bundle is an AAR holding the four ABIs' native libraries and the compiled Kotlin Binding Sets; the iOS Prebuilt Bundle is the `ZingoBindings` Swift package holding the two XCFrameworks and the Swift Binding Sets. Ruled 2026-09-23 as a loose `.so` set or XCFramework; restated 2026-10-12 to the packaged form that #2833 publishes. *Avoid*: bundle alone, and never "bundle" for grouping changes into one pull request. See `zingo-adrs zingolib/0054`. + +**Binding Set** — The generated source code that projects one Binding Layer crate into one language: the Kotlin or the Swift output of UniFFI for the wallet crate or the Nym proxy crate. A Prebuilt Bundle carries one Binding Set per crate for its platform. Ruled 2026-10-12. *Avoid*: the bindings (ambiguous between one Binding Set and the whole Binding Layer). + +**Binding Layer Endpoint** — One function or method that the Binding Layer exports to foreign code, such as `get_version`. A consumer's RN Bridge adds one native method per Binding Layer Endpoint it uses. Ruled 2026-10-12. *Avoid*: binding alone, FFI call. + +**Bundle Pin** — A consumer's fixed choice of zingolib's Prebuilt Bundles: one zingolib commit, and the digest of that commit's bundle for each platform the consumer uses, kept in one lock file in the consumer's repository. The commit's tag in the registry never moves, and the pin holds the commit to its recorded digests. Ruled 2026-10-12. *Avoid*: the submodule pin (the pin zingo-mobile held before #2930), the lock alone. ---