From 664e4637fbbc673d5f7ed179ef85f51aaacf0c32 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Fri, 10 Apr 2026 10:46:39 -0300 Subject: [PATCH 1/2] feat(iii-worker): add cross-platform integration test suite and CI Add comprehensive integration test suite for iii-worker covering all worker runtime features (local, binary, OCI, config, lifecycle) with cross-platform parity on Linux and macOS. Test infrastructure: - Shared tests/common/ module with fixtures, CWD isolation, path helpers - Feature gates (integration-vm, integration-oci) for heavy tests - Refactored existing tests to use shared helpers Integration test coverage: - Local worker lifecycle (add/start/stop, path detection, workspace mgmt) - Binary worker (download, arch detection, permissions, checksum) - OCI worker (layer extraction, safety limits, multi-arch selection) - Worker manager (RuntimeAdapter dispatch, state serialization) - Project auto-detection (language, package manager, scripts) - Firmware resolution chain (local, embedded, download, error) - VM boot args and configuration (ungated + feature-gated) CI configuration: - worker-test-matrix: cargo-nextest on ubuntu-latest + macos-latest (every PR) - worker-test-vm-macos: VM feature-gated tests on macos-latest (every PR) - worker-test-oci-macos: OCI feature-gated tests on macos-latest (every PR) - worker-test-vm-linux: VM tests on self-hosted KVM runner (manual) - worker-test-oci-linux: OCI tests on self-hosted runner (manual) - JUnit XML reporting via cargo-nextest CI profile --- .config/nextest.toml | 5 + .github/workflows/ci.yml | 156 ++++ Cargo.lock | 6 +- crates/iii-worker/Cargo.toml | 3 + crates/iii-worker/src/cli/binary_download.rs | 4 +- crates/iii-worker/src/cli/local_worker.rs | 2 +- crates/iii-worker/src/cli/vm_boot.rs | 16 +- .../src/cli/worker_manager/libkrun.rs | 10 +- .../tests/binary_worker_integration.rs | 425 ++++++++++ crates/iii-worker/tests/common/assertions.rs | 21 + crates/iii-worker/tests/common/fixtures.rs | 83 ++ crates/iii-worker/tests/common/isolation.rs | 34 + crates/iii-worker/tests/common/mod.rs | 9 + .../tests/common_fixtures_integration.rs | 121 +++ .../tests/config_clear_integration.rs | 43 + .../tests/config_crossplatform_integration.rs | 150 ++++ .../tests/config_crud_integration.rs | 304 +++++++ .../tests/config_file_integration.rs | 751 ------------------ .../tests/config_force_integration.rs | 87 ++ .../tests/config_managed_integration.rs | 167 ++++ .../tests/config_path_type_integration.rs | 162 ++++ .../iii-worker/tests/firmware_integration.rs | 358 +++++++++ .../tests/local_worker_integration.rs | 450 +++++++++++ crates/iii-worker/tests/oci_gate_smoke.rs | 18 + .../tests/oci_worker_integration.rs | 398 ++++++++++ .../tests/project_detection_integration.rs | 328 ++++++++ .../iii-worker/tests/vm_args_integration.rs | 638 +++++++++++++++ crates/iii-worker/tests/vm_gate_smoke.rs | 18 + crates/iii-worker/tests/vm_integration.rs | 460 +++++++++++ crates/iii-worker/tests/worker_integration.rs | 79 +- .../tests/worker_manager_integration.rs | 337 ++++++++ 31 files changed, 4840 insertions(+), 803 deletions(-) create mode 100644 .config/nextest.toml create mode 100644 crates/iii-worker/tests/binary_worker_integration.rs create mode 100644 crates/iii-worker/tests/common/assertions.rs create mode 100644 crates/iii-worker/tests/common/fixtures.rs create mode 100644 crates/iii-worker/tests/common/isolation.rs create mode 100644 crates/iii-worker/tests/common/mod.rs create mode 100644 crates/iii-worker/tests/common_fixtures_integration.rs create mode 100644 crates/iii-worker/tests/config_clear_integration.rs create mode 100644 crates/iii-worker/tests/config_crossplatform_integration.rs create mode 100644 crates/iii-worker/tests/config_crud_integration.rs delete mode 100644 crates/iii-worker/tests/config_file_integration.rs create mode 100644 crates/iii-worker/tests/config_force_integration.rs create mode 100644 crates/iii-worker/tests/config_managed_integration.rs create mode 100644 crates/iii-worker/tests/config_path_type_integration.rs create mode 100644 crates/iii-worker/tests/firmware_integration.rs create mode 100644 crates/iii-worker/tests/local_worker_integration.rs create mode 100644 crates/iii-worker/tests/oci_gate_smoke.rs create mode 100644 crates/iii-worker/tests/oci_worker_integration.rs create mode 100644 crates/iii-worker/tests/project_detection_integration.rs create mode 100644 crates/iii-worker/tests/vm_args_integration.rs create mode 100644 crates/iii-worker/tests/vm_gate_smoke.rs create mode 100644 crates/iii-worker/tests/vm_integration.rs create mode 100644 crates/iii-worker/tests/worker_manager_integration.rs diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..6a109e171 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,5 @@ +[profile.ci] +fail-fast = false + +[profile.ci.junit] +path = "junit.xml" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be222791b..ac9b46a31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,6 +200,162 @@ jobs: - name: Build iii-worker for target run: cargo build -p iii-worker --target ${{ matrix.target }} + # ────────────────────────────────────────────────────────────── + # Worker Test Matrix (cross-platform integration tests) + # ────────────────────────────────────────────────────────────── + + worker-test-matrix: + name: Worker Tests - ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + - os: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + key: worker-test-${{ matrix.os }} + + - uses: taiki-e/install-action@cargo-nextest + + - name: Install system dependencies (Linux only) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libcap-ng-dev + + - name: Run iii-worker tests + run: cargo nextest run -p iii-worker --profile ci + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: junit-worker-test-${{ matrix.os }} + path: target/nextest/ci/junit.xml + retention-days: 7 + + # ────────────────────────────────────────────────────────────── + # Worker Feature-Gated Tests + # ────────────────────────────────────────────────────────────── + + worker-test-vm-linux: + name: Worker Tests (VM) - linux + if: github.event_name == 'workflow_dispatch' + runs-on: [self-hosted, linux, kvm] + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + key: worker-test-vm-linux + + - uses: taiki-e/install-action@cargo-nextest + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcap-ng-dev + + - name: Run VM feature-gated tests + run: cargo nextest run -p iii-worker --features integration-vm --profile ci + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: junit-worker-test-vm-linux + path: target/nextest/ci/junit.xml + retention-days: 7 + + worker-test-vm-macos: + name: Worker Tests (VM) - macos + runs-on: macos-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + key: worker-test-vm-macos + + - uses: taiki-e/install-action@cargo-nextest + + - name: Run VM feature-gated tests + run: cargo nextest run -p iii-worker --features integration-vm --profile ci + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: junit-worker-test-vm-macos + path: target/nextest/ci/junit.xml + retention-days: 7 + + worker-test-oci-linux: + name: Worker Tests (OCI) - linux + if: github.event_name == 'workflow_dispatch' + runs-on: [self-hosted, linux, oci] + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + key: worker-test-oci-linux + + - uses: taiki-e/install-action@cargo-nextest + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcap-ng-dev + + - name: Run OCI feature-gated tests + run: cargo nextest run -p iii-worker --features integration-oci --profile ci + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: junit-worker-test-oci-linux + path: target/nextest/ci/junit.xml + retention-days: 7 + + worker-test-oci-macos: + name: Worker Tests (OCI) - macos + runs-on: macos-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + key: worker-test-oci-macos + + - uses: taiki-e/install-action@cargo-nextest + + - name: Run OCI feature-gated tests + run: cargo nextest run -p iii-worker --features integration-oci --profile ci + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: junit-worker-test-oci-macos + path: target/nextest/ci/junit.xml + retention-days: 7 + # ────────────────────────────────────────────────────────────── # SDK Node # ────────────────────────────────────────────────────────────── diff --git a/Cargo.lock b/Cargo.lock index de3ca6f4b..311dde022 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2392,7 +2392,7 @@ dependencies = [ [[package]] name = "iii" -version = "0.11.0-next.5" +version = "0.11.0-next.6" dependencies = [ "anyhow", "async-trait", @@ -2467,7 +2467,7 @@ dependencies = [ [[package]] name = "iii-console" -version = "0.11.0-next.5" +version = "0.11.0-next.6" dependencies = [ "anyhow", "axum", @@ -2536,7 +2536,7 @@ dependencies = [ [[package]] name = "iii-sdk" -version = "0.11.0-next.5" +version = "0.11.0-next.6" dependencies = [ "async-trait", "ctor", diff --git a/crates/iii-worker/Cargo.toml b/crates/iii-worker/Cargo.toml index 924518676..5aa3f78ca 100644 --- a/crates/iii-worker/Cargo.toml +++ b/crates/iii-worker/Cargo.toml @@ -17,6 +17,9 @@ path = "src/main.rs" default = [] embed-libkrunfw = [] embed-init = ["iii-filesystem/embed-init"] +# Test-only features — gate heavy integration tests (never in default) +integration-vm = [] +integration-oci = [] [dependencies] iii-filesystem = { path = "../iii-filesystem" } diff --git a/crates/iii-worker/src/cli/binary_download.rs b/crates/iii-worker/src/cli/binary_download.rs index 7684798c8..a28494241 100644 --- a/crates/iii-worker/src/cli/binary_download.rs +++ b/crates/iii-worker/src/cli/binary_download.rs @@ -59,7 +59,7 @@ pub fn current_target() -> &'static str { } /// Returns the platform-appropriate archive extension. -fn archive_extension(target: &str) -> &'static str { +pub fn archive_extension(target: &str) -> &'static str { if target.contains("windows") { "zip" } else { @@ -107,7 +107,7 @@ pub fn checksum_download_url( /// Extracts a named binary from a tar.gz archive. /// /// Looks for an entry whose filename matches `binary_name` (ignoring directory prefixes). -fn extract_binary_from_targz(binary_name: &str, archive_bytes: &[u8]) -> Result, String> { +pub fn extract_binary_from_targz(binary_name: &str, archive_bytes: &[u8]) -> Result, String> { let decoder = flate2::read::GzDecoder::new(archive_bytes); let mut archive = tar::Archive::new(decoder); diff --git a/crates/iii-worker/src/cli/local_worker.rs b/crates/iii-worker/src/cli/local_worker.rs index 86a26519b..65aa10ab0 100644 --- a/crates/iii-worker/src/cli/local_worker.rs +++ b/crates/iii-worker/src/cli/local_worker.rs @@ -109,7 +109,7 @@ pub fn parse_manifest_resources(manifest_path: &Path) -> (u32, u32) { /// Remove workspace contents except installed dependency directories. /// This lets us re-copy source files without losing `npm install` artifacts. -fn clean_workspace_preserving_deps(workspace: &Path) { +pub fn clean_workspace_preserving_deps(workspace: &Path) { let preserve = ["node_modules", "target", ".venv", "__pycache__"]; if let Ok(entries) = std::fs::read_dir(workspace) { for entry in entries.flatten() { diff --git a/crates/iii-worker/src/cli/vm_boot.rs b/crates/iii-worker/src/cli/vm_boot.rs index 11cc719b6..341b196c9 100644 --- a/crates/iii-worker/src/cli/vm_boot.rs +++ b/crates/iii-worker/src/cli/vm_boot.rs @@ -60,7 +60,7 @@ pub struct VmBootArgs { } /// Compose the full libkrunfw file path from the resolved directory and platform filename. -fn resolve_krunfw_file_path() -> Option { +pub fn resolve_krunfw_file_path() -> Option { let dir = crate::cli::firmware::resolve::resolve_libkrunfw_dir()?; let filename = crate::cli::firmware::constants::libkrunfw_filename(); let file_path = dir.join(&filename); @@ -108,7 +108,7 @@ fn raise_fd_limit() { } } -fn shell_quote(s: &str) -> String { +pub fn shell_quote(s: &str) -> String { if s.chars().all(|c| { c.is_alphanumeric() || c == '-' || c == '_' || c == '/' || c == '.' || c == ':' || c == '=' }) { @@ -118,7 +118,7 @@ fn shell_quote(s: &str) -> String { } } -fn build_worker_cmd(exec: &str, args: &[String]) -> String { +pub fn build_worker_cmd(exec: &str, args: &[String]) -> String { if args.is_empty() { shell_quote(exec) } else { @@ -130,6 +130,13 @@ fn build_worker_cmd(exec: &str, args: &[String]) -> String { } } +/// Rewrite localhost/loopback URLs to use the given gateway IP. +/// Used by the VM boot process to redirect traffic into the guest network. +pub fn rewrite_localhost(s: &str, gateway_ip: &str) -> String { + s.replace("://localhost:", &format!("://{}:", gateway_ip)) + .replace("://127.0.0.1:", &format!("://{}:", gateway_ip)) +} + /// Boot the VM. Called from `main()` when `__vm-boot` is parsed. /// This function does NOT return -- `krun_start_enter` replaces the process. pub fn run(args: &VmBootArgs) -> ! { @@ -230,8 +237,7 @@ fn boot_vm(args: &VmBootArgs) -> Result { let gateway_ip = network.gateway_ipv4().to_string(); let rewrite_localhost = |s: &str| -> String { - s.replace("://localhost:", &format!("://{}:", gateway_ip)) - .replace("://127.0.0.1:", &format!("://{}:", gateway_ip)) + rewrite_localhost(s, &gateway_ip) }; let worker_cmd = rewrite_localhost(&worker_cmd); diff --git a/crates/iii-worker/src/cli/worker_manager/libkrun.rs b/crates/iii-worker/src/cli/worker_manager/libkrun.rs index 578fb53fd..4f7510420 100644 --- a/crates/iii-worker/src/cli/worker_manager/libkrun.rs +++ b/crates/iii-worker/src/cli/worker_manager/libkrun.rs @@ -202,7 +202,7 @@ impl LibkrunAdapter { Self } - fn worker_dir(name: &str) -> PathBuf { + pub fn worker_dir(name: &str) -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from("/tmp")) .join(".iii") @@ -210,7 +210,7 @@ impl LibkrunAdapter { .join(name) } - fn image_rootfs(image: &str) -> PathBuf { + pub fn image_rootfs(image: &str) -> PathBuf { let hash = { use sha2::Digest; let mut hasher = sha2::Sha256::new(); @@ -224,11 +224,11 @@ impl LibkrunAdapter { .join(hash) } - fn pid_file(name: &str) -> PathBuf { + pub fn pid_file(name: &str) -> PathBuf { Self::worker_dir(name).join("vm.pid") } - fn logs_dir(name: &str) -> PathBuf { + pub fn logs_dir(name: &str) -> PathBuf { Self::worker_dir(name).join("logs") } @@ -524,7 +524,7 @@ This image likely does not publish arm64. Rebuild/push a multi-arch image (linux } } -fn k8s_mem_to_mib(value: &str) -> Option { +pub fn k8s_mem_to_mib(value: &str) -> Option { if let Some(n) = value.strip_suffix("Mi") { Some(n.to_string()) } else if let Some(n) = value.strip_suffix("Gi") { diff --git a/crates/iii-worker/tests/binary_worker_integration.rs b/crates/iii-worker/tests/binary_worker_integration.rs new file mode 100644 index 000000000..39b750f06 --- /dev/null +++ b/crates/iii-worker/tests/binary_worker_integration.rs @@ -0,0 +1,425 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Integration tests for binary worker download functions. +//! Covers requirements BIN-01 through BIN-04. + +mod common; + +use iii_worker::cli::binary_download::{ + archive_extension, binary_download_url, binary_worker_path, binary_workers_dir, + checksum_download_url, current_target, download_and_install_binary, + extract_binary_from_targz, verify_sha256, +}; +use sha2::{Digest, Sha256}; +use std::sync::Mutex; + +/// Serializes tests that mutate environment variables (HOME). +/// Each integration test file runs in its own process, so only intra-file locking is needed. +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +// --------------------------------------------------------------------------- +// Helper: create a tar.gz archive in memory containing one file. +// --------------------------------------------------------------------------- + +fn make_targz(file_name: &str, content: &[u8]) -> Vec { + use flate2::write::GzEncoder; + use flate2::Compression; + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + { + let mut archive = tar::Builder::new(&mut encoder); + let mut header = tar::Header::new_gnu(); + header.set_path(file_name).unwrap(); + header.set_size(content.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + archive.append(&header, content).unwrap(); + archive.finish().unwrap(); + } + encoder.finish().unwrap() +} + +// =========================================================================== +// Group 1: Architecture detection (BIN-01, BIN-02) +// =========================================================================== + +/// BIN-01/BIN-02: current_target() returns a known platform triple. +#[test] +fn current_target_returns_known_triple() { + let target = current_target(); + assert!(!target.is_empty(), "current_target() must not be empty"); + assert_ne!(target, "unknown", "current_target() must not be 'unknown'"); + + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + assert_eq!(target, "aarch64-apple-darwin"); + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + assert_eq!(target, "x86_64-apple-darwin"); + } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + assert_eq!(target, "x86_64-unknown-linux-gnu"); + } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { + assert_eq!(target, "aarch64-unknown-linux-gnu"); + } +} + +/// BIN-02: current_target() contains OS and architecture substrings. +#[test] +fn current_target_contains_os_and_arch() { + let target = current_target(); + + // Must contain the OS identifier + let has_os = target.contains("apple-darwin") || target.contains("unknown-linux-gnu"); + assert!( + has_os, + "target '{}' should contain 'apple-darwin' or 'unknown-linux-gnu'", + target + ); + + // Must contain the architecture identifier + let has_arch = target.contains("x86_64") || target.contains("aarch64"); + assert!( + has_arch, + "target '{}' should contain 'x86_64' or 'aarch64'", + target + ); +} + +// =========================================================================== +// Group 2: URL construction (BIN-01) +// =========================================================================== + +/// BIN-01: binary_download_url produces correct URL for Linux target. +#[test] +fn binary_download_url_linux_format() { + let url = binary_download_url( + "iii-hq/workers", + "my-worker", + "1.0.0", + "my-worker", + "x86_64-unknown-linux-gnu", + ); + assert_eq!( + url, + "https://github.com/iii-hq/workers/releases/download/my-worker/v1.0.0/my-worker-x86_64-unknown-linux-gnu.tar.gz" + ); +} + +/// BIN-01: binary_download_url produces correct URL for macOS target. +#[test] +fn binary_download_url_macos_format() { + let url = binary_download_url( + "iii-hq/workers", + "my-worker", + "1.0.0", + "my-worker", + "aarch64-apple-darwin", + ); + assert!( + url.ends_with("aarch64-apple-darwin.tar.gz"), + "macOS URL should end with aarch64-apple-darwin.tar.gz, got: {}", + url + ); +} + +/// BIN-01: binary_download_url uses .zip extension for Windows targets. +#[test] +fn binary_download_url_windows_uses_zip() { + let url = binary_download_url( + "iii-hq/workers", + "my-worker", + "1.0.0", + "my-worker", + "x86_64-pc-windows-msvc", + ); + assert!( + url.ends_with(".zip"), + "Windows URL should end with .zip, got: {}", + url + ); +} + +/// BIN-01: checksum_download_url produces correct .sha256 URL. +#[test] +fn checksum_download_url_format() { + let url = checksum_download_url( + "iii-hq/workers", + "my-worker", + "1.0.0", + "my-worker", + "x86_64-unknown-linux-gnu", + ); + assert!( + url.ends_with(".sha256"), + "checksum URL should end with .sha256, got: {}", + url + ); + assert_eq!( + url, + "https://github.com/iii-hq/workers/releases/download/my-worker/v1.0.0/my-worker-x86_64-unknown-linux-gnu.sha256" + ); +} + +/// BIN-01: archive_extension returns "tar.gz" for non-Windows targets. +#[test] +fn archive_extension_non_windows() { + assert_eq!(archive_extension("x86_64-unknown-linux-gnu"), "tar.gz"); + assert_eq!(archive_extension("aarch64-apple-darwin"), "tar.gz"); +} + +/// BIN-01: archive_extension returns "zip" for Windows targets. +#[test] +fn archive_extension_windows() { + assert_eq!(archive_extension("x86_64-pc-windows-msvc"), "zip"); +} + +// =========================================================================== +// Group 3: Checksum verification (BIN-01 security, T-04-02 mitigation) +// =========================================================================== + +/// BIN-01: verify_sha256 accepts correct hash-only format. +#[test] +fn verify_sha256_valid_hash_only() { + let data = b"test data"; + let mut hasher = Sha256::new(); + hasher.update(data); + let hex_string = format!("{:x}", hasher.finalize()); + + assert!( + verify_sha256(data, &hex_string).is_ok(), + "verify_sha256 should accept correct hash-only format" + ); +} + +/// BIN-01: verify_sha256 accepts sha256sum format (hash + filename). +#[test] +fn verify_sha256_valid_sha256sum_format() { + let data = b"test data"; + let mut hasher = Sha256::new(); + hasher.update(data); + let hex_string = format!("{:x}", hasher.finalize()); + let checksum_content = format!("{} my-worker-aarch64-apple-darwin.tar.gz", hex_string); + + assert!( + verify_sha256(data, &checksum_content).is_ok(), + "verify_sha256 should accept sha256sum format" + ); +} + +/// BIN-01 security (T-04-02): verify_sha256 rejects mismatched hash. +#[test] +fn verify_sha256_rejects_mismatch() { + let data = b"test data"; + let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + let result = verify_sha256(data, wrong_hash); + assert!(result.is_err(), "expected mismatch error"); + assert!( + result.unwrap_err().contains("SHA256 mismatch"), + "error should mention SHA256 mismatch" + ); +} + +/// BIN-01 security (T-04-02): verify_sha256 rejects empty checksum content. +#[test] +fn verify_sha256_rejects_empty() { + let data = b"test data"; + let result = verify_sha256(data, ""); + assert!(result.is_err(), "expected error for empty checksum"); + assert!( + result.unwrap_err().contains("empty"), + "error should mention 'empty'" + ); +} + +// =========================================================================== +// Group 4: tar.gz extraction (BIN-01) +// =========================================================================== + +/// BIN-01: extract_binary_from_targz finds binary by filename. +#[test] +fn extract_binary_from_targz_finds_by_name() { + let archive = make_targz("my-worker", b"BINARY_PAYLOAD"); + let result = extract_binary_from_targz("my-worker", &archive); + assert!(result.is_ok(), "should find binary in archive"); + assert_eq!(result.unwrap(), b"BINARY_PAYLOAD"); +} + +/// BIN-01: extract_binary_from_targz finds binary in nested path (ignores directory prefix). +#[test] +fn extract_binary_from_targz_nested_path() { + use flate2::write::GzEncoder; + use flate2::Compression; + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + { + let mut archive = tar::Builder::new(&mut encoder); + let mut header = tar::Header::new_gnu(); + header.set_path("release/my-worker").unwrap(); + header.set_size(7); + header.set_mode(0o755); + header.set_cksum(); + archive.append(&header, b"PAYLOAD" as &[u8]).unwrap(); + archive.finish().unwrap(); + } + let data = encoder.finish().unwrap(); + + let result = extract_binary_from_targz("my-worker", &data); + assert!(result.is_ok(), "should find nested binary by filename"); + assert_eq!(result.unwrap(), b"PAYLOAD"); +} + +/// BIN-01: extract_binary_from_targz returns error when binary not found. +#[test] +fn extract_binary_from_targz_not_found() { + let archive = make_targz("other-binary", b"content"); + let result = extract_binary_from_targz("my-worker", &archive); + assert!(result.is_err(), "should fail when binary not in archive"); + assert!( + result.unwrap_err().contains("not found in archive"), + "error should mention 'not found in archive'" + ); +} + +// =========================================================================== +// Group 5: Path construction with HOME override (BIN-01, BIN-04) +// =========================================================================== + +/// BIN-01/BIN-04: binary_workers_dir uses HOME env var for path construction. +/// +/// Threat mitigation T-03-05: HOME is saved before override and restored +/// immediately after the function call, before any assertions. +#[test] +fn binary_workers_dir_uses_home() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + + let original_home = std::env::var("HOME").ok(); + // SAFETY: test-only, serialized via ENV_LOCK + unsafe { + std::env::set_var("HOME", tmp.path()); + } + + let result = binary_workers_dir(); + + // Restore HOME immediately + unsafe { + if let Some(ref home) = original_home { + std::env::set_var("HOME", home); + } + } + + assert!( + result.starts_with(tmp.path()), + "binary_workers_dir() should start with HOME ({}), got: {}", + tmp.path().display(), + result.display() + ); + let result_str = result.to_string_lossy(); + assert!( + result_str.contains(".iii"), + "path should contain '.iii', got: {}", + result_str + ); + assert!( + result.ends_with("workers"), + "path should end with 'workers', got: {}", + result.display() + ); +} + +/// BIN-01/BIN-04: binary_worker_path appends worker name to base directory. +#[test] +fn binary_worker_path_appends_name() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + + let original_home = std::env::var("HOME").ok(); + // SAFETY: test-only, serialized via ENV_LOCK + unsafe { + std::env::set_var("HOME", tmp.path()); + } + + let result = binary_worker_path("image-resize"); + + // Restore HOME immediately + unsafe { + if let Some(ref home) = original_home { + std::env::set_var("HOME", home); + } + } + + assert!( + result.ends_with("workers/image-resize"), + "path should end with 'workers/image-resize', got: {}", + result.display() + ); +} + +// =========================================================================== +// Group 6: Executable permissions (BIN-03) +// =========================================================================== + +/// BIN-03: Verify that executable permissions (0o755) can be set and read back. +#[cfg(unix)] +#[test] +fn executable_permissions_roundtrip() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let binary_path = tmp.path().join("test-binary"); + std::fs::write(&binary_path, b"fake binary content").unwrap(); + + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&binary_path, perms).unwrap(); + + let metadata = std::fs::metadata(&binary_path).unwrap(); + let mode = metadata.permissions().mode(); + assert_eq!( + mode & 0o755, + 0o755, + "expected 0o755 permission bits, got: 0o{:o}", + mode + ); +} + +// =========================================================================== +// Group 7: Early validation in download_and_install_binary (BIN-04, T-04-03) +// =========================================================================== + +/// BIN-04 (T-04-03): download_and_install_binary rejects empty worker name. +#[tokio::test] +async fn download_rejects_invalid_worker_name() { + let result = download_and_install_binary("", "owner/repo", "tag", "1.0", &[], false).await; + assert!( + result.is_err(), + "empty worker name should be rejected" + ); +} + +/// BIN-04 (T-04-03): download_and_install_binary rejects path traversal in worker name. +#[tokio::test] +async fn download_rejects_path_traversal_name() { + let result = + download_and_install_binary("../evil", "owner/repo", "tag", "1.0", &[], false).await; + assert!( + result.is_err(), + "path traversal in worker name should be rejected" + ); +} + +/// BIN-04: download_and_install_binary rejects unsupported target platform. +#[tokio::test] +async fn download_rejects_unsupported_target() { + // Use a supported_targets list that definitely excludes the current platform. + let unsupported = vec!["riscv64-unknown-linux-gnu".to_string()]; + let result = + download_and_install_binary("my-worker", "owner/repo", "tag", "1.0", &unsupported, false) + .await; + assert!(result.is_err(), "unsupported target should be rejected"); + assert!( + result.unwrap_err().contains("not supported"), + "error should mention 'not supported'" + ); +} diff --git a/crates/iii-worker/tests/common/assertions.rs b/crates/iii-worker/tests/common/assertions.rs new file mode 100644 index 000000000..a40d95172 --- /dev/null +++ b/crates/iii-worker/tests/common/assertions.rs @@ -0,0 +1,21 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +use std::path::Path; + +/// Assert two paths are equal after canonicalization. +/// On macOS, /tmp -> /private/tmp, so naive string/Path comparison fails. +pub fn assert_paths_eq(left: &Path, right: &Path) { + let left_canon = std::fs::canonicalize(left) + .unwrap_or_else(|e| panic!("cannot canonicalize left path {:?}: {}", left, e)); + let right_canon = std::fs::canonicalize(right) + .unwrap_or_else(|e| panic!("cannot canonicalize right path {:?}: {}", right, e)); + assert_eq!( + left_canon, right_canon, + "paths differ after canonicalization:\n left: {:?}\n right: {:?}", + left_canon, right_canon + ); +} diff --git a/crates/iii-worker/tests/common/fixtures.rs b/crates/iii-worker/tests/common/fixtures.rs new file mode 100644 index 000000000..68d03aa2a --- /dev/null +++ b/crates/iii-worker/tests/common/fixtures.rs @@ -0,0 +1,83 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Typed fixture builders for config YAML test data. +//! +//! Generates YAML in the exact format expected by `config_file.rs` line-based +//! parser, ensuring roundtrip compatibility. + +use std::path::{Path, PathBuf}; + +/// Describes a single worker entry in config.yaml. +#[derive(Clone, Debug)] +struct WorkerEntry { + name: String, + worker_path: Option, + image: Option, +} + +/// Typed builder for `config.yaml` test fixtures. +/// +/// Produces YAML that roundtrips through `config_file.rs` public API +/// (`worker_exists`, `get_worker_path`, `get_worker_image`, `list_worker_names`). +pub struct TestConfigBuilder { + workers: Vec, +} + +impl TestConfigBuilder { + /// Create a new builder with no workers. + pub fn new() -> Self { + Self { + workers: Vec::new(), + } + } + + /// Add a worker with an optional local path. + /// + /// - `with_worker("w", Some("/abs/path"))` -> worker with `worker_path:` + /// - `with_worker("w", None)` -> config-only worker (no path, no image) + pub fn with_worker(mut self, name: &str, worker_path: Option<&str>) -> Self { + self.workers.push(WorkerEntry { + name: name.to_string(), + worker_path: worker_path.map(|s| s.to_string()), + image: None, + }); + self + } + + /// Add an OCI worker with the given image reference. + pub fn with_oci_worker(mut self, name: &str, image: &str) -> Self { + self.workers.push(WorkerEntry { + name: name.to_string(), + worker_path: None, + image: Some(image.to_string()), + }); + self + } + + /// Write `config.yaml` to `dir` and return its path. + /// + /// The generated YAML matches the exact format produced by + /// `config_file.rs::append_to_content_with_fields`, ensuring full + /// roundtrip compatibility with the line-based parser. + pub fn build(&self, dir: &Path) -> PathBuf { + let mut yaml = String::from("workers:\n"); + + for entry in &self.workers { + yaml.push_str(&format!(" - name: {}\n", entry.name)); + if let Some(ref img) = entry.image { + yaml.push_str(&format!(" image: {}\n", img)); + } + if let Some(ref wp) = entry.worker_path { + yaml.push_str(&format!(" worker_path: {}\n", wp)); + } + } + + let path = dir.join("config.yaml"); + std::fs::write(&path, &yaml).unwrap(); + path + } +} diff --git a/crates/iii-worker/tests/common/isolation.rs b/crates/iii-worker/tests/common/isolation.rs new file mode 100644 index 000000000..d5e87b12d --- /dev/null +++ b/crates/iii-worker/tests/common/isolation.rs @@ -0,0 +1,34 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +use std::sync::Mutex; + +/// Process-global lock serializing tests that mutate CWD. +pub static CWD_LOCK: Mutex<()> = Mutex::new(()); + +/// Run an async closure in a temp dir, restoring cwd afterward. +pub async fn in_temp_dir_async(f: F) +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + let _guard = CWD_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let original = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir.path()).unwrap(); + f().await; + std::env::set_current_dir(original).unwrap(); +} + +/// Run a closure in a temp dir, restoring cwd afterward. +pub fn in_temp_dir(f: F) { + let _guard = CWD_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let original = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir.path()).unwrap(); + f(); + std::env::set_current_dir(original).unwrap(); +} diff --git a/crates/iii-worker/tests/common/mod.rs b/crates/iii-worker/tests/common/mod.rs new file mode 100644 index 000000000..48a5794f7 --- /dev/null +++ b/crates/iii-worker/tests/common/mod.rs @@ -0,0 +1,9 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +pub mod assertions; +pub mod fixtures; +pub mod isolation; diff --git a/crates/iii-worker/tests/common_fixtures_integration.rs b/crates/iii-worker/tests/common_fixtures_integration.rs new file mode 100644 index 000000000..a8f63bb70 --- /dev/null +++ b/crates/iii-worker/tests/common_fixtures_integration.rs @@ -0,0 +1,121 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Roundtrip tests validating that TestConfigBuilder generates YAML +//! compatible with config_file.rs public API. + +mod common; + +use common::fixtures::TestConfigBuilder; +use common::isolation::in_temp_dir; + +#[test] +fn roundtrip_no_workers() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new().build(&dir); + + let names = iii_worker::cli::config_file::list_worker_names(); + assert!(names.is_empty(), "expected no workers, got: {:?}", names); + }); +} + +#[test] +fn roundtrip_local_worker() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new() + .with_worker("my-worker", Some("/abs/path")) + .build(&dir); + + assert!( + iii_worker::cli::config_file::worker_exists("my-worker"), + "my-worker should exist" + ); + assert_eq!( + iii_worker::cli::config_file::get_worker_path("my-worker"), + Some("/abs/path".to_string()), + "worker_path should be /abs/path" + ); + }); +} + +#[test] +fn roundtrip_oci_worker() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new() + .with_oci_worker("pdfkit", "ghcr.io/iii-hq/pdfkit:1.0") + .build(&dir); + + assert!( + iii_worker::cli::config_file::worker_exists("pdfkit"), + "pdfkit should exist" + ); + assert_eq!( + iii_worker::cli::config_file::get_worker_image("pdfkit"), + Some("ghcr.io/iii-hq/pdfkit:1.0".to_string()), + "image should be ghcr.io/iii-hq/pdfkit:1.0" + ); + }); +} + +#[test] +fn roundtrip_mixed_workers() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new() + .with_worker("local-w", Some("/home/user/proj")) + .with_oci_worker("oci-w", "ghcr.io/org/img:2.0") + .with_worker("plain-w", None) + .build(&dir); + + assert!( + iii_worker::cli::config_file::worker_exists("local-w"), + "local-w should exist" + ); + assert!( + iii_worker::cli::config_file::worker_exists("oci-w"), + "oci-w should exist" + ); + assert!( + iii_worker::cli::config_file::worker_exists("plain-w"), + "plain-w should exist" + ); + + assert_eq!( + iii_worker::cli::config_file::get_worker_path("local-w"), + Some("/home/user/proj".to_string()), + ); + assert_eq!( + iii_worker::cli::config_file::get_worker_image("oci-w"), + Some("ghcr.io/org/img:2.0".to_string()), + ); + assert!( + iii_worker::cli::config_file::get_worker_path("plain-w").is_none(), + "plain-w should have no path" + ); + }); +} + +#[test] +fn roundtrip_worker_no_path() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new() + .with_worker("plain", None) + .build(&dir); + + assert!( + iii_worker::cli::config_file::worker_exists("plain"), + "plain should exist" + ); + assert!( + iii_worker::cli::config_file::get_worker_path("plain").is_none(), + "plain should have no path" + ); + }); +} diff --git a/crates/iii-worker/tests/config_clear_integration.rs b/crates/iii-worker/tests/config_clear_integration.rs new file mode 100644 index 000000000..d1221e10b --- /dev/null +++ b/crates/iii-worker/tests/config_clear_integration.rs @@ -0,0 +1,43 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Integration tests for handle_managed_clear operations. +//! +//! Covers: clear single worker, clear with invalid name, clear all workers. + +mod common; + +use common::isolation::in_temp_dir_async; + +#[tokio::test] +async fn handle_managed_clear_single_no_artifacts() { + in_temp_dir_async(|| async { + // Clear a worker that has no artifacts -- should succeed silently + let exit_code = iii_worker::cli::managed::handle_managed_clear(Some("pdfkit"), true); + assert_eq!(exit_code, 0); + }) + .await; +} + +#[tokio::test] +async fn handle_managed_clear_invalid_name() { + in_temp_dir_async(|| async { + // Clear with an invalid name (contains path traversal) + let exit_code = iii_worker::cli::managed::handle_managed_clear(Some("../etc"), true); + assert_eq!(exit_code, 1); + }) + .await; +} + +#[tokio::test] +async fn handle_managed_clear_all_no_artifacts() { + in_temp_dir_async(|| async { + // Clear all when nothing is installed -- should succeed + let exit_code = iii_worker::cli::managed::handle_managed_clear(None, true); + assert_eq!(exit_code, 0); + }) + .await; +} diff --git a/crates/iii-worker/tests/config_crossplatform_integration.rs b/crates/iii-worker/tests/config_crossplatform_integration.rs new file mode 100644 index 000000000..dd8d2f38f --- /dev/null +++ b/crates/iii-worker/tests/config_crossplatform_integration.rs @@ -0,0 +1,150 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Cross-platform config YAML tests. +//! +//! Verifies that config_file.rs produces identical output on Linux and macOS: +//! byte-identical round-trips, comment preservation, merge idempotency, and +//! path resolution through symlinks (macOS /tmp -> /private/tmp). + +mod common; + +use common::assertions::assert_paths_eq; +use common::isolation::in_temp_dir; +use std::path::Path; + +/// CFG-01: Round-trip byte identity. +/// +/// Writing the same worker twice via append_worker should produce +/// byte-identical config.yaml content on both runs. +#[test] +fn config_roundtrip_produces_identical_output() { + in_temp_dir(|| { + // First write via API + iii_worker::cli::config_file::append_worker("test-worker", Some("port: 3000")).unwrap(); + let first_content = std::fs::read_to_string("config.yaml").unwrap(); + + // Second write of same worker triggers merge path + iii_worker::cli::config_file::append_worker("test-worker", Some("port: 3000")).unwrap(); + let second_content = std::fs::read_to_string("config.yaml").unwrap(); + + assert_eq!( + first_content, second_content, + "round-trip should produce byte-identical output.\nFirst:\n{}\nSecond:\n{}", + first_content, second_content + ); + }); +} + +/// CFG-02: Comment preservation after append. +/// +/// Comments in multiple positions (file start, before workers, inside worker +/// blocks) must survive an append_worker call for a NEW worker. +#[test] +fn config_comments_preserved_after_append() { + in_temp_dir(|| { + // Write config.yaml with comments in multiple positions + let initial = "# Global comment at file start\nworkers:\n # Comment before first worker\n - name: existing-worker\n # Inline comment in worker block\n config:\n port: 3000\n"; + std::fs::write("config.yaml", initial).unwrap(); + + // Append a new worker + iii_worker::cli::config_file::append_worker("new-worker", None).unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + + // All original comments must survive + assert!( + content.contains("# Global comment at file start"), + "global comment lost. Content:\n{}", + content + ); + assert!( + content.contains("# Comment before first worker"), + "pre-worker comment lost. Content:\n{}", + content + ); + assert!( + content.contains("# Inline comment in worker block"), + "inline comment lost. Content:\n{}", + content + ); + // New worker must also be present + assert!( + content.contains("- name: new-worker"), + "new worker not appended. Content:\n{}", + content + ); + // Original worker must be preserved + assert!( + content.contains("- name: existing-worker"), + "existing worker lost. Content:\n{}", + content + ); + }); +} + +/// CFG-03: Merge idempotency. +/// +/// Applying the same merge twice (append_worker with identical config on an +/// existing worker) must produce byte-identical output: merge(A,B) twice = merge(A,B) once. +#[test] +fn config_merge_is_idempotent() { + in_temp_dir(|| { + // Setup: worker with existing config + std::fs::write( + "config.yaml", + "workers:\n - name: w\n config:\n port: 3000\n host: custom\n", + ) + .unwrap(); + + // First merge: add new keys, override port + iii_worker::cli::config_file::append_worker("w", Some("port: 8080\ndebug: true")).unwrap(); + let first_result = std::fs::read_to_string("config.yaml").unwrap(); + + // Second merge with identical inputs + iii_worker::cli::config_file::append_worker("w", Some("port: 8080\ndebug: true")).unwrap(); + let second_result = std::fs::read_to_string("config.yaml").unwrap(); + + assert_eq!( + first_result, second_result, + "merge(A,B) applied twice should produce byte-identical output.\nFirst:\n{}\nSecond:\n{}", + first_result, second_result + ); + }); +} + +/// CFG-04: Path resolution cross-platform. +/// +/// On macOS, /tmp is a symlink to /private/tmp. This test stores a worker +/// path via append_worker_with_path, reads it back, and asserts both the +/// stored path and the original resolve to the same canonical location. +#[test] +fn config_path_resolution_cross_platform() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + + // Store a path using the tempdir's actual path (which on macOS may be + // /tmp/... symlinked to /private/tmp/...) + let worker_path_str = dir.join("my-project").to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("my-project")).unwrap(); + + iii_worker::cli::config_file::append_worker_with_path( + "local-worker", + &worker_path_str, + None, + ) + .unwrap(); + + // Read the stored path back + let stored_path = iii_worker::cli::config_file::get_worker_path("local-worker") + .expect("worker path should be stored"); + + // The stored path is a literal string (config_file.rs does NOT canonicalize). + // Verify that canonicalizing both the stored path and the original path + // yields the same result. This is the key cross-platform check: + // on macOS /tmp/xxx -> /private/tmp/xxx + assert_paths_eq(Path::new(&stored_path), &dir.join("my-project")); + }); +} diff --git a/crates/iii-worker/tests/config_crud_integration.rs b/crates/iii-worker/tests/config_crud_integration.rs new file mode 100644 index 000000000..1a74ba9c3 --- /dev/null +++ b/crates/iii-worker/tests/config_crud_integration.rs @@ -0,0 +1,304 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! CRUD integration tests for config_file public API. +//! +//! Covers: append_worker, remove_worker, worker_exists, list_worker_names, +//! get_worker_image, get_worker_config_as_env, and builtin default operations. + +mod common; + +use common::fixtures::TestConfigBuilder; +use common::isolation::in_temp_dir; + +#[test] +fn append_worker_creates_file_from_scratch() { + in_temp_dir(|| { + iii_worker::cli::config_file::append_worker("my-worker", None).unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: my-worker")); + assert!(content.contains("workers:")); + }); +} + +#[test] +fn append_worker_with_config() { + in_temp_dir(|| { + iii_worker::cli::config_file::append_worker("my-worker", Some("port: 3000")).unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: my-worker")); + assert!(content.contains("config:")); + assert!(content.contains("port: 3000")); + }); +} + +#[test] +fn append_worker_appends_to_existing() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new() + .with_worker("existing", None) + .build(&dir); + iii_worker::cli::config_file::append_worker("new-worker", None).unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: existing")); + assert!(content.contains("- name: new-worker")); + }); +} + +#[test] +fn append_worker_with_image() { + in_temp_dir(|| { + iii_worker::cli::config_file::append_worker_with_image( + "pdfkit", + "ghcr.io/iii-hq/pdfkit:1.0", + Some("timeout: 30"), + ) + .unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: pdfkit")); + assert!(content.contains("image: ghcr.io/iii-hq/pdfkit:1.0")); + assert!(content.contains("timeout: 30")); + }); +} + +#[test] +fn append_worker_idempotent_merge() { + in_temp_dir(|| { + iii_worker::cli::config_file::append_worker("w", Some("port: 3000\nhost: custom")).unwrap(); + iii_worker::cli::config_file::append_worker("w", Some("port: 8080\ndebug: true")).unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: w")); + // User's host should be preserved + assert!(content.contains("host")); + // New key from registry should be added + assert!(content.contains("debug")); + }); +} + +#[test] +fn remove_worker_removes_and_preserves_others() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new() + .with_worker("keep", None) + .with_worker("remove-me", None) + .with_worker("also-keep", None) + .build(&dir); + iii_worker::cli::config_file::remove_worker("remove-me").unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(!content.contains("remove-me")); + assert!(content.contains("- name: keep")); + assert!(content.contains("- name: also-keep")); + }); +} + +#[test] +fn remove_worker_not_found_returns_error() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new() + .with_worker("only", None) + .build(&dir); + let result = iii_worker::cli::config_file::remove_worker("ghost"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + }); +} + +#[test] +fn remove_worker_no_file_returns_error() { + in_temp_dir(|| { + let result = iii_worker::cli::config_file::remove_worker("any"); + assert!(result.is_err()); + }); +} + +#[test] +fn worker_exists_true() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new() + .with_worker("present", None) + .build(&dir); + assert!(iii_worker::cli::config_file::worker_exists("present")); + }); +} + +#[test] +fn worker_exists_false() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new() + .with_worker("other", None) + .build(&dir); + assert!(!iii_worker::cli::config_file::worker_exists("absent")); + }); +} + +#[test] +fn worker_exists_no_file() { + in_temp_dir(|| { + assert!(!iii_worker::cli::config_file::worker_exists("any")); + }); +} + +#[test] +fn list_worker_names_empty() { + in_temp_dir(|| { + std::fs::write("config.yaml", "workers:\n").unwrap(); + let names = iii_worker::cli::config_file::list_worker_names(); + assert!(names.is_empty()); + }); +} + +#[test] +fn list_worker_names_multiple() { + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + TestConfigBuilder::new() + .with_worker("alpha", None) + .with_worker("beta", None) + .with_worker("gamma", None) + .build(&dir); + let names = iii_worker::cli::config_file::list_worker_names(); + assert_eq!(names, vec!["alpha", "beta", "gamma"]); + }); +} + +#[test] +fn list_worker_names_no_file() { + in_temp_dir(|| { + let names = iii_worker::cli::config_file::list_worker_names(); + assert!(names.is_empty()); + }); +} + +#[test] +fn get_worker_image_present() { + in_temp_dir(|| { + std::fs::write( + "config.yaml", + "workers:\n - name: pdfkit\n image: ghcr.io/iii-hq/pdfkit:1.0\n", + ) + .unwrap(); + let image = iii_worker::cli::config_file::get_worker_image("pdfkit"); + assert_eq!(image, Some("ghcr.io/iii-hq/pdfkit:1.0".to_string())); + }); +} + +#[test] +fn get_worker_image_absent() { + in_temp_dir(|| { + std::fs::write("config.yaml", "workers:\n - name: binary-worker\n").unwrap(); + let image = iii_worker::cli::config_file::get_worker_image("binary-worker"); + assert!(image.is_none()); + }); +} + +#[test] +fn get_worker_config_as_env_flat() { + in_temp_dir(|| { + std::fs::write( + "config.yaml", + "workers:\n - name: w\n config:\n api_key: secret123\n port: 8080\n", + ) + .unwrap(); + let env = iii_worker::cli::config_file::get_worker_config_as_env("w"); + assert_eq!(env.get("API_KEY").unwrap(), "secret123"); + assert!(env.contains_key("PORT")); + }); +} + +#[test] +fn get_worker_config_as_env_nested() { + in_temp_dir(|| { + std::fs::write( + "config.yaml", + "workers:\n - name: w\n config:\n database:\n host: db.local\n port: 5432\n", + ) + .unwrap(); + let env = iii_worker::cli::config_file::get_worker_config_as_env("w"); + assert_eq!(env.get("DATABASE_HOST").unwrap(), "db.local"); + assert!(env.contains_key("DATABASE_PORT")); + }); +} + +#[test] +fn get_worker_config_as_env_no_config() { + in_temp_dir(|| { + std::fs::write("config.yaml", "workers:\n - name: w\n").unwrap(); + let env = iii_worker::cli::config_file::get_worker_config_as_env("w"); + assert!(env.is_empty()); + }); +} + +#[test] +fn append_builtin_worker_creates_entry_with_defaults() { + in_temp_dir(|| { + let default_yaml = + iii_worker::cli::builtin_defaults::get_builtin_default("iii-http").unwrap(); + iii_worker::cli::config_file::append_worker("iii-http", Some(default_yaml)).unwrap(); + + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: iii-http")); + assert!(content.contains("config:")); + assert!(content.contains("port: 3111")); + assert!(content.contains("host: 127.0.0.1")); + assert!(content.contains("default_timeout: 30000")); + assert!(content.contains("concurrency_request_limit: 1024")); + assert!(content.contains("allowed_origins")); + }); +} + +#[test] +fn append_builtin_worker_merges_with_existing_user_config() { + in_temp_dir(|| { + std::fs::write( + "config.yaml", + "workers:\n - name: iii-http\n config:\n port: 9999\n custom_key: preserved\n", + ) + .unwrap(); + + let default_yaml = + iii_worker::cli::builtin_defaults::get_builtin_default("iii-http").unwrap(); + iii_worker::cli::config_file::append_worker("iii-http", Some(default_yaml)).unwrap(); + + let content = std::fs::read_to_string("config.yaml").unwrap(); + // User's port override is preserved + assert!(content.contains("9999")); + // User's custom key is preserved + assert!(content.contains("custom_key")); + // Builtin defaults for missing fields are filled in + assert!(content.contains("default_timeout")); + assert!(content.contains("concurrency_request_limit")); + }); +} + +#[test] +fn all_builtins_produce_valid_config_entries() { + in_temp_dir(|| { + for name in iii_worker::cli::builtin_defaults::BUILTIN_NAMES { + let _ = std::fs::remove_file("config.yaml"); + + let default_yaml = + iii_worker::cli::builtin_defaults::get_builtin_default(name).unwrap(); + iii_worker::cli::config_file::append_worker(name, Some(default_yaml)).unwrap(); + + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!( + content.contains(&format!("- name: {}", name)), + "config.yaml missing entry for '{}'", + name + ); + assert!( + content.contains("config:"), + "config.yaml missing config block for '{}'", + name + ); + } + }); +} diff --git a/crates/iii-worker/tests/config_file_integration.rs b/crates/iii-worker/tests/config_file_integration.rs deleted file mode 100644 index 352048c17..000000000 --- a/crates/iii-worker/tests/config_file_integration.rs +++ /dev/null @@ -1,751 +0,0 @@ -// Copyright Motia LLC and/or licensed to Motia LLC under one or more -// contributor license agreements. Licensed under the Elastic License 2.0; -// you may not use this file except in compliance with the Elastic License 2.0. -// This software is patent protected. We welcome discussions - reach out at support@motia.dev -// See LICENSE and PATENTS files for details. - -//! Integration tests for config_file public API. -//! -//! Each test changes the working directory to a temp dir so that the -//! relative `config.yaml` path used by the public API resolves there. - -use std::sync::Mutex; - -// Serialize tests that mutate the cwd to prevent races. -static CWD_LOCK: Mutex<()> = Mutex::new(()); - -/// Helper: run an async closure in a temp dir, restoring cwd afterward. -async fn in_temp_dir_async(f: F) -where - F: FnOnce() -> Fut, - Fut: std::future::Future, -{ - let _guard = CWD_LOCK.lock().unwrap(); - let dir = tempfile::tempdir().unwrap(); - let original = std::env::current_dir().unwrap(); - std::env::set_current_dir(dir.path()).unwrap(); - f().await; - std::env::set_current_dir(original).unwrap(); -} - -/// Helper: run a closure in a temp dir, restoring cwd afterward. -fn in_temp_dir(f: F) { - let _guard = CWD_LOCK.lock().unwrap(); - let dir = tempfile::tempdir().unwrap(); - let original = std::env::current_dir().unwrap(); - std::env::set_current_dir(dir.path()).unwrap(); - f(); - std::env::set_current_dir(original).unwrap(); -} - -#[test] -fn append_worker_creates_file_from_scratch() { - in_temp_dir(|| { - iii_worker::cli::config_file::append_worker("my-worker", None).unwrap(); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: my-worker")); - assert!(content.contains("workers:")); - }); -} - -#[test] -fn append_worker_with_config() { - in_temp_dir(|| { - iii_worker::cli::config_file::append_worker("my-worker", Some("port: 3000")).unwrap(); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: my-worker")); - assert!(content.contains("config:")); - assert!(content.contains("port: 3000")); - }); -} - -#[test] -fn append_worker_appends_to_existing() { - in_temp_dir(|| { - std::fs::write("config.yaml", "workers:\n - name: existing\n").unwrap(); - iii_worker::cli::config_file::append_worker("new-worker", None).unwrap(); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: existing")); - assert!(content.contains("- name: new-worker")); - }); -} - -#[test] -fn append_worker_with_image() { - in_temp_dir(|| { - iii_worker::cli::config_file::append_worker_with_image( - "pdfkit", - "ghcr.io/iii-hq/pdfkit:1.0", - Some("timeout: 30"), - ) - .unwrap(); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: pdfkit")); - assert!(content.contains("image: ghcr.io/iii-hq/pdfkit:1.0")); - assert!(content.contains("timeout: 30")); - }); -} - -#[test] -fn append_worker_idempotent_merge() { - in_temp_dir(|| { - iii_worker::cli::config_file::append_worker("w", Some("port: 3000\nhost: custom")).unwrap(); - iii_worker::cli::config_file::append_worker("w", Some("port: 8080\ndebug: true")).unwrap(); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: w")); - // User's host should be preserved - assert!(content.contains("host")); - // New key from registry should be added - assert!(content.contains("debug")); - }); -} - -#[test] -fn remove_worker_removes_and_preserves_others() { - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: keep\n - name: remove-me\n - name: also-keep\n", - ) - .unwrap(); - iii_worker::cli::config_file::remove_worker("remove-me").unwrap(); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(!content.contains("remove-me")); - assert!(content.contains("- name: keep")); - assert!(content.contains("- name: also-keep")); - }); -} - -#[test] -fn remove_worker_not_found_returns_error() { - in_temp_dir(|| { - std::fs::write("config.yaml", "workers:\n - name: only\n").unwrap(); - let result = iii_worker::cli::config_file::remove_worker("ghost"); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found")); - }); -} - -#[test] -fn remove_worker_no_file_returns_error() { - in_temp_dir(|| { - let result = iii_worker::cli::config_file::remove_worker("any"); - assert!(result.is_err()); - }); -} - -#[test] -fn worker_exists_true() { - in_temp_dir(|| { - std::fs::write("config.yaml", "workers:\n - name: present\n").unwrap(); - assert!(iii_worker::cli::config_file::worker_exists("present")); - }); -} - -#[test] -fn worker_exists_false() { - in_temp_dir(|| { - std::fs::write("config.yaml", "workers:\n - name: other\n").unwrap(); - assert!(!iii_worker::cli::config_file::worker_exists("absent")); - }); -} - -#[test] -fn worker_exists_no_file() { - in_temp_dir(|| { - assert!(!iii_worker::cli::config_file::worker_exists("any")); - }); -} - -#[test] -fn list_worker_names_empty() { - in_temp_dir(|| { - std::fs::write("config.yaml", "workers:\n").unwrap(); - let names = iii_worker::cli::config_file::list_worker_names(); - assert!(names.is_empty()); - }); -} - -#[test] -fn list_worker_names_multiple() { - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: alpha\n - name: beta\n - name: gamma\n", - ) - .unwrap(); - let names = iii_worker::cli::config_file::list_worker_names(); - assert_eq!(names, vec!["alpha", "beta", "gamma"]); - }); -} - -#[test] -fn list_worker_names_no_file() { - in_temp_dir(|| { - let names = iii_worker::cli::config_file::list_worker_names(); - assert!(names.is_empty()); - }); -} - -#[test] -fn get_worker_image_present() { - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: pdfkit\n image: ghcr.io/iii-hq/pdfkit:1.0\n", - ) - .unwrap(); - let image = iii_worker::cli::config_file::get_worker_image("pdfkit"); - assert_eq!(image, Some("ghcr.io/iii-hq/pdfkit:1.0".to_string())); - }); -} - -#[test] -fn get_worker_image_absent() { - in_temp_dir(|| { - std::fs::write("config.yaml", "workers:\n - name: binary-worker\n").unwrap(); - let image = iii_worker::cli::config_file::get_worker_image("binary-worker"); - assert!(image.is_none()); - }); -} - -#[test] -fn get_worker_config_as_env_flat() { - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: w\n config:\n api_key: secret123\n port: 8080\n", - ) - .unwrap(); - let env = iii_worker::cli::config_file::get_worker_config_as_env("w"); - assert_eq!(env.get("API_KEY").unwrap(), "secret123"); - assert!(env.contains_key("PORT")); - }); -} - -#[test] -fn get_worker_config_as_env_nested() { - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: w\n config:\n database:\n host: db.local\n port: 5432\n", - ) - .unwrap(); - let env = iii_worker::cli::config_file::get_worker_config_as_env("w"); - assert_eq!(env.get("DATABASE_HOST").unwrap(), "db.local"); - assert!(env.contains_key("DATABASE_PORT")); - }); -} - -#[test] -fn get_worker_config_as_env_no_config() { - in_temp_dir(|| { - std::fs::write("config.yaml", "workers:\n - name: w\n").unwrap(); - let env = iii_worker::cli::config_file::get_worker_config_as_env("w"); - assert!(env.is_empty()); - }); -} - -#[test] -fn append_builtin_worker_creates_entry_with_defaults() { - in_temp_dir(|| { - let default_yaml = - iii_worker::cli::builtin_defaults::get_builtin_default("iii-http").unwrap(); - iii_worker::cli::config_file::append_worker("iii-http", Some(default_yaml)).unwrap(); - - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: iii-http")); - assert!(content.contains("config:")); - assert!(content.contains("port: 3111")); - assert!(content.contains("host: 127.0.0.1")); - assert!(content.contains("default_timeout: 30000")); - assert!(content.contains("concurrency_request_limit: 1024")); - assert!(content.contains("allowed_origins")); - }); -} - -#[test] -fn append_builtin_worker_merges_with_existing_user_config() { - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: iii-http\n config:\n port: 9999\n custom_key: preserved\n", - ) - .unwrap(); - - let default_yaml = - iii_worker::cli::builtin_defaults::get_builtin_default("iii-http").unwrap(); - iii_worker::cli::config_file::append_worker("iii-http", Some(default_yaml)).unwrap(); - - let content = std::fs::read_to_string("config.yaml").unwrap(); - // User's port override is preserved - assert!(content.contains("9999")); - // User's custom key is preserved - assert!(content.contains("custom_key")); - // Builtin defaults for missing fields are filled in - assert!(content.contains("default_timeout")); - assert!(content.contains("concurrency_request_limit")); - }); -} - -#[test] -fn all_builtins_produce_valid_config_entries() { - in_temp_dir(|| { - for name in iii_worker::cli::builtin_defaults::BUILTIN_NAMES { - let _ = std::fs::remove_file("config.yaml"); - - let default_yaml = - iii_worker::cli::builtin_defaults::get_builtin_default(name).unwrap(); - iii_worker::cli::config_file::append_worker(name, Some(default_yaml)).unwrap(); - - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!( - content.contains(&format!("- name: {}", name)), - "config.yaml missing entry for '{}'", - name - ); - assert!( - content.contains("config:"), - "config.yaml missing config block for '{}'", - name - ); - } - }); -} - -// ────────────────────────────────────────────────────────────────────────────── -// handle_managed_add_many flow tests -// ────────────────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn add_many_builtin_workers() { - in_temp_dir_async(|| async { - let names = vec!["iii-http".to_string(), "iii-state".to_string()]; - let exit_code = iii_worker::cli::managed::handle_managed_add_many(&names).await; - assert_eq!(exit_code, 0, "all builtin workers should succeed"); - - assert!( - iii_worker::cli::config_file::worker_exists("iii-http"), - "iii-http should be in config.yaml" - ); - assert!( - iii_worker::cli::config_file::worker_exists("iii-state"), - "iii-state should be in config.yaml" - ); - }) - .await; -} - -#[tokio::test] -async fn add_many_with_invalid_worker_returns_nonzero() { - in_temp_dir_async(|| async { - let names = vec![ - "iii-http".to_string(), - "definitely-not-a-real-worker-xyz".to_string(), - ]; - let exit_code = iii_worker::cli::managed::handle_managed_add_many(&names).await; - assert_ne!(exit_code, 0, "should fail when any worker fails"); - - assert!( - iii_worker::cli::config_file::worker_exists("iii-http"), - "iii-http should still be in config.yaml despite other failure" - ); - }) - .await; -} - -// ────────────────────────────────────────────────────────────────────────────── -// handle_managed_add flow tests -// ────────────────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn handle_managed_add_builtin_creates_config() { - in_temp_dir_async(|| async { - let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, false, false) - .await; - assert_eq!( - exit_code, 0, - "expected success exit code for builtin worker" - ); - - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: iii-http")); - assert!(content.contains("config:")); - assert!(content.contains("port: 3111")); - assert!(content.contains("host: 127.0.0.1")); - assert!(content.contains("default_timeout: 30000")); - assert!(content.contains("concurrency_request_limit: 1024")); - assert!(content.contains("allowed_origins")); - }) - .await; -} - -#[tokio::test] -async fn handle_managed_add_builtin_merges_existing() { - in_temp_dir_async(|| async { - // Pre-populate with user overrides - std::fs::write( - "config.yaml", - "workers:\n - name: iii-http\n config:\n port: 9999\n custom_key: preserved\n", - ) - .unwrap(); - - let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, false, false) - .await; - assert_eq!(exit_code, 0, "expected success exit code for merge"); - - let content = std::fs::read_to_string("config.yaml").unwrap(); - // User override preserved - assert!(content.contains("9999")); - assert!(content.contains("custom_key")); - // Builtin defaults filled in - assert!(content.contains("default_timeout")); - assert!(content.contains("concurrency_request_limit")); - }) - .await; -} - -#[tokio::test] -async fn handle_managed_add_all_builtins_succeed() { - in_temp_dir_async(|| async { - for name in iii_worker::cli::builtin_defaults::BUILTIN_NAMES { - let _ = std::fs::remove_file("config.yaml"); - - let exit_code = - iii_worker::cli::managed::handle_managed_add(name, false, None, false, false).await; - assert_eq!(exit_code, 0, "expected success for builtin '{}'", name); - - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!( - content.contains(&format!("- name: {}", name)), - "config.yaml missing entry for '{}'", - name - ); - } - }) - .await; -} - -// ────────────────────────────────────────────────────────────────────────────── -// handle_managed_remove_many flow tests -// ────────────────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn remove_many_workers() { - in_temp_dir_async(|| async { - // Add two builtins first. - let names = vec!["iii-http".to_string(), "iii-state".to_string()]; - let exit_code = iii_worker::cli::managed::handle_managed_add_many(&names).await; - assert_eq!(exit_code, 0); - - // Remove both at once. - let exit_code = iii_worker::cli::managed::handle_managed_remove_many(&names).await; - assert_eq!(exit_code, 0, "all removals should succeed"); - - assert!( - !iii_worker::cli::config_file::worker_exists("iii-http"), - "iii-http should be removed" - ); - assert!( - !iii_worker::cli::config_file::worker_exists("iii-state"), - "iii-state should be removed" - ); - }) - .await; -} - -#[tokio::test] -async fn remove_many_with_missing_worker_returns_nonzero() { - in_temp_dir_async(|| async { - // Add one builtin. - let add_names = vec!["iii-http".to_string()]; - let exit_code = iii_worker::cli::managed::handle_managed_add_many(&add_names).await; - assert_eq!(exit_code, 0); - - // Remove existing + nonexistent. - let remove_names = vec!["iii-http".to_string(), "not-a-real-worker".to_string()]; - let exit_code = iii_worker::cli::managed::handle_managed_remove_many(&remove_names).await; - assert_ne!(exit_code, 0, "should fail when any removal fails"); - - // The valid one should still have been removed. - assert!( - !iii_worker::cli::config_file::worker_exists("iii-http"), - "iii-http should be removed despite other failure" - ); - }) - .await; -} - -// ────────────────────────────────────────────────────────────────────────────── -// handle_managed_add --force tests -// ────────────────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn handle_managed_add_force_builtin_re_adds() { - in_temp_dir_async(|| async { - // First add creates config - let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, false, false) - .await; - assert_eq!(exit_code, 0); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: iii-http")); - - // Force re-add succeeds (builtins have no artifacts to delete) - let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, true, false) - .await; - assert_eq!(exit_code, 0); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: iii-http")); - }) - .await; -} - -#[tokio::test] -async fn handle_managed_add_force_reset_config_clears_overrides() { - in_temp_dir_async(|| async { - // Pre-populate with user overrides - std::fs::write( - "config.yaml", - "workers:\n - name: iii-http\n config:\n port: 9999\n custom_key: preserved\n", - ) - .unwrap(); - - // Force with reset_config should clear user overrides and re-apply defaults - let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, true, true) - .await; - assert_eq!(exit_code, 0); - - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: iii-http")); - // Builtin defaults should be present - assert!(content.contains("default_timeout")); - // User override should NOT be preserved (reset_config wipes it) - assert!(!content.contains("custom_key")); - }) - .await; -} - -#[tokio::test] -async fn handle_managed_add_force_without_reset_preserves_config() { - in_temp_dir_async(|| async { - // Pre-populate with user overrides - std::fs::write( - "config.yaml", - "workers:\n - name: iii-http\n config:\n port: 9999\n custom_key: preserved\n", - ) - .unwrap(); - - // Force WITHOUT reset_config should preserve user overrides - let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, true, false) - .await; - assert_eq!(exit_code, 0); - - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: iii-http")); - // User override preserved via merge - assert!(content.contains("9999")); - assert!(content.contains("custom_key")); - }) - .await; -} - -// ────────────────────────────────────────────────────────────────────────────── -// handle_managed_clear tests -// ────────────────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn handle_managed_clear_single_no_artifacts() { - in_temp_dir_async(|| async { - // Clear a worker that has no artifacts — should succeed silently - let exit_code = iii_worker::cli::managed::handle_managed_clear(Some("pdfkit"), true); - assert_eq!(exit_code, 0); - }) - .await; -} - -#[tokio::test] -async fn handle_managed_clear_invalid_name() { - in_temp_dir_async(|| async { - // Clear with an invalid name (contains path traversal) - let exit_code = iii_worker::cli::managed::handle_managed_clear(Some("../etc"), true); - assert_eq!(exit_code, 1); - }) - .await; -} - -#[tokio::test] -async fn handle_managed_clear_all_no_artifacts() { - in_temp_dir_async(|| async { - // Clear all when nothing is installed — should succeed - let exit_code = iii_worker::cli::managed::handle_managed_clear(None, true); - assert_eq!(exit_code, 0); - }) - .await; -} - -// ────────────────────────────────────────────────────────────────────────────── -// append_worker_with_path tests -// ────────────────────────────────────────────────────────────────────────────── - -#[test] -fn append_worker_with_path_creates_entry() { - in_temp_dir(|| { - iii_worker::cli::config_file::append_worker_with_path("local-w", "/abs/path", None) - .unwrap(); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: local-w")); - assert!(content.contains("worker_path: /abs/path")); - }); -} - -#[test] -fn append_worker_with_path_with_config() { - in_temp_dir(|| { - iii_worker::cli::config_file::append_worker_with_path( - "local-w", - "/abs/path", - Some("timeout: 30"), - ) - .unwrap(); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!(content.contains("- name: local-w")); - assert!(content.contains("worker_path: /abs/path")); - assert!(content.contains("timeout: 30")); - }); -} - -#[test] -fn append_worker_with_path_merges_existing() { - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: local-w\n worker_path: /old\n config:\n custom_key: preserved\n", - ) - .unwrap(); - iii_worker::cli::config_file::append_worker_with_path( - "local-w", - "/new/path", - Some("new_key: added"), - ) - .unwrap(); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!( - content.contains("worker_path: /new/path"), - "path should be updated, got:\n{}", - content - ); - assert!( - content.contains("custom_key"), - "user config should be preserved, got:\n{}", - content - ); - assert!( - content.contains("new_key"), - "incoming config should be merged, got:\n{}", - content - ); - }); -} - -#[test] -fn append_worker_with_path_replaces_image_with_path() { - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: my-worker\n image: ghcr.io/org/w:1\n", - ) - .unwrap(); - iii_worker::cli::config_file::append_worker_with_path("my-worker", "/local/path", None) - .unwrap(); - let content = std::fs::read_to_string("config.yaml").unwrap(); - assert!( - content.contains("worker_path: /local/path"), - "should have worker_path, got:\n{}", - content - ); - assert!( - !content.contains("image:"), - "image should be removed, got:\n{}", - content - ); - }); -} - -// ────────────────────────────────────────────────────────────────────────────── -// get_worker_path tests -// ────────────────────────────────────────────────────────────────────────────── - -#[test] -fn get_worker_path_present() { - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: my-worker\n worker_path: /home/user/proj\n", - ) - .unwrap(); - let path = iii_worker::cli::config_file::get_worker_path("my-worker"); - assert_eq!(path, Some("/home/user/proj".to_string())); - }); -} - -#[test] -fn get_worker_path_absent() { - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: oci-worker\n image: ghcr.io/org/w:1\n", - ) - .unwrap(); - let path = iii_worker::cli::config_file::get_worker_path("oci-worker"); - assert!(path.is_none()); - }); -} - -// ────────────────────────────────────────────────────────────────────────────── -// resolve_worker_type tests -// ────────────────────────────────────────────────────────────────────────────── - -#[test] -fn resolve_worker_type_from_config_file() { - use iii_worker::cli::config_file::ResolvedWorkerType; - - in_temp_dir(|| { - std::fs::write( - "config.yaml", - "workers:\n - name: local-w\n worker_path: /home/user/proj\n - name: oci-w\n image: ghcr.io/org/w:1\n - name: config-w\n", - ) - .unwrap(); - - let local = iii_worker::cli::config_file::resolve_worker_type("local-w"); - assert!( - matches!(local, ResolvedWorkerType::Local { ref worker_path } if worker_path == "/home/user/proj"), - "expected Local, got {:?}", - local - ); - - let oci = iii_worker::cli::config_file::resolve_worker_type("oci-w"); - assert!( - matches!(oci, ResolvedWorkerType::Oci { ref image, .. } if image == "ghcr.io/org/w:1"), - "expected Oci, got {:?}", - oci - ); - - // config-only worker resolves to Config (or Binary if ~/.iii/workers/config-w exists, - // but that's unlikely in test environments) - let config = iii_worker::cli::config_file::resolve_worker_type("config-w"); - assert!( - matches!( - config, - ResolvedWorkerType::Config | ResolvedWorkerType::Binary { .. } - ), - "expected Config or Binary fallback, got {:?}", - config - ); - }); -} diff --git a/crates/iii-worker/tests/config_force_integration.rs b/crates/iii-worker/tests/config_force_integration.rs new file mode 100644 index 000000000..88f827b11 --- /dev/null +++ b/crates/iii-worker/tests/config_force_integration.rs @@ -0,0 +1,87 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Force/reset-config integration tests for handle_managed_add --force. +//! +//! Covers: force re-add, force with reset_config (clears overrides), +//! and force without reset_config (preserves overrides). + +mod common; + +use common::isolation::in_temp_dir_async; + +#[tokio::test] +async fn handle_managed_add_force_builtin_re_adds() { + in_temp_dir_async(|| async { + // First add creates config + let exit_code = + iii_worker::cli::managed::handle_managed_add("iii-http", false, None, false, false) + .await; + assert_eq!(exit_code, 0); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: iii-http")); + + // Force re-add succeeds (builtins have no artifacts to delete) + let exit_code = + iii_worker::cli::managed::handle_managed_add("iii-http", false, None, true, false) + .await; + assert_eq!(exit_code, 0); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: iii-http")); + }) + .await; +} + +#[tokio::test] +async fn handle_managed_add_force_reset_config_clears_overrides() { + in_temp_dir_async(|| async { + // Pre-populate with user overrides + std::fs::write( + "config.yaml", + "workers:\n - name: iii-http\n config:\n port: 9999\n custom_key: preserved\n", + ) + .unwrap(); + + // Force with reset_config should clear user overrides and re-apply defaults + let exit_code = + iii_worker::cli::managed::handle_managed_add("iii-http", false, None, true, true) + .await; + assert_eq!(exit_code, 0); + + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: iii-http")); + // Builtin defaults should be present + assert!(content.contains("default_timeout")); + // User override should NOT be preserved (reset_config wipes it) + assert!(!content.contains("custom_key")); + }) + .await; +} + +#[tokio::test] +async fn handle_managed_add_force_without_reset_preserves_config() { + in_temp_dir_async(|| async { + // Pre-populate with user overrides + std::fs::write( + "config.yaml", + "workers:\n - name: iii-http\n config:\n port: 9999\n custom_key: preserved\n", + ) + .unwrap(); + + // Force WITHOUT reset_config should preserve user overrides + let exit_code = + iii_worker::cli::managed::handle_managed_add("iii-http", false, None, true, false) + .await; + assert_eq!(exit_code, 0); + + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: iii-http")); + // User override preserved via merge + assert!(content.contains("9999")); + assert!(content.contains("custom_key")); + }) + .await; +} diff --git a/crates/iii-worker/tests/config_managed_integration.rs b/crates/iii-worker/tests/config_managed_integration.rs new file mode 100644 index 000000000..51ee19f71 --- /dev/null +++ b/crates/iii-worker/tests/config_managed_integration.rs @@ -0,0 +1,167 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Managed worker integration tests for handle_managed_add/remove flows. +//! +//! Covers: handle_managed_add_many, handle_managed_add (single builtin), +//! handle_managed_remove_many, and merge/default behaviors. + +mod common; + +use common::isolation::in_temp_dir_async; + +#[tokio::test] +async fn add_many_builtin_workers() { + in_temp_dir_async(|| async { + let names = vec!["iii-http".to_string(), "iii-state".to_string()]; + let exit_code = iii_worker::cli::managed::handle_managed_add_many(&names).await; + assert_eq!(exit_code, 0, "all builtin workers should succeed"); + + assert!( + iii_worker::cli::config_file::worker_exists("iii-http"), + "iii-http should be in config.yaml" + ); + assert!( + iii_worker::cli::config_file::worker_exists("iii-state"), + "iii-state should be in config.yaml" + ); + }) + .await; +} + +#[tokio::test] +async fn add_many_with_invalid_worker_returns_nonzero() { + in_temp_dir_async(|| async { + let names = vec![ + "iii-http".to_string(), + "definitely-not-a-real-worker-xyz".to_string(), + ]; + let exit_code = iii_worker::cli::managed::handle_managed_add_many(&names).await; + assert_ne!(exit_code, 0, "should fail when any worker fails"); + + assert!( + iii_worker::cli::config_file::worker_exists("iii-http"), + "iii-http should still be in config.yaml despite other failure" + ); + }) + .await; +} + +#[tokio::test] +async fn handle_managed_add_builtin_creates_config() { + in_temp_dir_async(|| async { + let exit_code = + iii_worker::cli::managed::handle_managed_add("iii-http", false, None, false, false) + .await; + assert_eq!( + exit_code, 0, + "expected success exit code for builtin worker" + ); + + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: iii-http")); + assert!(content.contains("config:")); + assert!(content.contains("port: 3111")); + assert!(content.contains("host: 127.0.0.1")); + assert!(content.contains("default_timeout: 30000")); + assert!(content.contains("concurrency_request_limit: 1024")); + assert!(content.contains("allowed_origins")); + }) + .await; +} + +#[tokio::test] +async fn handle_managed_add_builtin_merges_existing() { + in_temp_dir_async(|| async { + // Pre-populate with user overrides + std::fs::write( + "config.yaml", + "workers:\n - name: iii-http\n config:\n port: 9999\n custom_key: preserved\n", + ) + .unwrap(); + + let exit_code = + iii_worker::cli::managed::handle_managed_add("iii-http", false, None, false, false) + .await; + assert_eq!(exit_code, 0, "expected success exit code for merge"); + + let content = std::fs::read_to_string("config.yaml").unwrap(); + // User override preserved + assert!(content.contains("9999")); + assert!(content.contains("custom_key")); + // Builtin defaults filled in + assert!(content.contains("default_timeout")); + assert!(content.contains("concurrency_request_limit")); + }) + .await; +} + +#[tokio::test] +async fn handle_managed_add_all_builtins_succeed() { + in_temp_dir_async(|| async { + for name in iii_worker::cli::builtin_defaults::BUILTIN_NAMES { + let _ = std::fs::remove_file("config.yaml"); + + let exit_code = + iii_worker::cli::managed::handle_managed_add(name, false, None, false, false).await; + assert_eq!(exit_code, 0, "expected success for builtin '{}'", name); + + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!( + content.contains(&format!("- name: {}", name)), + "config.yaml missing entry for '{}'", + name + ); + } + }) + .await; +} + +#[tokio::test] +async fn remove_many_workers() { + in_temp_dir_async(|| async { + // Add two builtins first. + let names = vec!["iii-http".to_string(), "iii-state".to_string()]; + let exit_code = iii_worker::cli::managed::handle_managed_add_many(&names).await; + assert_eq!(exit_code, 0); + + // Remove both at once. + let exit_code = iii_worker::cli::managed::handle_managed_remove_many(&names).await; + assert_eq!(exit_code, 0, "all removals should succeed"); + + assert!( + !iii_worker::cli::config_file::worker_exists("iii-http"), + "iii-http should be removed" + ); + assert!( + !iii_worker::cli::config_file::worker_exists("iii-state"), + "iii-state should be removed" + ); + }) + .await; +} + +#[tokio::test] +async fn remove_many_with_missing_worker_returns_nonzero() { + in_temp_dir_async(|| async { + // Add one builtin. + let add_names = vec!["iii-http".to_string()]; + let exit_code = iii_worker::cli::managed::handle_managed_add_many(&add_names).await; + assert_eq!(exit_code, 0); + + // Remove existing + nonexistent. + let remove_names = vec!["iii-http".to_string(), "not-a-real-worker".to_string()]; + let exit_code = iii_worker::cli::managed::handle_managed_remove_many(&remove_names).await; + assert_ne!(exit_code, 0, "should fail when any removal fails"); + + // The valid one should still have been removed. + assert!( + !iii_worker::cli::config_file::worker_exists("iii-http"), + "iii-http should be removed despite other failure" + ); + }) + .await; +} diff --git a/crates/iii-worker/tests/config_path_type_integration.rs b/crates/iii-worker/tests/config_path_type_integration.rs new file mode 100644 index 000000000..d87d1964e --- /dev/null +++ b/crates/iii-worker/tests/config_path_type_integration.rs @@ -0,0 +1,162 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Integration tests for worker path and type resolution. +//! +//! Covers: append_worker_with_path, get_worker_path, resolve_worker_type. + +mod common; + +use common::isolation::in_temp_dir; + +#[test] +fn append_worker_with_path_creates_entry() { + in_temp_dir(|| { + iii_worker::cli::config_file::append_worker_with_path("local-w", "/abs/path", None) + .unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: local-w")); + assert!(content.contains("worker_path: /abs/path")); + }); +} + +#[test] +fn append_worker_with_path_with_config() { + in_temp_dir(|| { + iii_worker::cli::config_file::append_worker_with_path( + "local-w", + "/abs/path", + Some("timeout: 30"), + ) + .unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!(content.contains("- name: local-w")); + assert!(content.contains("worker_path: /abs/path")); + assert!(content.contains("timeout: 30")); + }); +} + +#[test] +fn append_worker_with_path_merges_existing() { + in_temp_dir(|| { + std::fs::write( + "config.yaml", + "workers:\n - name: local-w\n worker_path: /old\n config:\n custom_key: preserved\n", + ) + .unwrap(); + iii_worker::cli::config_file::append_worker_with_path( + "local-w", + "/new/path", + Some("new_key: added"), + ) + .unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!( + content.contains("worker_path: /new/path"), + "path should be updated, got:\n{}", + content + ); + assert!( + content.contains("custom_key"), + "user config should be preserved, got:\n{}", + content + ); + assert!( + content.contains("new_key"), + "incoming config should be merged, got:\n{}", + content + ); + }); +} + +#[test] +fn append_worker_with_path_replaces_image_with_path() { + in_temp_dir(|| { + std::fs::write( + "config.yaml", + "workers:\n - name: my-worker\n image: ghcr.io/org/w:1\n", + ) + .unwrap(); + iii_worker::cli::config_file::append_worker_with_path("my-worker", "/local/path", None) + .unwrap(); + let content = std::fs::read_to_string("config.yaml").unwrap(); + assert!( + content.contains("worker_path: /local/path"), + "should have worker_path, got:\n{}", + content + ); + assert!( + !content.contains("image:"), + "image should be removed, got:\n{}", + content + ); + }); +} + +#[test] +fn get_worker_path_present() { + in_temp_dir(|| { + std::fs::write( + "config.yaml", + "workers:\n - name: my-worker\n worker_path: /home/user/proj\n", + ) + .unwrap(); + let path = iii_worker::cli::config_file::get_worker_path("my-worker"); + assert_eq!(path, Some("/home/user/proj".to_string())); + }); +} + +#[test] +fn get_worker_path_absent() { + in_temp_dir(|| { + std::fs::write( + "config.yaml", + "workers:\n - name: oci-worker\n image: ghcr.io/org/w:1\n", + ) + .unwrap(); + let path = iii_worker::cli::config_file::get_worker_path("oci-worker"); + assert!(path.is_none()); + }); +} + +#[test] +fn resolve_worker_type_from_config_file() { + use iii_worker::cli::config_file::ResolvedWorkerType; + + in_temp_dir(|| { + std::fs::write( + "config.yaml", + "workers:\n - name: local-w\n worker_path: /home/user/proj\n - name: oci-w\n image: ghcr.io/org/w:1\n - name: config-w\n", + ) + .unwrap(); + + let local = iii_worker::cli::config_file::resolve_worker_type("local-w"); + assert!( + matches!(local, ResolvedWorkerType::Local { ref worker_path } if worker_path == "/home/user/proj"), + "expected Local, got {:?}", + local + ); + + let oci = iii_worker::cli::config_file::resolve_worker_type("oci-w"); + assert!( + matches!(oci, ResolvedWorkerType::Oci { ref image, .. } if image == "ghcr.io/org/w:1"), + "expected Oci, got {:?}", + oci + ); + + // config-only worker resolves to Config (or Binary if ~/.iii/workers/config-w exists, + // but that's unlikely in test environments) + let config = iii_worker::cli::config_file::resolve_worker_type("config-w"); + assert!( + matches!( + config, + ResolvedWorkerType::Config | ResolvedWorkerType::Binary { .. } + ), + "expected Config or Binary fallback, got {:?}", + config + ); + }); +} diff --git a/crates/iii-worker/tests/firmware_integration.rs b/crates/iii-worker/tests/firmware_integration.rs new file mode 100644 index 000000000..6b63c2d0c --- /dev/null +++ b/crates/iii-worker/tests/firmware_integration.rs @@ -0,0 +1,358 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Integration tests for firmware resolution chain and constants. +//! +//! Covers requirements FW-01 through FW-04: +//! - FW-01: Local firmware resolution (env var override, ~/.iii/lib/) +//! - FW-02: Embedded fallback behavior (documented, observational) +//! - FW-03: Download fallback URL construction (archive names, platform support) +//! - FW-04: All sources fail returns None + +mod common; + +use common::assertions::assert_paths_eq; +use iii_worker::cli::firmware::constants::{ + check_libkrunfw_platform_support, iii_init_archive_name, libkrunfw_archive_name, + libkrunfw_filename, III_INIT_FILENAME, +}; +use iii_worker::cli::firmware::resolve::{lib_path_env_var, resolve_init_binary, resolve_libkrunfw_dir}; +use std::sync::Mutex; + +/// Serializes tests that mutate environment variables (HOME, III_LIBKRUNFW_PATH, III_INIT_PATH). +/// Each integration test file runs in its own process, so only intra-file locking is needed. +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +// --------------------------------------------------------------------------- +// Group 1: Firmware resolution with local path (FW-01) +// --------------------------------------------------------------------------- + +/// FW-01: resolve_libkrunfw_dir finds firmware in ~/.iii/lib/ when present. +/// +/// Threat mitigation T-03-05: HOME is saved before override and restored +/// immediately after the function call, before any assertions. +#[test] +fn resolve_libkrunfw_dir_finds_local_firmware() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let lib_dir = tmp.path().join(".iii").join("lib"); + std::fs::create_dir_all(&lib_dir).unwrap(); + + let filename = libkrunfw_filename(); + std::fs::write(lib_dir.join(&filename), b"fake-firmware").unwrap(); + + let original_home = std::env::var("HOME").ok(); + // SAFETY: test-only, serialized via ENV_LOCK + unsafe { + std::env::set_var("HOME", tmp.path()); + std::env::remove_var("III_LIBKRUNFW_PATH"); + } + + let result = resolve_libkrunfw_dir(); + + // Restore HOME immediately (T-03-05 mitigation) + unsafe { + if let Some(ref home) = original_home { + std::env::set_var("HOME", home); + } + } + + assert!(result.is_some(), "expected Some when firmware exists in ~/.iii/lib/"); + assert_paths_eq(&result.unwrap(), &lib_dir); +} + +/// FW-01: resolve_init_binary finds iii-init in ~/.iii/lib/ when present. +#[test] +fn resolve_init_binary_finds_local_init() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let lib_dir = tmp.path().join(".iii").join("lib"); + std::fs::create_dir_all(&lib_dir).unwrap(); + + let init_path = lib_dir.join(III_INIT_FILENAME); + std::fs::write(&init_path, b"fake-init").unwrap(); + + let original_home = std::env::var("HOME").ok(); + unsafe { + std::env::set_var("HOME", tmp.path()); + std::env::remove_var("III_INIT_PATH"); + } + + let result = resolve_init_binary(); + + unsafe { + if let Some(ref home) = original_home { + std::env::set_var("HOME", home); + } + } + + assert!(result.is_some(), "expected Some when iii-init exists in ~/.iii/lib/"); + assert_paths_eq(&result.unwrap(), &init_path); +} + +/// FW-01: resolve_libkrunfw_dir respects III_LIBKRUNFW_PATH env var override. +#[test] +fn resolve_libkrunfw_dir_env_var_override() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let filename = libkrunfw_filename(); + let file_path = tmp.path().join(&filename); + std::fs::write(&file_path, b"fake-firmware").unwrap(); + + let original_env = std::env::var("III_LIBKRUNFW_PATH").ok(); + unsafe { + std::env::set_var("III_LIBKRUNFW_PATH", file_path.to_str().unwrap()); + } + + let result = resolve_libkrunfw_dir(); + + unsafe { + match original_env { + Some(ref v) => std::env::set_var("III_LIBKRUNFW_PATH", v), + None => std::env::remove_var("III_LIBKRUNFW_PATH"), + } + } + + assert!(result.is_some(), "expected Some with III_LIBKRUNFW_PATH override"); + assert_paths_eq(&result.unwrap(), tmp.path()); +} + +// --------------------------------------------------------------------------- +// Group 2: Embedded fallback (FW-02) +// --------------------------------------------------------------------------- + +/// FW-02: Documents the embedded firmware fallback behavior. +/// +/// The `resolve_libkrunfw_dir` function does NOT directly check for embedded firmware -- +/// that's handled by `ensure_libkrunfw` in the download module. This test verifies +/// the behavior when no local firmware exists: the result depends on whether firmware +/// is present in system paths (/usr/lib, /usr/local/lib, Homebrew on macOS). +/// +/// Full embedded/download fallback is tested at a higher level via `ensure_libkrunfw` +/// which is out of scope for this phase. +#[test] +fn resolve_libkrunfw_dir_embedded_fallback_conditional() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + + let original_home = std::env::var("HOME").ok(); + let original_env = std::env::var("III_LIBKRUNFW_PATH").ok(); + unsafe { + std::env::set_var("HOME", tmp.path()); + std::env::remove_var("III_LIBKRUNFW_PATH"); + } + + let result = resolve_libkrunfw_dir(); + + unsafe { + if let Some(ref home) = original_home { + std::env::set_var("HOME", home); + } + match original_env { + Some(ref v) => std::env::set_var("III_LIBKRUNFW_PATH", v), + None => std::env::remove_var("III_LIBKRUNFW_PATH"), + } + } + + // The result depends on the host system: + // - Some(path): system paths have libkrunfw installed (e.g., /usr/lib, Homebrew) + // - None: no system-level libkrunfw (typical in CI/dev without VM support) + match result { + Some(path) => { + let path_str = path.display().to_string(); + assert!( + path_str.starts_with("/usr/") || path_str.starts_with("/opt/"), + "expected system path, got: {path_str}" + ); + } + None => { + // Expected when no system-level libkrunfw is installed + } + } +} + +// --------------------------------------------------------------------------- +// Group 3: Download fallback URL construction (FW-03) +// --------------------------------------------------------------------------- + +/// FW-03: libkrunfw archive name contains correct platform identifier. +#[test] +fn libkrunfw_archive_name_contains_platform() { + let name = libkrunfw_archive_name(); + if cfg!(target_os = "macos") { + assert!( + name.contains("darwin"), + "macOS archive should contain 'darwin': {name}" + ); + } else { + assert!( + name.contains("linux"), + "Linux archive should contain 'linux': {name}" + ); + } + // Archive name does not contain the version string directly -- it uses os+arch only. + // Verify it's a valid .tar.gz archive name. + assert!( + name.ends_with(".tar.gz"), + "archive should end with '.tar.gz': {name}" + ); +} + +/// FW-03: iii-init archive name contains musl target identifier. +#[test] +fn iii_init_archive_name_contains_musl_target() { + let name = iii_init_archive_name(); + assert!( + name.contains("iii-init"), + "archive name should contain 'iii-init': {name}" + ); + assert!( + name.contains("musl"), + "archive name should contain 'musl' (init binary is always musl-linked): {name}" + ); +} + +/// FW-03: Platform support check succeeds on supported architectures. +#[test] +fn check_libkrunfw_platform_support_succeeds_on_supported() { + let result = check_libkrunfw_platform_support(); + // Available firmware: darwin-aarch64, linux-x86_64, linux-aarch64 + // Missing: darwin-x86_64 (Intel Mac) + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + assert!(result.is_ok(), "darwin-aarch64 should be supported: {result:?}"); + + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + assert!(result.is_ok(), "linux-x86_64 should be supported: {result:?}"); + + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + assert!(result.is_ok(), "linux-aarch64 should be supported: {result:?}"); + + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + { + assert!(result.is_err(), "darwin-x86_64 should NOT be supported"); + let msg = result.unwrap_err(); + assert!( + msg.contains("Intel Mac"), + "error message should mention Intel Mac: {msg}" + ); + } +} + +// --------------------------------------------------------------------------- +// Group 4: All sources fail (FW-04) +// --------------------------------------------------------------------------- + +/// FW-04: resolve_libkrunfw_dir returns None (or system path) when env var and HOME fail. +/// +/// The function checks: env var (nonexistent file), ~/.iii/lib/ (empty tempdir), +/// adjacent to binary (in cargo target dir, skipped), system paths. +/// If system paths don't have libkrunfw (typical in CI/dev), result is None. +/// If system has it installed, result is Some pointing to a system path. +#[test] +fn resolve_libkrunfw_dir_returns_none_when_all_sources_fail() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + + let original_home = std::env::var("HOME").ok(); + let original_env = std::env::var("III_LIBKRUNFW_PATH").ok(); + unsafe { + std::env::set_var("HOME", tmp.path()); + std::env::set_var("III_LIBKRUNFW_PATH", "/nonexistent/libkrunfw"); + } + + let result = resolve_libkrunfw_dir(); + + unsafe { + if let Some(ref home) = original_home { + std::env::set_var("HOME", home); + } + match original_env { + Some(ref v) => std::env::set_var("III_LIBKRUNFW_PATH", v), + None => std::env::remove_var("III_LIBKRUNFW_PATH"), + } + } + + // System path caveat: if system has libkrunfw installed, we get Some. + match result { + None => {} // Expected in most CI/dev environments + Some(path) => { + let path_str = path.display().to_string(); + assert!( + path_str.starts_with("/usr/") || path_str.starts_with("/opt/"), + "if found, should be a system path, got: {path_str}" + ); + } + } +} + +/// FW-04: resolve_init_binary returns None when all sources fail. +#[test] +fn resolve_init_binary_returns_none_when_all_sources_fail() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + + let original_home = std::env::var("HOME").ok(); + let original_env = std::env::var("III_INIT_PATH").ok(); + unsafe { + std::env::set_var("HOME", tmp.path()); + std::env::set_var("III_INIT_PATH", "/nonexistent/iii-init"); + } + + let result = resolve_init_binary(); + + unsafe { + if let Some(ref home) = original_home { + std::env::set_var("HOME", home); + } + match original_env { + Some(ref v) => std::env::set_var("III_INIT_PATH", v), + None => std::env::remove_var("III_INIT_PATH"), + } + } + + assert!( + result.is_none(), + "expected None when all iii-init sources fail, got: {:?}", + result + ); +} + +// --------------------------------------------------------------------------- +// Group 5: Constants verification (supporting tests) +// --------------------------------------------------------------------------- + +/// lib_path_env_var returns the correct platform-specific variable name. +#[test] +fn lib_path_env_var_is_platform_correct() { + let var = lib_path_env_var(); + if cfg!(target_os = "macos") { + assert_eq!(var, "DYLD_LIBRARY_PATH"); + } else { + assert_eq!(var, "LD_LIBRARY_PATH"); + } +} + +/// libkrunfw_filename returns the correct platform-specific library filename. +#[test] +fn libkrunfw_filename_is_platform_correct() { + let name = libkrunfw_filename(); + assert!( + name.contains("libkrunfw"), + "filename should contain 'libkrunfw': {name}" + ); + if cfg!(target_os = "macos") { + assert!( + name.ends_with(".dylib"), + "macOS should end with '.dylib': {name}" + ); + } else { + // Linux: libkrunfw.so.5.2.1 -- ends with a version number + assert!( + name.contains(".so."), + "Linux should contain '.so.': {name}" + ); + } +} diff --git a/crates/iii-worker/tests/local_worker_integration.rs b/crates/iii-worker/tests/local_worker_integration.rs new file mode 100644 index 000000000..026b2f066 --- /dev/null +++ b/crates/iii-worker/tests/local_worker_integration.rs @@ -0,0 +1,450 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Integration tests for local worker lifecycle helpers (LOCAL-01 through LOCAL-12). +//! +//! Tests are organized in four groups: +//! 1. Pure function tests (no filesystem, no async) +//! 2. Filesystem tests (sync, tempdir) +//! 3. CWD-dependent async tests (handle_local_add) +//! 4. Platform-gated detect_lan_ip tests + +mod common; + +use common::fixtures::TestConfigBuilder; +use common::isolation::in_temp_dir_async; +use iii_worker::cli::config_file::{get_worker_path, worker_exists}; +use iii_worker::cli::local_worker::{ + build_env_exports, build_libkrun_local_script, build_local_env, clean_workspace_preserving_deps, + copy_dir_contents, detect_lan_ip, handle_local_add, is_local_path, parse_manifest_resources, + resolve_worker_name, shell_escape, +}; +use iii_worker::cli::project::{ProjectInfo, WORKER_MANIFEST}; +use std::collections::HashMap; + +// ────────────────────────────────────────────────────────────────────────────── +// Group 1: Pure function tests (no filesystem, no async) +// ────────────────────────────────────────────────────────────────────────────── + +#[test] +fn is_local_path_dot_relative() { + assert!(is_local_path("./myworker")); +} + +#[test] +fn is_local_path_absolute() { + assert!(is_local_path("/home/user/worker")); +} + +#[test] +fn is_local_path_tilde() { + assert!(is_local_path("~/projects/worker")); +} + +#[test] +fn is_local_path_registry_name() { + assert!(!is_local_path("my-worker")); +} + +/// LOCAL-10: shell_escape replaces single quotes with the '\'' escape sequence. +#[test] +fn shell_escape_single_quotes() { + assert_eq!(shell_escape("it's a test"), "it'\\''s a test"); +} + +#[test] +fn shell_escape_no_special_chars() { + assert_eq!(shell_escape("normal"), "normal"); +} + +/// LOCAL-12: build_env_exports excludes III_ENGINE_URL and III_URL keys. +#[test] +fn build_env_exports_excludes_engine_urls() { + let mut env = HashMap::new(); + env.insert("III_ENGINE_URL".to_string(), "ws://localhost:49134".to_string()); + env.insert("III_URL".to_string(), "ws://localhost:49134".to_string()); + env.insert("VALID_KEY".to_string(), "value".to_string()); + + let result = build_env_exports(&env); + assert!(!result.contains("III_ENGINE_URL"), "should exclude III_ENGINE_URL"); + assert!(!result.contains("III_URL"), "should exclude III_URL"); + assert!(result.contains("export VALID_KEY='value'"), "should include VALID_KEY"); +} + +/// build_env_exports skips keys with invalid characters (spaces, empty). +#[test] +fn build_env_exports_skips_invalid_keys() { + let mut env = HashMap::new(); + env.insert("invalid key".to_string(), "value".to_string()); + env.insert("".to_string(), "empty-key".to_string()); + + let result = build_env_exports(&env); + assert!(!result.contains("invalid key"), "should skip keys with spaces"); + assert_eq!(result, "true", "should return 'true' when no valid keys remain"); +} + +#[test] +fn build_env_exports_empty_map() { + let env = HashMap::new(); + let result = build_env_exports(&env); + assert_eq!(result, "true"); +} + +/// LOCAL-12 wiring: build_local_env merges engine URL and project env, +/// excluding III_ENGINE_URL/III_URL from project env values. +#[test] +fn build_local_env_merges_and_excludes() { + let mut project_env = HashMap::new(); + project_env.insert("CUSTOM".to_string(), "val".to_string()); + project_env.insert("III_ENGINE_URL".to_string(), "skip-this".to_string()); + project_env.insert("III_URL".to_string(), "skip-this-too".to_string()); + + let result = build_local_env("ws://localhost:49134", &project_env); + assert_eq!(result.get("III_ENGINE_URL").unwrap(), "ws://localhost:49134"); + assert_eq!(result.get("III_URL").unwrap(), "ws://localhost:49134"); + assert_eq!(result.get("CUSTOM").unwrap(), "val"); + // Engine URL values come from the function argument, not project_env + assert_ne!(result.get("III_ENGINE_URL").unwrap(), "skip-this"); + assert_ne!(result.get("III_URL").unwrap(), "skip-this-too"); +} + +/// LOCAL-11: build_libkrun_local_script includes setup/install when prepared=false. +#[test] +fn build_libkrun_local_script_not_prepared() { + let project = ProjectInfo { + name: "test".to_string(), + language: Some("typescript".to_string()), + setup_cmd: "apt-get update".to_string(), + install_cmd: "npm install".to_string(), + run_cmd: "npm start".to_string(), + env: HashMap::new(), + }; + let script = build_libkrun_local_script(&project, false); + assert!(script.contains("apt-get update"), "should include setup_cmd"); + assert!(script.contains("npm install"), "should include install_cmd"); + assert!(script.contains(".iii-prepared"), "should include prepared marker"); + assert!(script.contains("npm start"), "should include run_cmd"); +} + +/// LOCAL-11: build_libkrun_local_script omits setup/install when prepared=true. +#[test] +fn build_libkrun_local_script_prepared() { + let project = ProjectInfo { + name: "test".to_string(), + language: Some("typescript".to_string()), + setup_cmd: "apt-get update".to_string(), + install_cmd: "npm install".to_string(), + run_cmd: "npm start".to_string(), + env: HashMap::new(), + }; + let script = build_libkrun_local_script(&project, true); + assert!(!script.contains("apt-get update"), "should omit setup_cmd when prepared"); + assert!(!script.contains("npm install"), "should omit install_cmd when prepared"); + assert!(script.contains("npm start"), "should still include run_cmd"); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Group 2: Filesystem tests (sync, tempdir) +// ────────────────────────────────────────────────────────────────────────────── + +/// LOCAL-08 partial: resolve_worker_name reads name from manifest. +#[test] +fn resolve_worker_name_from_manifest() { + let dir = tempfile::tempdir().unwrap(); + let yaml = "name: my-custom-worker\nruntime:\n language: typescript\n"; + std::fs::write(dir.path().join(WORKER_MANIFEST), yaml).unwrap(); + let name = resolve_worker_name(dir.path()); + assert_eq!(name, "my-custom-worker"); +} + +/// resolve_worker_name falls back to directory name when no manifest exists. +#[test] +fn resolve_worker_name_fallback_to_dir_name() { + let dir = tempfile::tempdir().unwrap(); + let name = resolve_worker_name(dir.path()); + let expected = dir.path().file_name().unwrap().to_str().unwrap(); + assert_eq!(name, expected); +} + +/// LOCAL-08: parse_manifest_resources returns custom CPU/memory from YAML. +#[test] +fn parse_manifest_resources_custom_values() { + let dir = tempfile::tempdir().unwrap(); + let manifest_path = dir.path().join(WORKER_MANIFEST); + let yaml = "name: resource-test\nresources:\n cpus: 4\n memory: 4096\n"; + std::fs::write(&manifest_path, yaml).unwrap(); + let (cpus, memory) = parse_manifest_resources(&manifest_path); + assert_eq!(cpus, 4); + assert_eq!(memory, 4096); +} + +/// LOCAL-08: parse_manifest_resources returns defaults when path is absent. +#[test] +fn parse_manifest_resources_defaults_on_missing() { + let dir = tempfile::tempdir().unwrap(); + let nonexistent = dir.path().join("nonexistent.yaml"); + let (cpus, memory) = parse_manifest_resources(&nonexistent); + assert_eq!(cpus, 2); + assert_eq!(memory, 2048); +} + +/// LOCAL-05, LOCAL-06: copy_dir_contents copies files and skips ignored directories. +#[test] +fn copy_dir_contents_copies_files_skips_ignored() { + let src = tempfile::tempdir().unwrap(); + let dst = tempfile::tempdir().unwrap(); + + // Create source files that should be copied + std::fs::create_dir_all(src.path().join("src")).unwrap(); + std::fs::write(src.path().join("src/main.rs"), "fn main() {}").unwrap(); + std::fs::write(src.path().join("README.md"), "# README").unwrap(); + + // Create directories that should be skipped + std::fs::create_dir_all(src.path().join("node_modules/pkg")).unwrap(); + std::fs::write(src.path().join("node_modules/pkg/index.js"), "").unwrap(); + std::fs::create_dir_all(src.path().join(".git")).unwrap(); + std::fs::write(src.path().join(".git/config"), "").unwrap(); + std::fs::create_dir_all(src.path().join("target/debug")).unwrap(); + std::fs::write(src.path().join("target/debug/bin"), "").unwrap(); + std::fs::create_dir_all(src.path().join("__pycache__")).unwrap(); + std::fs::write(src.path().join("__pycache__/mod.pyc"), "").unwrap(); + std::fs::create_dir_all(src.path().join(".venv/lib")).unwrap(); + std::fs::write(src.path().join(".venv/lib/site.py"), "").unwrap(); + std::fs::create_dir_all(src.path().join("dist")).unwrap(); + std::fs::write(src.path().join("dist/bundle.js"), "").unwrap(); + + copy_dir_contents(src.path(), dst.path()).unwrap(); + + // Verify copied files + assert!(dst.path().join("src/main.rs").exists(), "src/main.rs should be copied"); + assert!(dst.path().join("README.md").exists(), "README.md should be copied"); + + // Verify skipped directories + assert!(!dst.path().join("node_modules").exists(), "node_modules should be skipped"); + assert!(!dst.path().join(".git").exists(), ".git should be skipped"); + assert!(!dst.path().join("target").exists(), "target should be skipped"); + assert!(!dst.path().join("__pycache__").exists(), "__pycache__ should be skipped"); + assert!(!dst.path().join(".venv").exists(), ".venv should be skipped"); + assert!(!dst.path().join("dist").exists(), "dist should be skipped"); +} + +/// LOCAL-07: clean_workspace_preserving_deps removes source but keeps dependency dirs. +#[test] +fn clean_workspace_preserving_deps_preserves_deps_removes_source() { + let dir = tempfile::tempdir().unwrap(); + let ws = dir.path(); + + // Create dependency directories that should be preserved + std::fs::create_dir_all(ws.join("node_modules/pkg")).unwrap(); + std::fs::write(ws.join("node_modules/pkg/index.js"), "mod").unwrap(); + std::fs::create_dir_all(ws.join("target/debug")).unwrap(); + std::fs::write(ws.join("target/debug/bin"), "elf").unwrap(); + std::fs::create_dir_all(ws.join(".venv/lib")).unwrap(); + std::fs::write(ws.join(".venv/lib/site.py"), "py").unwrap(); + std::fs::create_dir_all(ws.join("__pycache__")).unwrap(); + std::fs::write(ws.join("__pycache__/mod.pyc"), "pyc").unwrap(); + + // Create source files/dirs that should be removed + std::fs::write(ws.join("main.ts"), "console.log()").unwrap(); + std::fs::create_dir_all(ws.join("src")).unwrap(); + std::fs::write(ws.join("src/lib.ts"), "export {}").unwrap(); + + clean_workspace_preserving_deps(ws); + + // Dep dirs preserved + assert!(ws.join("node_modules/pkg/index.js").exists()); + assert!(ws.join("target/debug/bin").exists()); + assert!(ws.join(".venv/lib/site.py").exists()); + assert!(ws.join("__pycache__/mod.pyc").exists()); + + // Source files/dirs removed + assert!(!ws.join("main.ts").exists()); + assert!(!ws.join("src").exists()); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Group 3: CWD-dependent async tests (handle_local_add) +// ────────────────────────────────────────────────────────────────────────────── + +/// LOCAL-01: handle_local_add adds a worker from a valid filesystem path. +/// Note: Without an iii.worker.yaml manifest, resolve_worker_name falls back +/// to the directory name (not the auto-detected project type name). +#[tokio::test] +async fn handle_local_add_valid_path() { + in_temp_dir_async(|| async { + let cwd = std::env::current_dir().unwrap(); + + // Create a project directory with package.json (node auto-detect) + let project_dir = cwd.join("my-worker"); + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::write(project_dir.join("package.json"), "{}").unwrap(); + + let result = + handle_local_add(project_dir.to_str().unwrap(), false, false, true).await; + assert_eq!(result, 0, "handle_local_add should return 0 for valid path"); + + // Without manifest, resolve_worker_name falls back to directory name + assert!( + worker_exists("my-worker"), + "worker should exist in config.yaml with directory name" + ); + let stored_path = get_worker_path("my-worker"); + assert!(stored_path.is_some(), "worker path should be stored"); + let stored = stored_path.unwrap(); + // Verify the stored path contains the project directory + // (canonicalized, so on macOS /tmp -> /private/tmp) + let canonical_project = std::fs::canonicalize(&project_dir).unwrap(); + assert!( + stored.contains(canonical_project.to_str().unwrap()), + "stored path '{}' should contain canonical project dir '{}'", + stored, + canonical_project.display() + ); + }) + .await; +} + +/// LOCAL-03: handle_local_add rejects duplicate worker name without --force. +#[tokio::test] +async fn handle_local_add_rejects_duplicate_without_force() { + in_temp_dir_async(|| async { + let cwd = std::env::current_dir().unwrap(); + + // Create project with manifest defining worker name + let project_dir = cwd.join("my-worker"); + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::write(project_dir.join("package.json"), "{}").unwrap(); + std::fs::write( + project_dir.join(WORKER_MANIFEST), + "name: my-worker\nruntime:\n language: typescript\n package_manager: npm\n entry: src/index.ts\n", + ) + .unwrap(); + + // Pre-populate config.yaml with existing worker + TestConfigBuilder::new() + .with_worker("my-worker", None) + .build(&cwd); + + let result = + handle_local_add(project_dir.to_str().unwrap(), false, false, true).await; + assert_eq!( + result, 1, + "should return 1 when worker exists and force=false" + ); + }) + .await; +} + +/// LOCAL-02: handle_local_add with force=true replaces an existing worker entry. +#[tokio::test] +async fn handle_local_add_force_replaces_existing() { + in_temp_dir_async(|| async { + let cwd = std::env::current_dir().unwrap(); + + // Create project with manifest + let project_dir = cwd.join("my-worker"); + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::write(project_dir.join("package.json"), "{}").unwrap(); + std::fs::write( + project_dir.join(WORKER_MANIFEST), + "name: my-worker\nruntime:\n language: typescript\n package_manager: npm\n entry: src/index.ts\n", + ) + .unwrap(); + + // Pre-populate config.yaml with old path + TestConfigBuilder::new() + .with_worker("my-worker", Some("/old/path")) + .build(&cwd); + + let result = + handle_local_add(project_dir.to_str().unwrap(), true, true, true).await; + assert_eq!(result, 0, "should return 0 with force=true"); + + let stored_path = get_worker_path("my-worker"); + assert!(stored_path.is_some(), "worker path should be stored"); + let stored = stored_path.unwrap(); + assert!( + !stored.contains("/old/path"), + "stored path '{}' should not be the old path", + stored + ); + // Verify it contains the new canonical path + let canonical_project = std::fs::canonicalize(&project_dir).unwrap(); + assert!( + stored.contains(canonical_project.to_str().unwrap()), + "stored path '{}' should contain new canonical path '{}'", + stored, + canonical_project.display() + ); + }) + .await; +} + +/// LOCAL-04: handle_local_add resolves relative paths to absolute before storing. +#[tokio::test] +async fn handle_local_add_canonicalizes_relative_path() { + in_temp_dir_async(|| async { + let cwd = std::env::current_dir().unwrap(); + + // Create project using relative path + let project_dir = cwd.join("rel-worker"); + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::write(project_dir.join("package.json"), "{}").unwrap(); + + let result = handle_local_add("./rel-worker", false, false, true).await; + assert_eq!(result, 0, "should succeed with relative path"); + + // Without manifest, name falls back to directory name "rel-worker" + let stored_path = get_worker_path("rel-worker"); + assert!(stored_path.is_some(), "worker path should be stored"); + let stored = stored_path.unwrap(); + assert!( + stored.starts_with('/'), + "stored path '{}' should be absolute (start with /)", + stored + ); + }) + .await; +} + +/// handle_local_add returns 1 for nonexistent path. +#[tokio::test] +async fn handle_local_add_invalid_path_returns_error() { + in_temp_dir_async(|| async { + let result = + handle_local_add("/nonexistent/path/to/worker", false, false, true).await; + assert_eq!(result, 1, "should return 1 for nonexistent path"); + }) + .await; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Group 4: Platform-gated detect_lan_ip tests +// ────────────────────────────────────────────────────────────────────────────── + +/// LOCAL-09: detect_lan_ip returns Some(IPv4) on macOS. +#[cfg(target_os = "macos")] +#[tokio::test] +async fn detect_lan_ip_macos_returns_some_ipv4() { + let result = detect_lan_ip().await; + assert!(result.is_some(), "macOS should return Some(ip)"); + let ip = result.unwrap(); + // Validate IPv4 format without regex dependency + assert_eq!(ip.split('.').count(), 4, "IP '{}' should have 4 octets", ip); + assert!( + ip.split('.').all(|octet| octet.parse::().is_ok()), + "IP '{}' octets should all be valid u8", + ip + ); +} + +/// LOCAL-09: detect_lan_ip returns None on Linux (route -n get default is macOS-only). +#[cfg(target_os = "linux")] +#[tokio::test] +async fn detect_lan_ip_linux_returns_none() { + let result = detect_lan_ip().await; + assert!(result.is_none(), "Linux should return None"); +} diff --git a/crates/iii-worker/tests/oci_gate_smoke.rs b/crates/iii-worker/tests/oci_gate_smoke.rs new file mode 100644 index 000000000..b45b44a54 --- /dev/null +++ b/crates/iii-worker/tests/oci_gate_smoke.rs @@ -0,0 +1,18 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Smoke test: proves the integration-oci feature gate compiles and activates. +//! Real OCI tests are added in Phase 4. + +#![cfg(feature = "integration-oci")] + +#[test] +fn oci_feature_gate_active() { + assert!( + cfg!(feature = "integration-oci"), + "integration-oci feature should be active" + ); +} diff --git a/crates/iii-worker/tests/oci_worker_integration.rs b/crates/iii-worker/tests/oci_worker_integration.rs new file mode 100644 index 000000000..b5ed06a26 --- /dev/null +++ b/crates/iii-worker/tests/oci_worker_integration.rs @@ -0,0 +1,398 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Integration tests for OCI worker operations. +//! Covers requirements OCI-01 through OCI-05. +//! +//! Tests are split into ungated (pure/filesystem) and gated (network-dependent): +//! - Ungated: extract_layer_with_limits safety, expected_oci_arch, oci_image_for_language, +//! OCI config reading (entrypoint, workdir, env), rootfs_search_paths, read_cached_rootfs_arch +//! - Gated (#[cfg(feature = "integration-oci")]): pull_and_extract_rootfs end-to-end + +use iii_worker::cli::worker_manager::oci::{ + expected_oci_arch, extract_layer_with_limits, oci_image_for_language, read_cached_rootfs_arch, + read_oci_entrypoint, read_oci_env, read_oci_workdir, rootfs_search_paths, +}; + +/// Build a tar.gz archive from a list of (path, content, mode) entries. +fn make_layer_targz(entries: &[(&str, &[u8], u32)]) -> Vec { + use flate2::write::GzEncoder; + use flate2::Compression; + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + { + let mut archive = tar::Builder::new(&mut encoder); + for (path, content, mode) in entries { + let mut header = tar::Header::new_gnu(); + header.set_path(path).unwrap(); + header.set_size(content.len() as u64); + header.set_mode(*mode); + header.set_cksum(); + archive.append(&header, *content as &[u8]).unwrap(); + } + archive.finish().unwrap(); + } + encoder.finish().unwrap() +} + +/// Build raw tar bytes with a custom path, bypassing tar::Builder path validation. +/// This is needed to test path traversal and absolute path rejection. +fn make_raw_tar_gz(path_bytes: &[u8], data: &[u8]) -> Vec { + use std::io::Write; + + let mut raw_tar = Vec::new(); + + // 512-byte GNU tar header + let mut header_block = [0u8; 512]; + header_block[..path_bytes.len()].copy_from_slice(path_bytes); + // mode (offset 100, 8 bytes) + header_block[100..107].copy_from_slice(b"0000644"); + // size (offset 124, 12 bytes) -- octal + let size_str = format!("{:011o}", data.len()); + header_block[124..135].copy_from_slice(size_str.as_bytes()); + // typeflag (offset 156) -- '0' regular file + header_block[156] = b'0'; + // magic (offset 257, 6 bytes) + version (offset 263, 2 bytes) + header_block[257..263].copy_from_slice(b"ustar\0"); + header_block[263..265].copy_from_slice(b"00"); + // checksum (offset 148, 8 bytes): fill with spaces, compute, then write + header_block[148..156].copy_from_slice(b" "); + let cksum: u32 = header_block.iter().map(|&b| b as u32).sum(); + let cksum_str = format!("{:06o}\0 ", cksum); + header_block[148..156].copy_from_slice(cksum_str.as_bytes()); + + raw_tar.extend_from_slice(&header_block); + raw_tar.extend_from_slice(data); + // Pad to 512-byte boundary + let padding = 512 - (data.len() % 512); + if padding < 512 { + raw_tar.extend(std::iter::repeat(0u8).take(padding)); + } + // Two zero blocks to end the archive + raw_tar.extend(std::iter::repeat(0u8).take(1024)); + + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + gz.write_all(&raw_tar).unwrap(); + gz.finish().unwrap() +} + +// ============================================================================= +// Group 1: Layer extraction -- valid (OCI-02) +// ============================================================================= + +#[test] +fn extract_layer_valid_single_file() { + let dir = tempfile::tempdir().unwrap(); + let layer = make_layer_targz(&[("test.txt", b"hello world", 0o644)]); + + let mut total_size = 0u64; + extract_layer_with_limits(&layer, dir.path(), 0, 1, &mut total_size).unwrap(); + + let content = std::fs::read_to_string(dir.path().join("test.txt")).unwrap(); + assert_eq!(content, "hello world"); + assert_eq!(total_size, 11); +} + +#[test] +fn extract_layer_valid_multiple_files() { + let dir = tempfile::tempdir().unwrap(); + let layer = make_layer_targz(&[ + ("bin/app", b"binary", 0o755), + ("etc/config", b"key=value", 0o644), + ]); + + let mut total_size = 0u64; + extract_layer_with_limits(&layer, dir.path(), 0, 1, &mut total_size).unwrap(); + + let app_content = std::fs::read_to_string(dir.path().join("bin/app")).unwrap(); + assert_eq!(app_content, "binary"); + + let config_content = std::fs::read_to_string(dir.path().join("etc/config")).unwrap(); + assert_eq!(config_content, "key=value"); +} + +#[test] +fn extract_layer_preserves_directory_structure() { + let dir = tempfile::tempdir().unwrap(); + let layer = make_layer_targz(&[("usr/local/bin/tool", b"#!/bin/sh\necho hi", 0o755)]); + + let mut total_size = 0u64; + extract_layer_with_limits(&layer, dir.path(), 0, 1, &mut total_size).unwrap(); + + assert!(dir.path().join("usr/local/bin/tool").exists()); + let content = std::fs::read_to_string(dir.path().join("usr/local/bin/tool")).unwrap(); + assert_eq!(content, "#!/bin/sh\necho hi"); +} + +// ============================================================================= +// Group 2: Layer extraction -- safety rejections (OCI-04) +// ============================================================================= + +#[test] +fn extract_layer_rejects_path_traversal() { + let dir = tempfile::tempdir().unwrap(); + let gz_data = make_raw_tar_gz(b"../escape.txt", b"malicious content"); + + let mut total_size = 0u64; + let result = extract_layer_with_limits(&gz_data, dir.path(), 0, 1, &mut total_size); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("path traversal"), "got: {}", err_msg); +} + +#[test] +fn extract_layer_rejects_absolute_path() { + let dir = tempfile::tempdir().unwrap(); + let gz_data = make_raw_tar_gz(b"/etc/passwd", b"root:x:0:0"); + + let mut total_size = 0u64; + let result = extract_layer_with_limits(&gz_data, dir.path(), 0, 1, &mut total_size); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("absolute path"), "got: {}", err_msg); +} + +#[test] +fn extract_layer_rejects_oversized_total() { + let dir = tempfile::tempdir().unwrap(); + // Create a small valid tar.gz with a 2-byte file + let layer = make_layer_targz(&[("small.txt", b"ab", 0o644)]); + + // Pre-set total_size to just below MAX_TOTAL_SIZE (10 GiB - 1 byte) + let mut total_size: u64 = 10 * 1024 * 1024 * 1024 - 1; + let result = extract_layer_with_limits(&layer, dir.path(), 0, 1, &mut total_size); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("total extraction size exceeded"), + "got: {}", + err_msg + ); +} + +#[test] +fn extract_layer_handles_whiteout_files() { + let dir = tempfile::tempdir().unwrap(); + + // Create a pre-existing file that the whiteout should remove + let target_path = dir.path().join("deleted-file"); + std::fs::write(&target_path, "should be removed").unwrap(); + assert!(target_path.exists()); + + // Create a tar.gz with a whiteout entry + let layer = make_layer_targz(&[(".wh.deleted-file", b"", 0o644)]); + + let mut total_size = 0u64; + extract_layer_with_limits(&layer, dir.path(), 0, 1, &mut total_size).unwrap(); + + // The whiteout should have removed the pre-existing file + assert!( + !target_path.exists(), + "whiteout should have removed 'deleted-file'" + ); +} + +// ============================================================================= +// Group 3: Architecture detection (OCI-05) +// ============================================================================= + +#[test] +fn expected_oci_arch_returns_known_value() { + let arch = expected_oci_arch(); + assert!( + arch == "arm64" || arch == "amd64", + "expected arm64 or amd64, got: {}", + arch + ); + + if cfg!(target_arch = "aarch64") { + assert_eq!(arch, "arm64"); + } + if cfg!(target_arch = "x86_64") { + assert_eq!(arch, "amd64"); + } +} + +// ============================================================================= +// Group 4: Image-to-language mapping (OCI-05) +// ============================================================================= + +#[test] +fn oci_image_for_typescript() { + let (image, name) = oci_image_for_language("typescript"); + assert_eq!(image, "docker.io/iiidev/node:latest"); + assert_eq!(name, "node"); +} + +#[test] +fn oci_image_for_javascript() { + let (image, name) = oci_image_for_language("javascript"); + assert_eq!(image, "docker.io/iiidev/node:latest"); + assert_eq!(name, "node"); +} + +#[test] +fn oci_image_for_python() { + let (image, name) = oci_image_for_language("python"); + assert_eq!(image, "docker.io/iiidev/python:latest"); + assert_eq!(name, "python"); +} + +#[test] +fn oci_image_for_rust() { + let (image, name) = oci_image_for_language("rust"); + assert_eq!(image, "docker.io/library/rust:slim-bookworm"); + assert_eq!(name, "rust"); +} + +#[test] +fn oci_image_for_unknown_defaults_to_node() { + let (image, name) = oci_image_for_language("go"); + assert_eq!(image, "docker.io/iiidev/node:latest"); + assert_eq!(name, "node"); + + let (image2, name2) = oci_image_for_language("unknown_lang"); + assert_eq!(image2, "docker.io/iiidev/node:latest"); + assert_eq!(name2, "node"); +} + +// ============================================================================= +// Group 5: OCI config reading (OCI-03) +// ============================================================================= + +#[test] +fn read_oci_entrypoint_with_entrypoint_and_cmd() { + let dir = tempfile::tempdir().unwrap(); + let config = r#"{"config": {"Entrypoint": ["/usr/bin/node"], "Cmd": ["server.js"]}}"#; + std::fs::write(dir.path().join(".oci-config.json"), config).unwrap(); + + let result = read_oci_entrypoint(dir.path()).unwrap(); + assert_eq!(result.0, "/usr/bin/node"); + assert_eq!(result.1, vec!["server.js"]); +} + +#[test] +fn read_oci_entrypoint_cmd_only() { + let dir = tempfile::tempdir().unwrap(); + let config = r#"{"config": {"Cmd": ["/bin/sh", "-c", "echo"]}}"#; + std::fs::write(dir.path().join(".oci-config.json"), config).unwrap(); + + let result = read_oci_entrypoint(dir.path()).unwrap(); + assert_eq!(result.0, "/bin/sh"); + assert_eq!(result.1, vec!["-c", "echo"]); +} + +#[test] +fn read_oci_entrypoint_empty_config() { + let dir = tempfile::tempdir().unwrap(); + let config = r#"{"config": {}}"#; + std::fs::write(dir.path().join(".oci-config.json"), config).unwrap(); + + assert!(read_oci_entrypoint(dir.path()).is_none()); +} + +#[test] +fn read_oci_workdir_present() { + let dir = tempfile::tempdir().unwrap(); + let config = r#"{"config": {"WorkingDir": "/app"}}"#; + std::fs::write(dir.path().join(".oci-config.json"), config).unwrap(); + + let result = read_oci_workdir(dir.path()); + assert_eq!(result, Some("/app".to_string())); +} + +#[test] +fn read_oci_workdir_absent() { + let dir = tempfile::tempdir().unwrap(); + let config = r#"{"config": {}}"#; + std::fs::write(dir.path().join(".oci-config.json"), config).unwrap(); + + assert!(read_oci_workdir(dir.path()).is_none()); +} + +#[test] +fn read_oci_env_parses_key_value() { + let dir = tempfile::tempdir().unwrap(); + let config = r#"{"config": {"Env": ["PATH=/usr/bin", "HOME=/root"]}}"#; + std::fs::write(dir.path().join(".oci-config.json"), config).unwrap(); + + let env = read_oci_env(dir.path()); + assert_eq!( + env, + vec![ + ("PATH".to_string(), "/usr/bin".to_string()), + ("HOME".to_string(), "/root".to_string()), + ] + ); +} + +#[test] +fn read_oci_env_empty() { + let dir = tempfile::tempdir().unwrap(); + let config = r#"{"config": {}}"#; + std::fs::write(dir.path().join(".oci-config.json"), config).unwrap(); + + let env = read_oci_env(dir.path()); + assert!(env.is_empty()); +} + +#[test] +fn read_cached_rootfs_arch_reads_architecture() { + let dir = tempfile::tempdir().unwrap(); + let config = r#"{"architecture": "arm64"}"#; + std::fs::write(dir.path().join(".oci-config.json"), config).unwrap(); + + let result = read_cached_rootfs_arch(dir.path()); + assert_eq!(result, Some("arm64".to_string())); +} + +#[test] +fn read_cached_rootfs_arch_missing_file() { + let dir = tempfile::tempdir().unwrap(); + // No .oci-config.json written + let result = read_cached_rootfs_arch(dir.path()); + assert!(result.is_none()); +} + +// ============================================================================= +// Group 6: Rootfs search paths +// ============================================================================= + +#[test] +fn rootfs_search_paths_includes_standard_locations() { + let paths = rootfs_search_paths("node"); + + let has_home_path = paths + .iter() + .any(|p| p.to_string_lossy().contains(".iii/rootfs/node")); + assert!(has_home_path, "should include ~/.iii/rootfs/node path"); + + let has_system_path = paths + .iter() + .any(|p| p.to_string_lossy().contains("/usr/local/share/iii/rootfs/node")); + assert!( + has_system_path, + "should include /usr/local/share/iii/rootfs/node path" + ); +} + +// ============================================================================= +// Group 7: Feature-gated network tests (OCI-01) +// ============================================================================= + +#[cfg(feature = "integration-oci")] +mod gated { + use super::*; + + #[tokio::test] + async fn pull_and_extract_rootfs_placeholder() { + // This test requires network access and a running OCI registry. + // It is intentionally feature-gated behind integration-oci. + // Full implementation depends on registry availability. + // For now, verify the import compiles under the feature gate. + use iii_worker::cli::worker_manager::oci::pull_and_extract_rootfs; + let _ = pull_and_extract_rootfs; // type-check only + } +} diff --git a/crates/iii-worker/tests/project_detection_integration.rs b/crates/iii-worker/tests/project_detection_integration.rs new file mode 100644 index 000000000..f7e4bc4c4 --- /dev/null +++ b/crates/iii-worker/tests/project_detection_integration.rs @@ -0,0 +1,328 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Integration tests for project auto-detection, manifest loading, and script inference. +//! +//! Covers requirements PROJ-01 through PROJ-05: +//! - PROJ-01: TypeScript detection from package.json +//! - PROJ-02: Rust detection from Cargo.toml +//! - PROJ-03: Python detection from pyproject.toml / requirements.txt +//! - PROJ-04: Package manager differentiation (bun vs npm default) +//! - PROJ-05: Script inference and manifest loading + +mod common; + +use iii_worker::cli::project::{ + auto_detect_project, infer_scripts, load_from_manifest, load_project_info, WORKER_MANIFEST, +}; + +// --------------------------------------------------------------------------- +// Group 1: auto_detect_project tests (PROJ-01 through PROJ-04) +// --------------------------------------------------------------------------- + +/// PROJ-01: Detects TypeScript/npm project from package.json without bun lockfile. +#[test] +fn auto_detect_typescript_npm_from_package_json() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let result = auto_detect_project(dir.path()); + assert!(result.is_some(), "expected Some for package.json project"); + let info = result.unwrap(); + assert_eq!(info.language.as_deref(), Some("typescript")); + assert_eq!(info.name, "node (npm)"); + assert!( + info.install_cmd.contains("npm"), + "install_cmd should contain 'npm': {}", + info.install_cmd + ); +} + +/// PROJ-01, PROJ-04: Detects TypeScript/bun project from package.json + bun.lock. +#[test] +fn auto_detect_typescript_bun_from_bun_lock() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + std::fs::write(dir.path().join("bun.lock"), "").unwrap(); + + let result = auto_detect_project(dir.path()); + assert!(result.is_some(), "expected Some for bun project"); + let info = result.unwrap(); + assert_eq!(info.name, "node (bun)"); + assert_eq!(info.language.as_deref(), Some("typescript")); + assert!( + info.install_cmd.contains("bun") || info.run_cmd.contains("bun"), + "bun commands should reference 'bun': install={}, run={}", + info.install_cmd, + info.run_cmd + ); +} + +/// PROJ-04: Detects TypeScript/bun project from package.json + bun.lockb (binary lockfile). +#[test] +fn auto_detect_typescript_bun_from_bun_lockb() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + std::fs::write(dir.path().join("bun.lockb"), "").unwrap(); + + let result = auto_detect_project(dir.path()); + assert!(result.is_some(), "expected Some for bun.lockb project"); + let info = result.unwrap(); + assert_eq!(info.name, "node (bun)"); + assert_eq!(info.language.as_deref(), Some("typescript")); + assert!( + info.install_cmd.contains("bun") || info.run_cmd.contains("bun"), + "bun commands should reference 'bun': install={}, run={}", + info.install_cmd, + info.run_cmd + ); +} + +/// PROJ-02: Detects Rust project from Cargo.toml. +#[test] +fn auto_detect_rust_from_cargo_toml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap(); + + let result = auto_detect_project(dir.path()); + assert!(result.is_some(), "expected Some for Cargo.toml project"); + let info = result.unwrap(); + assert_eq!(info.name, "rust"); + assert_eq!(info.language.as_deref(), Some("rust")); + assert!( + info.install_cmd.contains("cargo"), + "install_cmd should contain 'cargo': {}", + info.install_cmd + ); +} + +/// PROJ-03: Detects Python project from pyproject.toml. +#[test] +fn auto_detect_python_from_pyproject_toml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("pyproject.toml"), "[project]").unwrap(); + + let result = auto_detect_project(dir.path()); + assert!(result.is_some(), "expected Some for pyproject.toml project"); + let info = result.unwrap(); + assert_eq!(info.name, "python"); + assert_eq!(info.language.as_deref(), Some("python")); +} + +/// PROJ-03: Detects Python project from requirements.txt. +#[test] +fn auto_detect_python_from_requirements_txt() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("requirements.txt"), "flask").unwrap(); + + let result = auto_detect_project(dir.path()); + assert!(result.is_some(), "expected Some for requirements.txt project"); + let info = result.unwrap(); + assert_eq!(info.name, "python"); + assert_eq!(info.language.as_deref(), Some("python")); +} + +/// Returns None for an empty directory with no recognizable project markers. +#[test] +fn auto_detect_unknown_project_returns_none() { + let dir = tempfile::tempdir().unwrap(); + assert!( + auto_detect_project(dir.path()).is_none(), + "expected None for empty directory" + ); +} + +/// PROJ-04: Without bun.lock, auto_detect defaults to npm. With bun.lock, detects bun. +/// +/// NOTE: auto_detect_project does NOT detect yarn (yarn.lock) or pnpm (pnpm-lock.yaml) +/// from lockfiles. It only differentiates bun (bun.lock/bun.lockb) from the npm default. +/// The infer_scripts function supports yarn/pnpm but only when called from a manifest +/// with an explicit package_manager field. +#[test] +fn package_manager_detection_bun_vs_npm_default() { + // Without bun lockfile -> defaults to npm + let npm_dir = tempfile::tempdir().unwrap(); + std::fs::write(npm_dir.path().join("package.json"), "{}").unwrap(); + let npm_info = auto_detect_project(npm_dir.path()).unwrap(); + assert_eq!(npm_info.name, "node (npm)"); + + // With bun.lock -> detects bun + let bun_dir = tempfile::tempdir().unwrap(); + std::fs::write(bun_dir.path().join("package.json"), "{}").unwrap(); + std::fs::write(bun_dir.path().join("bun.lock"), "").unwrap(); + let bun_info = auto_detect_project(bun_dir.path()).unwrap(); + assert_eq!(bun_info.name, "node (bun)"); +} + +// --------------------------------------------------------------------------- +// Group 2: infer_scripts tests (PROJ-05) +// --------------------------------------------------------------------------- + +/// PROJ-05: Infer scripts for TypeScript/bun runtime. +#[test] +fn infer_scripts_typescript_bun() { + let (setup, install, run) = infer_scripts("typescript", "bun", "src/index.ts"); + assert!( + setup.contains("bun.sh/install"), + "setup should contain 'bun.sh/install': {setup}" + ); + assert!( + install.contains("bun install"), + "install should contain 'bun install': {install}" + ); + assert!( + run.contains("bun src/index.ts"), + "run should contain 'bun src/index.ts': {run}" + ); +} + +/// PROJ-05: Infer scripts for TypeScript/npm runtime. +#[test] +fn infer_scripts_typescript_npm() { + let (setup, install, run) = infer_scripts("typescript", "npm", "src/index.ts"); + assert!( + install.contains("npm install"), + "install should contain 'npm install': {install}" + ); + assert!( + run.contains("npx tsx src/index.ts"), + "run should contain 'npx tsx src/index.ts': {run}" + ); +} + +/// PROJ-05: Infer scripts for Python/pip runtime. +#[test] +fn infer_scripts_python_pip() { + let (setup, install, run) = infer_scripts("python", "pip", "my_module"); + assert!( + setup.contains("python3"), + "setup should contain 'python3': {setup}" + ); + assert!( + install.contains(".venv/bin/pip"), + "install should contain '.venv/bin/pip': {install}" + ); + assert!( + run.contains(".venv/bin/python -m my_module"), + "run should contain '.venv/bin/python -m my_module': {run}" + ); +} + +/// PROJ-05: Infer scripts for Rust/cargo runtime. +#[test] +fn infer_scripts_rust_cargo() { + let (setup, install, run) = infer_scripts("rust", "cargo", "src/main.rs"); + assert!( + setup.contains("rustup"), + "setup should contain 'rustup': {setup}" + ); + assert!( + install.contains("cargo build"), + "install should contain 'cargo build': {install}" + ); + assert!( + run.contains("cargo run"), + "run should contain 'cargo run': {run}" + ); +} + +/// PROJ-05: Unknown language/package manager returns entry as run command. +#[test] +fn infer_scripts_unknown_returns_entry() { + let (setup, install, run) = infer_scripts("unknown", "unknown", "main.sh"); + assert!(setup.is_empty(), "setup should be empty: {setup}"); + assert!(install.is_empty(), "install should be empty: {install}"); + assert_eq!(run, "main.sh"); +} + +// --------------------------------------------------------------------------- +// Group 3: Manifest loading and load_project_info (PROJ-05 augmentation) +// --------------------------------------------------------------------------- + +/// PROJ-05: load_from_manifest with explicit scripts and env filtering. +#[test] +fn load_from_manifest_with_explicit_scripts() { + let dir = tempfile::tempdir().unwrap(); + let manifest_path = dir.path().join(WORKER_MANIFEST); + let yaml = r#" +name: my-worker +scripts: + setup: "apt-get update" + install: "npm install" + start: "node server.js" +env: + FOO: bar + III_URL: skip + III_ENGINE_URL: skip +"#; + std::fs::write(&manifest_path, yaml).unwrap(); + + let info = load_from_manifest(&manifest_path).unwrap(); + assert_eq!(info.name, "my-worker"); + assert_eq!(info.setup_cmd, "apt-get update"); + assert_eq!(info.install_cmd, "npm install"); + assert_eq!(info.run_cmd, "node server.js"); + assert_eq!(info.env.get("FOO").unwrap(), "bar"); + assert!( + !info.env.contains_key("III_URL"), + "III_URL should be filtered out" + ); + assert!( + !info.env.contains_key("III_ENGINE_URL"), + "III_ENGINE_URL should be filtered out" + ); +} + +/// PROJ-05: load_from_manifest infers scripts from runtime section when no explicit scripts. +#[test] +fn load_from_manifest_infers_scripts_from_runtime() { + let dir = tempfile::tempdir().unwrap(); + let manifest_path = dir.path().join(WORKER_MANIFEST); + let yaml = r#" +name: my-bun-worker +runtime: + language: typescript + package_manager: bun + entry: src/index.ts +"#; + std::fs::write(&manifest_path, yaml).unwrap(); + + let info = load_from_manifest(&manifest_path).unwrap(); + assert_eq!(info.name, "my-bun-worker"); + assert!( + info.setup_cmd.contains("bun.sh/install"), + "setup_cmd should contain 'bun.sh/install': {}", + info.setup_cmd + ); + assert!( + info.run_cmd.contains("bun src/index.ts"), + "run_cmd should contain 'bun src/index.ts': {}", + info.run_cmd + ); +} + +/// PROJ-05: load_project_info prefers manifest over auto-detection when both exist. +#[test] +fn load_project_info_prefers_manifest_over_auto_detect() { + let dir = tempfile::tempdir().unwrap(); + // Create package.json (would auto-detect as node/npm) + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + // Create manifest (should take precedence) + let yaml = r#" +name: manifest-worker +runtime: + language: typescript + package_manager: npm + entry: src/index.ts +"#; + std::fs::write(dir.path().join(WORKER_MANIFEST), yaml).unwrap(); + + let info = load_project_info(dir.path()).unwrap(); + assert_eq!( + info.name, "manifest-worker", + "manifest should take precedence over auto-detection" + ); +} diff --git a/crates/iii-worker/tests/vm_args_integration.rs b/crates/iii-worker/tests/vm_args_integration.rs new file mode 100644 index 000000000..31a686476 --- /dev/null +++ b/crates/iii-worker/tests/vm_args_integration.rs @@ -0,0 +1,638 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Ungated integration tests for VM argument construction, mount parsing, +//! environment variable propagation, signal handling, PID management, +//! path helper verification, and firmware resolution. +//! Covers VM-01 through VM-04 requirements at the pure-function level. +//! These tests run in default `cargo test` without requiring libkrun. + +mod common; + +use clap::Parser; +use iii_worker::cli::lifecycle::build_container_spec; +use iii_worker::cli::vm_boot::{ + build_worker_cmd, resolve_krunfw_file_path, rewrite_localhost, shell_quote, VmBootArgs, +}; +use iii_worker::cli::worker_manager::adapter::RuntimeAdapter; +use iii_worker::cli::worker_manager::libkrun::{k8s_mem_to_mib, LibkrunAdapter}; +use iii_worker::cli::worker_manager::state::{WorkerDef, WorkerResources}; +use std::collections::HashMap; + +/// Helper wrapper for clap parsing of VmBootArgs. +#[derive(Parser)] +struct TestCli { + #[command(flatten)] + args: VmBootArgs, +} + +// =========================================================================== +// Group 1: VmBootArgs parsing (VM-01, per D-06) +// =========================================================================== + +#[test] +fn vm_boot_args_all_fields() { + let cli = TestCli::parse_from([ + "test", + "--rootfs", + "/tmp/rootfs", + "--exec", + "/usr/bin/node", + "--workdir", + "/workspace", + "--vcpus", + "4", + "--ram", + "1024", + "--env", + "FOO=bar", + "--mount", + "/host:/guest", + "--arg", + "script.js", + "--slot", + "42", + "--pid-file", + "/tmp/vm.pid", + "--console-output", + "/tmp/console.log", + ]); + assert_eq!(cli.args.rootfs, "/tmp/rootfs"); + assert_eq!(cli.args.exec, "/usr/bin/node"); + assert_eq!(cli.args.workdir, "/workspace"); + assert_eq!(cli.args.vcpus, 4); + assert_eq!(cli.args.ram, 1024); + assert_eq!(cli.args.env, vec!["FOO=bar"]); + assert_eq!(cli.args.mount, vec!["/host:/guest"]); + assert_eq!(cli.args.arg, vec!["script.js"]); + assert_eq!(cli.args.slot, 42); + assert_eq!(cli.args.pid_file.as_deref(), Some("/tmp/vm.pid")); + assert_eq!( + cli.args.console_output.as_deref(), + Some("/tmp/console.log") + ); +} + +#[test] +fn vm_boot_args_defaults() { + let cli = TestCli::parse_from(["test", "--rootfs", "/rootfs", "--exec", "/bin/app"]); + assert_eq!(cli.args.rootfs, "/rootfs"); + assert_eq!(cli.args.exec, "/bin/app"); + assert_eq!(cli.args.workdir, "/"); + assert_eq!(cli.args.vcpus, 2); + assert_eq!(cli.args.ram, 2048); + assert_eq!(cli.args.slot, 0); + assert!(cli.args.mount.is_empty()); + assert!(cli.args.env.is_empty()); + assert!(cli.args.arg.is_empty()); + assert!(cli.args.pid_file.is_none()); + assert!(cli.args.console_output.is_none()); +} + +#[test] +fn vm_boot_args_hyphen_prefixed_args() { + let cli = TestCli::parse_from([ + "test", "--rootfs", "/r", "--exec", "/e", "--arg", "--port", "--arg", "3000", "--arg", + "-v", + ]); + assert_eq!(cli.args.arg, vec!["--port", "3000", "-v"]); +} + +#[test] +fn vm_boot_args_multiple_envs() { + let cli = TestCli::parse_from([ + "test", + "--rootfs", + "/r", + "--exec", + "/e", + "--env", + "FOO=bar", + "--env", + "BAZ=qux", + "--env", + "URL=http://host:8080", + ]); + assert_eq!(cli.args.env.len(), 3); + assert_eq!(cli.args.env[0], "FOO=bar"); + assert_eq!(cli.args.env[1], "BAZ=qux"); + assert_eq!(cli.args.env[2], "URL=http://host:8080"); +} + +#[test] +fn vm_boot_args_multiple_mounts() { + let cli = TestCli::parse_from([ + "test", + "--rootfs", + "/r", + "--exec", + "/e", + "--mount", + "/src:/workspace", + "--mount", + "/data:/mnt/data", + "--mount", + "/cache:/tmp/cache", + ]); + assert_eq!(cli.args.mount.len(), 3); + assert_eq!(cli.args.mount[0], "/src:/workspace"); + assert_eq!(cli.args.mount[1], "/data:/mnt/data"); + assert_eq!(cli.args.mount[2], "/cache:/tmp/cache"); +} + +// =========================================================================== +// Group 2: shell_quote (VM-01, per D-04) +// =========================================================================== + +#[test] +fn shell_quote_safe_chars_passthrough() { + assert_eq!(shell_quote("simple"), "simple"); + assert_eq!(shell_quote("/usr/bin/node"), "/usr/bin/node"); + assert_eq!(shell_quote("key=value"), "key=value"); + assert_eq!(shell_quote("a-b_c.d:e/f"), "a-b_c.d:e/f"); +} + +#[test] +fn shell_quote_spaces_get_quoted() { + assert_eq!(shell_quote("has space"), "'has space'"); +} + +#[test] +fn shell_quote_embedded_single_quotes() { + assert_eq!(shell_quote("it's a test"), "'it'\\''s a test'"); +} + +#[test] +fn shell_quote_special_chars() { + let result = shell_quote("$(cmd)"); + assert!(result.starts_with('\''), "$(cmd) should be quoted"); + let result2 = shell_quote("a;b"); + assert!(result2.starts_with('\''), "a;b should be quoted"); +} + +#[test] +fn shell_quote_empty_string() { + // Empty string has no unsafe chars, passes through unchanged. + assert_eq!(shell_quote(""), ""); +} + +// =========================================================================== +// Group 3: build_worker_cmd (VM-01, per D-04) +// =========================================================================== + +#[test] +fn build_worker_cmd_no_args() { + assert_eq!(build_worker_cmd("/usr/bin/node", &[]), "/usr/bin/node"); +} + +#[test] +fn build_worker_cmd_with_args() { + let args = vec![ + "script.js".to_string(), + "--port".to_string(), + "3000".to_string(), + ]; + assert_eq!( + build_worker_cmd("/usr/bin/node", &args), + "/usr/bin/node script.js --port 3000" + ); +} + +#[test] +fn build_worker_cmd_with_spaces_in_exec() { + let result = build_worker_cmd("/path/to/my app", &[]); + assert!( + result.contains('\''), + "exec path with spaces should be quoted: {}", + result + ); +} + +#[test] +fn build_worker_cmd_with_special_arg() { + let args = vec!["it's".to_string()]; + let result = build_worker_cmd("/bin/sh", &args); + assert!( + result.contains("\\'"), + "argument with single quote should be escaped: {}", + result + ); +} + +// =========================================================================== +// Group 4: k8s_mem_to_mib (VM-01, per D-04) +// =========================================================================== + +#[test] +fn k8s_mem_to_mib_mi() { + assert_eq!(k8s_mem_to_mib("512Mi"), Some("512".into())); +} + +#[test] +fn k8s_mem_to_mib_gi() { + assert_eq!(k8s_mem_to_mib("2Gi"), Some("2048".into())); +} + +#[test] +fn k8s_mem_to_mib_ki() { + assert_eq!(k8s_mem_to_mib("1048576Ki"), Some("1024".into())); +} + +#[test] +fn k8s_mem_to_mib_bytes() { + assert_eq!(k8s_mem_to_mib("2147483648"), Some("2048".into())); +} + +#[test] +fn k8s_mem_to_mib_invalid() { + assert_eq!(k8s_mem_to_mib("not-a-number"), None); +} + +#[test] +fn k8s_mem_to_mib_zero() { + assert_eq!(k8s_mem_to_mib("0"), Some("0".into())); +} + +// =========================================================================== +// Group 5: Mount format via VmBootArgs (VM-02, per D-08) +// =========================================================================== + +#[test] +fn vm_boot_args_mount_valid_format() { + let cli = TestCli::parse_from([ + "test", "--rootfs", "/r", "--exec", "/e", "--mount", "/host:/guest", + ]); + assert_eq!(cli.args.mount[0], "/host:/guest"); +} + +#[test] +fn vm_boot_args_mount_no_colon_stored_as_is() { + // VmBootArgs stores the string as-is; validation happens at boot_vm time. + let cli = TestCli::parse_from([ + "test", + "--rootfs", + "/r", + "--exec", + "/e", + "--mount", + "invalid-no-colon", + ]); + assert_eq!(cli.args.mount[0], "invalid-no-colon"); +} + +#[test] +fn vm_boot_args_mount_empty_guest() { + let cli = TestCli::parse_from([ + "test", "--rootfs", "/r", "--exec", "/e", "--mount", "/host:", + ]); + assert_eq!(cli.args.mount[0], "/host:"); +} + +// =========================================================================== +// Group 6: rewrite_localhost (VM-03, per D-09) +// =========================================================================== + +#[test] +fn rewrite_localhost_replaces_localhost() { + assert_eq!( + rewrite_localhost("http://localhost:3000/api", "192.168.1.1"), + "http://192.168.1.1:3000/api" + ); +} + +#[test] +fn rewrite_localhost_replaces_loopback() { + assert_eq!( + rewrite_localhost("ws://127.0.0.1:8080/ws", "10.0.0.1"), + "ws://10.0.0.1:8080/ws" + ); +} + +#[test] +fn rewrite_localhost_both_in_one_string() { + let result = rewrite_localhost("http://localhost:3000 ws://127.0.0.1:8080", "10.0.0.1"); + assert!( + result.contains("10.0.0.1:3000"), + "should rewrite localhost: {}", + result + ); + assert!( + result.contains("10.0.0.1:8080"), + "should rewrite 127.0.0.1: {}", + result + ); +} + +#[test] +fn rewrite_localhost_no_match_unchanged() { + assert_eq!( + rewrite_localhost("http://example.com:3000", "10.0.0.1"), + "http://example.com:3000" + ); +} + +#[test] +fn rewrite_localhost_no_port_unchanged() { + // Only matches "://localhost:" pattern (requires colon after hostname). + assert_eq!( + rewrite_localhost("localhost without colon", "10.0.0.1"), + "localhost without colon" + ); +} + +// =========================================================================== +// Group 7: build_container_spec (VM-01, per D-06) +// =========================================================================== + +#[test] +fn build_container_spec_managed_with_resources() { + let mut env = HashMap::new(); + env.insert( + "III_ENGINE_URL".to_string(), + "ws://localhost:49134".to_string(), + ); + let def = WorkerDef::Managed { + image: "ghcr.io/iii-hq/worker:latest".to_string(), + env, + resources: Some(WorkerResources { + cpus: Some("4".to_string()), + memory: Some("4096Mi".to_string()), + }), + }; + let spec = build_container_spec("vm-worker", &def, "ws://localhost:49134"); + assert_eq!(spec.name, "vm-worker"); + assert_eq!(spec.image, "ghcr.io/iii-hq/worker:latest"); + assert_eq!(spec.cpu_limit.as_deref(), Some("4")); + assert_eq!(spec.memory_limit.as_deref(), Some("4096Mi")); + assert!(spec.env.contains_key("III_ENGINE_URL")); +} + +#[test] +fn build_container_spec_managed_no_resources() { + let def = WorkerDef::Managed { + image: "ghcr.io/iii-hq/worker:latest".to_string(), + env: HashMap::new(), + resources: None, + }; + let spec = build_container_spec("vm-worker", &def, "ws://localhost:49134"); + assert!(spec.cpu_limit.is_none()); + assert!(spec.memory_limit.is_none()); +} + +#[test] +fn build_container_spec_binary_has_no_spec_fields() { + let def = WorkerDef::Binary { + version: "0.1.2".to_string(), + config: None, + }; + let spec = build_container_spec("bin-worker", &def, "ws://localhost:49134"); + assert!(spec.image.is_empty()); + assert!(spec.env.is_empty()); + assert!(spec.cpu_limit.is_none()); + assert!(spec.memory_limit.is_none()); +} + +// =========================================================================== +// Group 8: Signal handling via LibkrunAdapter (VM-04, per D-10, D-11, D-12) +// =========================================================================== + +#[tokio::test] +async fn stop_terminates_sleeping_process() { + let mut child = std::process::Command::new("sleep") + .arg("60") + .spawn() + .expect("failed to spawn sleep"); + let pid = child.id(); + // Forget the child so our Drop does not interfere with stop()'s signal handling. + std::mem::forget(child); + + let adapter = LibkrunAdapter::new(); + let pid_str = pid.to_string(); + + // Record start time. With timeout=5s, if SIGTERM works immediately, + // stop() should return well under 3 seconds. If it had to wait for + // SIGKILL escalation, it would take ~5 seconds. + let start = std::time::Instant::now(); + adapter.stop(&pid_str, 5).await.expect("stop should succeed"); + let elapsed = start.elapsed(); + + assert!( + elapsed < std::time::Duration::from_secs(3), + "stop() took {:?}, expected < 3s (SIGTERM should kill sleep immediately)", + elapsed + ); + + // Verify the process is dead. + let alive = unsafe { nix::libc::kill(pid as i32, 0) == 0 }; + assert!(!alive, "process {} should be dead after stop()", pid); +} + +#[tokio::test] +async fn stop_escalates_to_sigkill_when_sigterm_ignored() { + // Spawn a process that traps (ignores) SIGTERM. + let mut child = std::process::Command::new("sh") + .arg("-c") + .arg("trap '' TERM; sleep 60") + .spawn() + .expect("failed to spawn trap process"); + let pid = child.id(); + std::mem::forget(child); + + let adapter = LibkrunAdapter::new(); + let pid_str = pid.to_string(); + + // Use a 1-second timeout to force SIGKILL escalation quickly. + adapter.stop(&pid_str, 1).await.expect("stop should succeed"); + + // Give a moment for the kernel to reap the killed process. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + let alive = unsafe { nix::libc::kill(pid as i32, 0) == 0 }; + assert!( + !alive, + "process {} should be dead after SIGKILL escalation", + pid + ); +} + +#[tokio::test] +async fn stop_handles_already_dead_process() { + // Spawn a process that exits immediately. + let child = std::process::Command::new("sleep") + .arg("0") + .spawn() + .expect("failed to spawn sleep 0"); + let pid = child.id(); + // Wait a bit for it to exit. + std::thread::sleep(std::time::Duration::from_millis(200)); + + let adapter = LibkrunAdapter::new(); + let pid_str = pid.to_string(); + let result = adapter.stop(&pid_str, 1).await; + assert!(result.is_ok(), "stop on dead process should not error"); +} + +// =========================================================================== +// Group 9: PID alive and status (VM-04, per D-15, D-16) +// =========================================================================== + +#[tokio::test] +async fn status_running_process_detected() { + let adapter = LibkrunAdapter::new(); + // Use our own PID -- we know it is alive. + let our_pid = std::process::id().to_string(); + let status = adapter + .status(&our_pid) + .await + .expect("status should succeed"); + assert!( + status.running, + "our own process should be detected as running" + ); +} + +#[tokio::test] +async fn status_dead_process_detected() { + let adapter = LibkrunAdapter::new(); + // PID 99999999 is almost certainly not running. + let status = adapter + .status("99999999") + .await + .expect("status should succeed"); + assert!(!status.running, "PID 99999999 should not be running"); +} + +#[tokio::test] +async fn status_invalid_pid_zero() { + let adapter = LibkrunAdapter::new(); + let status = adapter.status("0").await.expect("status should succeed"); + assert!(!status.running, "PID 0 should not be reported as running"); +} + +// =========================================================================== +// Group 10: PID file roundtrip (VM-04, per D-14) +// =========================================================================== + +#[test] +fn pid_file_write_and_read_roundtrip() { + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let pid_path = dir.path().join("vm.pid"); + std::fs::write(&pid_path, "12345").expect("failed to write PID file"); + let content = std::fs::read_to_string(&pid_path).expect("failed to read PID file"); + assert_eq!(content, "12345"); +} + +// =========================================================================== +// Group 11: LibkrunAdapter path helpers (VM-01, VM-04, per D-04) +// =========================================================================== + +#[test] +fn worker_dir_constructs_path_under_managed() { + let path = LibkrunAdapter::worker_dir("my-worker"); + let path_str = path.to_string_lossy(); + assert!( + path_str.contains(".iii/managed/my-worker"), + "worker_dir should contain .iii/managed/my-worker, got: {}", + path_str + ); + // Verify the path ends with the expected segments. + let components: Vec<_> = path + .components() + .map(|c| c.as_os_str().to_string_lossy().to_string()) + .collect(); + let len = components.len(); + assert!(len >= 3, "path should have at least 3 components"); + assert_eq!(components[len - 3], ".iii"); + assert_eq!(components[len - 2], "managed"); + assert_eq!(components[len - 1], "my-worker"); +} + +#[test] +fn pid_file_is_under_worker_dir() { + let pid = LibkrunAdapter::pid_file("test-vm"); + let pid_str = pid.to_string_lossy(); + assert!( + pid_str.contains(".iii/managed/test-vm/vm.pid"), + "pid_file should end with .iii/managed/test-vm/vm.pid, got: {}", + pid_str + ); + assert_eq!( + pid.parent().unwrap(), + LibkrunAdapter::worker_dir("test-vm"), + "pid_file parent should equal worker_dir" + ); +} + +#[test] +fn logs_dir_is_under_worker_dir() { + let logs = LibkrunAdapter::logs_dir("test-vm"); + let logs_str = logs.to_string_lossy(); + assert!( + logs_str.contains(".iii/managed/test-vm/logs"), + "logs_dir should end with .iii/managed/test-vm/logs, got: {}", + logs_str + ); + assert_eq!( + logs.parent().unwrap(), + LibkrunAdapter::worker_dir("test-vm"), + "logs_dir parent should equal worker_dir" + ); +} + +#[test] +fn image_rootfs_uses_sha256_hash_prefix() { + let path_a = LibkrunAdapter::image_rootfs("ghcr.io/iii-hq/worker:latest"); + let path_a_str = path_a.to_string_lossy(); + assert!( + path_a_str.contains(".iii/images/"), + "image_rootfs should contain .iii/images/, got: {}", + path_a_str + ); + + // Different image strings should produce different hashes. + let path_b = LibkrunAdapter::image_rootfs("docker.io/library/nginx:alpine"); + assert_ne!( + path_a.file_name(), + path_b.file_name(), + "different images should have different hash directories" + ); +} + +#[test] +fn path_helpers_different_names_different_paths() { + assert_ne!( + LibkrunAdapter::worker_dir("a"), + LibkrunAdapter::worker_dir("b"), + "different names should produce different worker_dir paths" + ); + assert_ne!( + LibkrunAdapter::pid_file("a"), + LibkrunAdapter::pid_file("b"), + "different names should produce different pid_file paths" + ); +} + +// =========================================================================== +// Group 12: resolve_krunfw_file_path (VM-01, per D-04) +// =========================================================================== + +#[test] +fn resolve_krunfw_file_path_returns_option() { + // In a test environment without firmware installed, this should return None. + // The function is callable and returns the correct type. + let result = resolve_krunfw_file_path(); + // We cannot guarantee Some or None depending on the host, but in CI/test + // environments firmware is typically not installed. The key assertion is + // that the function is callable from integration tests (pub visibility works). + assert!( + result.is_none() || result.is_some(), + "resolve_krunfw_file_path should return a valid Option" + ); + // If firmware IS present, verify the path exists. + if let Some(ref path) = result { + assert!(path.exists(), "returned firmware path should exist"); + } +} diff --git a/crates/iii-worker/tests/vm_gate_smoke.rs b/crates/iii-worker/tests/vm_gate_smoke.rs new file mode 100644 index 000000000..821991640 --- /dev/null +++ b/crates/iii-worker/tests/vm_gate_smoke.rs @@ -0,0 +1,18 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Smoke test: proves the integration-vm feature gate compiles and activates. +//! Real VM tests are added in Phase 5. + +#![cfg(feature = "integration-vm")] + +#[test] +fn vm_feature_gate_active() { + assert!( + cfg!(feature = "integration-vm"), + "integration-vm feature should be active" + ); +} diff --git a/crates/iii-worker/tests/vm_integration.rs b/crates/iii-worker/tests/vm_integration.rs new file mode 100644 index 000000000..9f5d95e54 --- /dev/null +++ b/crates/iii-worker/tests/vm_integration.rs @@ -0,0 +1,460 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Feature-gated VM integration tests. +//! Run with: cargo test -p iii-worker --test vm_integration --features integration-vm +//! +//! These tests verify the argument construction patterns used by LibkrunAdapter::start(), +//! environment variable merging, path conventions, adapter lifecycle contracts, and +//! actual __vm-boot subprocess argument passing. + +#![cfg(feature = "integration-vm")] + +mod common; + +use clap::Parser; +use iii_worker::cli::vm_boot::{rewrite_localhost, VmBootArgs}; +use iii_worker::cli::worker_manager::adapter::RuntimeAdapter; +use iii_worker::cli::worker_manager::libkrun::LibkrunAdapter; +use std::collections::HashMap; + +/// Helper wrapper for clap parsing of VmBootArgs. +#[derive(Parser)] +struct TestCli { + #[command(flatten)] + args: VmBootArgs, +} + +// =========================================================================== +// Group 1: Feature gate verification (sanity check) +// =========================================================================== + +#[test] +fn vm_feature_gate_compiles() { + assert!( + cfg!(feature = "integration-vm"), + "integration-vm feature should be active when this file compiles" + ); +} + +// =========================================================================== +// Group 2: Realistic VmBootArgs matching start() pattern (VM-01, per D-07) +// =========================================================================== + +/// Parse args mimicking what LibkrunAdapter::start() actually constructs: +/// rootfs, exec, workdir, vcpus, ram, env vars, mount, args, pid-file, +/// console-output, and slot -- matching the real argument list from start(). +#[test] +fn vm_boot_args_realistic_managed_worker() { + let cli = TestCli::parse_from([ + "test", + "--rootfs", + "/home/user/.iii/managed/my-worker/rootfs", + "--exec", + "/usr/bin/node", + "--workdir", + "/workspace", + "--vcpus", + "4", + "--ram", + "4096", + "--env", + "III_ENGINE_URL=ws://localhost:49134", + "--env", + "III_WORKER_NAME=my-worker", + "--mount", + "/home/user/project:/workspace", + "--arg", + "--url", + "--arg", + "ws://localhost:49134", + "--pid-file", + "/home/user/.iii/managed/my-worker/vm.pid", + "--console-output", + "/home/user/.iii/managed/my-worker/logs/stdout.log", + "--slot", + "1", + ]); + assert_eq!( + cli.args.rootfs, + "/home/user/.iii/managed/my-worker/rootfs" + ); + assert_eq!(cli.args.exec, "/usr/bin/node"); + assert_eq!(cli.args.workdir, "/workspace"); + assert_eq!(cli.args.vcpus, 4); + assert_eq!(cli.args.ram, 4096); + assert_eq!( + cli.args.env, + vec![ + "III_ENGINE_URL=ws://localhost:49134", + "III_WORKER_NAME=my-worker" + ] + ); + assert_eq!(cli.args.mount, vec!["/home/user/project:/workspace"]); + assert_eq!(cli.args.arg, vec!["--url", "ws://localhost:49134"]); + assert_eq!( + cli.args.pid_file.as_deref(), + Some("/home/user/.iii/managed/my-worker/vm.pid") + ); + assert_eq!( + cli.args.console_output.as_deref(), + Some("/home/user/.iii/managed/my-worker/logs/stdout.log") + ); + assert_eq!(cli.args.slot, 1); +} + +/// Verify that the PID file path convention from start() matches the +/// LibkrunAdapter::pid_file() helper. +#[test] +fn vm_boot_args_start_uses_correct_pid_file_path() { + let expected_pid_path = LibkrunAdapter::pid_file("test-worker"); + let expected_pid_str = expected_pid_path.to_string_lossy().to_string(); + + let cli = TestCli::parse_from([ + "test", + "--rootfs", + "/tmp/rootfs", + "--exec", + "/bin/sh", + "--pid-file", + &expected_pid_str, + ]); + assert_eq!( + cli.args.pid_file.as_deref(), + Some(expected_pid_str.as_str()), + "parsed --pid-file should match LibkrunAdapter::pid_file()" + ); + // Verify the path follows the worker_dir/vm.pid convention + assert!( + expected_pid_str.ends_with("vm.pid"), + "pid_file should end with vm.pid" + ); + assert!( + expected_pid_str.contains(".iii/managed/test-worker"), + "pid_file should be under .iii/managed/test-worker" + ); +} + +/// Verify that the console output path convention from start() matches +/// the logs_dir pattern: logs_dir(name).join("stdout.log"). +#[test] +fn vm_boot_args_start_uses_correct_console_output_path() { + let expected_log_path = LibkrunAdapter::logs_dir("test-worker").join("stdout.log"); + let expected_log_str = expected_log_path.to_string_lossy().to_string(); + + let cli = TestCli::parse_from([ + "test", + "--rootfs", + "/tmp/rootfs", + "--exec", + "/bin/sh", + "--console-output", + &expected_log_str, + ]); + assert_eq!( + cli.args.console_output.as_deref(), + Some(expected_log_str.as_str()), + "parsed --console-output should match logs_dir/stdout.log" + ); + // Verify the path follows the expected convention + assert!( + expected_log_str.ends_with("logs/stdout.log"), + "console_output should end with logs/stdout.log" + ); + assert!( + expected_log_str.contains(".iii/managed/test-worker"), + "console_output should be under .iii/managed/test-worker" + ); +} + +/// Parse with vcpus at u8 boundary and beyond. VmBootArgs accepts u32; +/// the u8 check happens at boot_vm runtime, not at parse time. +#[test] +fn vm_boot_args_max_vcpus_at_u8_boundary() { + // u8::MAX (255) should parse fine + let cli = TestCli::parse_from([ + "test", "--rootfs", "/r", "--exec", "/e", "--vcpus", "255", + ]); + assert_eq!(cli.args.vcpus, 255); + + // 256 should also parse (VmBootArgs field is u32) + let cli = TestCli::parse_from([ + "test", "--rootfs", "/r", "--exec", "/e", "--vcpus", "256", + ]); + assert_eq!(cli.args.vcpus, 256); + + // Large value within u32 range + let cli = TestCli::parse_from([ + "test", "--rootfs", "/r", "--exec", "/e", "--vcpus", "65535", + ]); + assert_eq!(cli.args.vcpus, 65535); +} + +// =========================================================================== +// Group 3: Environment variable merging pattern (VM-03, per D-09) +// =========================================================================== + +/// Simulate the env merging pattern from LibkrunAdapter::start() lines 422-429. +/// Image env is loaded first, then spec env overwrites matching keys. +#[test] +fn env_merger_image_env_overridden_by_spec_env() { + // Simulate image env (from OCI config) + let mut image_env: HashMap = HashMap::new(); + image_env.insert("PATH".to_string(), "/usr/bin".to_string()); + image_env.insert("LANG".to_string(), "C".to_string()); + + // Simulate spec env (from ContainerSpec) + let mut spec_env: HashMap = HashMap::new(); + spec_env.insert("PATH".to_string(), "/custom/bin".to_string()); + spec_env.insert("III_URL".to_string(), "ws://host:1234".to_string()); + + // Merge: start with image_env, then spec overwrites (exact pattern from start()) + let mut merged_env: HashMap = image_env.into_iter().collect(); + for (key, value) in &spec_env { + merged_env.insert(key.clone(), value.clone()); + } + + // spec's PATH should overwrite image's PATH + assert_eq!( + merged_env.get("PATH").unwrap(), + "/custom/bin", + "spec env should overwrite image env for PATH" + ); + // III_URL from spec should be present + assert_eq!( + merged_env.get("III_URL").unwrap(), + "ws://host:1234", + "spec env should add III_URL" + ); + // LANG from image should be preserved (not in spec) + assert_eq!( + merged_env.get("LANG").unwrap(), + "C", + "image env LANG should be preserved when not overridden" + ); + // Total: 3 keys (PATH overwritten, LANG preserved, III_URL added) + assert_eq!(merged_env.len(), 3); +} + +/// Verify that rewrite_localhost correctly transforms environment variable +/// values containing localhost URLs, as happens during VM boot. +#[test] +fn env_values_with_localhost_get_rewritten() { + let env_vars: Vec<(&str, &str)> = vec![ + ("III_ENGINE_URL", "ws://localhost:49134"), + ("DATABASE_URL", "postgres://127.0.0.1:5432/db"), + ("SIMPLE_VAR", "no-url-here"), + ]; + let gateway_ip = "192.168.64.2"; + + let rewritten: Vec<(String, String)> = env_vars + .iter() + .map(|(k, v)| (k.to_string(), rewrite_localhost(v, gateway_ip))) + .collect(); + + // III_ENGINE_URL: localhost -> gateway_ip + let iii_url = &rewritten[0].1; + assert!( + iii_url.contains("192.168.64.2:49134"), + "III_ENGINE_URL should have rewritten localhost: {}", + iii_url + ); + assert!( + !iii_url.contains("localhost"), + "III_ENGINE_URL should not contain localhost after rewrite: {}", + iii_url + ); + + // DATABASE_URL: 127.0.0.1 -> gateway_ip + let db_url = &rewritten[1].1; + assert!( + db_url.contains("192.168.64.2:5432"), + "DATABASE_URL should have rewritten 127.0.0.1: {}", + db_url + ); + assert!( + !db_url.contains("127.0.0.1"), + "DATABASE_URL should not contain 127.0.0.1 after rewrite: {}", + db_url + ); + + // SIMPLE_VAR: no URL, unchanged + assert_eq!( + rewritten[2].1, "no-url-here", + "SIMPLE_VAR should be unchanged" + ); +} + +// =========================================================================== +// Group 4: LibkrunAdapter lifecycle contracts (VM-04) +// =========================================================================== + +/// LibkrunAdapter is a zero-sized unit struct -- construction requires no +/// runtime dependencies. Its presence in a gated file proves the import +/// path compiles when integration-vm is active. +#[test] +fn libkrun_adapter_constructor_is_zero_cost() { + let adapter = LibkrunAdapter::new(); + // Also verify Default impl works + let _adapter2: LibkrunAdapter = Default::default(); + // The adapter is a unit struct -- no fields to inspect. + // The assertions are that construction succeeds without panic. + let _ = adapter; +} + +/// stop() should be a no-op for non-existent PIDs. status() should report +/// not-running for a PID that does not exist. +#[tokio::test] +async fn stop_and_status_contract_on_bogus_pid() { + let adapter = LibkrunAdapter::new(); + + // stop on a PID that almost certainly does not exist + let result = adapter.stop("99999999", 1).await; + assert!( + result.is_ok(), + "stop() on non-existent PID should return Ok: {:?}", + result + ); + + // status on a PID that almost certainly does not exist + let status = adapter + .status("99999999") + .await + .expect("status() should succeed"); + assert!( + !status.running, + "status() for non-existent PID should report not running" + ); + assert_eq!( + status.container_id, "99999999", + "status should echo back the container_id" + ); +} + +/// remove() should succeed even when no matching PID file exists in the +/// managed directory. +#[tokio::test] +async fn remove_with_nonexistent_worker_succeeds() { + let adapter = LibkrunAdapter::new(); + let result = adapter.remove("99999999").await; + assert!( + result.is_ok(), + "remove() on non-existent worker should return Ok: {:?}", + result + ); +} + +// =========================================================================== +// Group 5: Actual subprocess argument verification (VM-01, per D-05/D-07) +// =========================================================================== + +/// Spawn the real iii-worker __vm-boot subprocess with a valid temp directory +/// as rootfs. The subprocess will fail because the rootfs has no init binary +/// and no firmware, but its stderr output will contain the rootfs path -- +/// proving it received and used the --rootfs argument correctly. +#[test] +fn vm_boot_subprocess_receives_correct_rootfs_argument() { + let temp_dir = tempfile::TempDir::new().expect("failed to create temp dir"); + let temp_dir_path = temp_dir.path().to_string_lossy().to_string(); + + let binary = env!("CARGO_BIN_EXE_iii-worker"); + let output = std::process::Command::new(binary) + .arg("__vm-boot") + .arg("--rootfs") + .arg(&temp_dir_path) + .arg("--exec") + .arg("/usr/bin/node") + .arg("--workdir") + .arg("/workspace") + .arg("--vcpus") + .arg("2") + .arg("--ram") + .arg("2048") + .arg("--env") + .arg("III_ENGINE_URL=ws://localhost:49134") + .arg("--env") + .arg("III_WORKER_NAME=test-subprocess") + .arg("--arg") + .arg("--url") + .arg("--arg") + .arg("ws://localhost:49134") + .output() + .expect("failed to execute iii-worker __vm-boot subprocess"); + + // The subprocess should exit with non-zero status (no init binary) + assert!( + !output.status.success(), + "subprocess should fail (no init binary in rootfs)" + ); + + let stderr_str = String::from_utf8_lossy(&output.stderr); + + // The key assertion: stderr contains the rootfs path, proving the + // subprocess received the --rootfs argument and attempted to use it. + // The error comes from vm_boot::run() or boot_vm() which includes + // the rootfs path in its error message. + assert!( + stderr_str.contains(&temp_dir_path), + "stderr should contain the rootfs path '{}' proving argument receipt.\nActual stderr: {}", + temp_dir_path, + stderr_str + ); + + // Verify the error message is from the expected validation path + let is_known_error = stderr_str.contains("No init binary available") + || stderr_str.contains("VM execution failed") + || stderr_str.contains("PassthroughFs failed"); + assert!( + is_known_error, + "stderr should contain a known VM boot error message.\nActual stderr: {}", + stderr_str + ); +} + +/// Spawn the real iii-worker __vm-boot subprocess with a nonexistent rootfs +/// path. The subprocess should fail immediately with the early validation +/// error in vm_boot::run() and report the path in stderr. +#[test] +fn vm_boot_subprocess_rejects_nonexistent_rootfs() { + let binary = env!("CARGO_BIN_EXE_iii-worker"); + let nonexistent_path = "/nonexistent/path/that/does/not/exist"; + + let output = std::process::Command::new(binary) + .arg("__vm-boot") + .arg("--rootfs") + .arg(nonexistent_path) + .arg("--exec") + .arg("/bin/sh") + .arg("--vcpus") + .arg("1") + .arg("--ram") + .arg("512") + .output() + .expect("failed to execute iii-worker __vm-boot subprocess"); + + // The subprocess should exit with non-zero status + assert!( + !output.status.success(), + "subprocess should fail for nonexistent rootfs" + ); + + let stderr_str = String::from_utf8_lossy(&output.stderr); + + // Verify the early validation message from run() line 143-145 + assert!( + stderr_str.contains("rootfs path does not exist"), + "stderr should contain 'rootfs path does not exist'.\nActual stderr: {}", + stderr_str + ); + + // Verify the nonexistent path appears in stderr + assert!( + stderr_str.contains(nonexistent_path), + "stderr should contain the nonexistent path '{}'.\nActual stderr: {}", + nonexistent_path, + stderr_str + ); +} diff --git a/crates/iii-worker/tests/worker_integration.rs b/crates/iii-worker/tests/worker_integration.rs index 4fb9efd1b..3b8022363 100644 --- a/crates/iii-worker/tests/worker_integration.rs +++ b/crates/iii-worker/tests/worker_integration.rs @@ -3,7 +3,10 @@ //! These tests import the real `Cli`, `Commands`, and `VmBootArgs` types from //! the crate library, ensuring any CLI changes are caught at compile time. +mod common; + use clap::Parser; +use common::isolation::in_temp_dir; use iii_worker::{Cli, Commands, VmBootArgs}; /// All 10 subcommands parse without error. @@ -178,8 +181,9 @@ fn vm_boot_args_defaults() { /// Manifest YAML roundtrip (serde pattern test, kept as-is). #[test] fn manifest_yaml_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let yaml = r#" + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + let yaml = r#" name: integration-test-worker runtime: language: typescript @@ -192,17 +196,18 @@ resources: cpus: 4 memory: 4096 "#; - std::fs::write(dir.path().join("iii.worker.yaml"), yaml).unwrap(); + std::fs::write(dir.join("iii.worker.yaml"), yaml).unwrap(); - let content = std::fs::read_to_string(dir.path().join("iii.worker.yaml")).unwrap(); - let parsed: serde_yaml::Value = serde_yaml::from_str(&content).unwrap(); + let content = std::fs::read_to_string(dir.join("iii.worker.yaml")).unwrap(); + let parsed: serde_yaml::Value = serde_yaml::from_str(&content).unwrap(); - assert_eq!(parsed["name"].as_str(), Some("integration-test-worker")); - assert_eq!(parsed["runtime"]["language"].as_str(), Some("typescript")); - assert_eq!(parsed["runtime"]["package_manager"].as_str(), Some("npm")); - assert_eq!(parsed["env"]["NODE_ENV"].as_str(), Some("production")); - assert_eq!(parsed["resources"]["cpus"].as_u64(), Some(4)); - assert_eq!(parsed["resources"]["memory"].as_u64(), Some(4096)); + assert_eq!(parsed["name"].as_str(), Some("integration-test-worker")); + assert_eq!(parsed["runtime"]["language"].as_str(), Some("typescript")); + assert_eq!(parsed["runtime"]["package_manager"].as_str(), Some("npm")); + assert_eq!(parsed["env"]["NODE_ENV"].as_str(), Some("production")); + assert_eq!(parsed["resources"]["cpus"].as_u64(), Some(4)); + assert_eq!(parsed["resources"]["memory"].as_u64(), Some(4096)); + }); } /// `add --force` parses the force flag correctly. @@ -350,33 +355,35 @@ fn clear_yes_flag() { /// OCI config JSON parsing (serde pattern test, kept as-is). #[test] fn oci_config_json_parsing() { - let dir = tempfile::tempdir().unwrap(); - let config = serde_json::json!({ - "config": { - "Entrypoint": ["/usr/bin/node"], - "Cmd": ["server.js", "--port", "8080"], - "Env": [ - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - "NODE_VERSION=20.11.0", - "HOME=/root" - ] - } - }); - std::fs::write( - dir.path().join(".oci-config.json"), - serde_json::to_string_pretty(&config).unwrap(), - ) - .unwrap(); + in_temp_dir(|| { + let dir = std::env::current_dir().unwrap(); + let config = serde_json::json!({ + "config": { + "Entrypoint": ["/usr/bin/node"], + "Cmd": ["server.js", "--port", "8080"], + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "NODE_VERSION=20.11.0", + "HOME=/root" + ] + } + }); + std::fs::write( + dir.join(".oci-config.json"), + serde_json::to_string_pretty(&config).unwrap(), + ) + .unwrap(); - let content = std::fs::read_to_string(dir.path().join(".oci-config.json")).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + let content = std::fs::read_to_string(dir.join(".oci-config.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); - let entrypoint = parsed["config"]["Entrypoint"].as_array().unwrap(); - assert_eq!(entrypoint[0].as_str(), Some("/usr/bin/node")); + let entrypoint = parsed["config"]["Entrypoint"].as_array().unwrap(); + assert_eq!(entrypoint[0].as_str(), Some("/usr/bin/node")); - let cmd = parsed["config"]["Cmd"].as_array().unwrap(); - assert_eq!(cmd.len(), 3); + let cmd = parsed["config"]["Cmd"].as_array().unwrap(); + assert_eq!(cmd.len(), 3); - let env = parsed["config"]["Env"].as_array().unwrap(); - assert_eq!(env.len(), 3); + let env = parsed["config"]["Env"].as_array().unwrap(); + assert_eq!(env.len(), 3); + }); } diff --git a/crates/iii-worker/tests/worker_manager_integration.rs b/crates/iii-worker/tests/worker_manager_integration.rs new file mode 100644 index 000000000..ceb32f072 --- /dev/null +++ b/crates/iii-worker/tests/worker_manager_integration.rs @@ -0,0 +1,337 @@ +// Copyright Motia LLC and/or licensed to Motia LLC under one or more +// contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. +// This software is patent protected. We welcome discussions - reach out at support@motia.dev +// See LICENSE and PATENTS files for details. + +//! Integration tests for worker manager: RuntimeAdapter dispatch, MockAdapter contract, +//! and WorkerDef serialization roundtrip. +//! Covers requirements WMGR-01 through WMGR-04. + +mod common; + +use iii_worker::cli::worker_manager::adapter::{ + ContainerSpec, ContainerStatus, ImageInfo, RuntimeAdapter, +}; +use iii_worker::cli::worker_manager::create_adapter; +use iii_worker::cli::worker_manager::state::{WorkerDef, WorkerResources}; +use std::collections::HashMap; +use std::sync::Arc; + +// --------------------------------------------------------------------------- +// MockAdapter: hand-written mock implementing RuntimeAdapter with canned Ok +// responses. Per project convention (CLAUDE.md): 30-line mock over mockall +// for a 6-method trait. +// --------------------------------------------------------------------------- + +struct MockAdapter; + +#[async_trait::async_trait] +impl RuntimeAdapter for MockAdapter { + async fn pull(&self, image: &str) -> anyhow::Result { + Ok(ImageInfo { + image: image.to_string(), + size_bytes: Some(1024), + }) + } + + async fn extract_file(&self, _image: &str, _path: &str) -> anyhow::Result> { + Ok(b"mock content".to_vec()) + } + + async fn start(&self, spec: &ContainerSpec) -> anyhow::Result { + Ok(format!("mock-{}", spec.name)) + } + + async fn stop(&self, _container_id: &str, _timeout_secs: u32) -> anyhow::Result<()> { + Ok(()) + } + + async fn status(&self, container_id: &str) -> anyhow::Result { + Ok(ContainerStatus { + name: "mock-worker".to_string(), + container_id: container_id.to_string(), + running: true, + exit_code: None, + }) + } + + async fn remove(&self, _container_id: &str) -> anyhow::Result<()> { + Ok(()) + } +} + +// =========================================================================== +// Group 1: RuntimeAdapter dispatch (WMGR-01, D-10) +// =========================================================================== + +/// create_adapter returns a valid trait object for the "libkrun" runtime string. +/// Verifies Arc is constructed without panic. +#[tokio::test] +async fn create_adapter_returns_valid_trait_object() { + let adapter: Arc = create_adapter("libkrun"); + // Verify the trait object is usable by calling status. + // The real LibkrunAdapter may fail (no running container), but it must not panic. + let result = adapter.status("nonexistent").await; + // We only care that the call completes (Ok or Err), not that it succeeds. + let _ = result; +} + +/// create_adapter with an unknown runtime string still returns a valid Arc. +/// Current implementation always returns LibkrunAdapter regardless of input. +#[tokio::test] +async fn create_adapter_with_unknown_runtime() { + let adapter: Arc = create_adapter("unknown"); + // Must not panic -- the adapter is valid even for unknown runtimes. + let result = adapter.status("nonexistent").await; + let _ = result; +} + +/// libkrun_available returns a bool without panicking. +/// The actual value depends on whether firmware is present on the test host. +#[test] +fn libkrun_available_returns_bool() { + let available: bool = iii_worker::cli::worker_manager::libkrun::libkrun_available(); + // Assert it is a valid bool (this is mainly a smoke test for no panic). + assert!(available || !available); +} + +// =========================================================================== +// Group 2: MockAdapter contract (WMGR-03, WMGR-04, D-11) +// =========================================================================== + +/// MockAdapter.pull returns ImageInfo with the requested image name. +#[tokio::test] +async fn mock_adapter_pull_returns_image_info() { + let adapter = MockAdapter; + let info = adapter.pull("test-image:latest").await.unwrap(); + assert_eq!(info.image, "test-image:latest"); + assert_eq!(info.size_bytes, Some(1024)); +} + +/// MockAdapter.extract_file returns canned content. +#[tokio::test] +async fn mock_adapter_extract_file_returns_content() { + let adapter = MockAdapter; + let content = adapter.extract_file("img", "/etc/config").await.unwrap(); + assert_eq!(content, b"mock content"); +} + +/// MockAdapter.start returns a container ID derived from the spec name. +#[tokio::test] +async fn mock_adapter_start_returns_container_id() { + let adapter = MockAdapter; + let spec = ContainerSpec { + name: "my-worker".to_string(), + image: "test:latest".to_string(), + env: HashMap::new(), + memory_limit: None, + cpu_limit: None, + }; + let id = adapter.start(&spec).await.unwrap(); + assert_eq!(id, "mock-my-worker"); +} + +/// MockAdapter.stop returns Ok(()) for clean shutdown. +#[tokio::test] +async fn mock_adapter_stop_returns_ok() { + let adapter = MockAdapter; + let result = adapter.stop("mock-123", 30).await; + assert!(result.is_ok()); +} + +/// MockAdapter.status returns ContainerStatus with running=true. +#[tokio::test] +async fn mock_adapter_status_returns_running() { + let adapter = MockAdapter; + let status = adapter.status("mock-123").await.unwrap(); + assert!(status.running); + assert_eq!(status.container_id, "mock-123"); + assert_eq!(status.name, "mock-worker"); + assert!(status.exit_code.is_none()); +} + +/// MockAdapter.remove returns Ok(()). +#[tokio::test] +async fn mock_adapter_remove_returns_ok() { + let adapter = MockAdapter; + let result = adapter.remove("mock-123").await; + assert!(result.is_ok()); +} + +/// MockAdapter can be used as Arc trait object, +/// verifying it is object-safe (same pattern as production create_adapter). +#[tokio::test] +async fn mock_adapter_as_trait_object() { + let adapter: Arc = Arc::new(MockAdapter); + let info = adapter.pull("trait-object-test:latest").await.unwrap(); + assert_eq!(info.image, "trait-object-test:latest"); +} + +// =========================================================================== +// Group 3: WorkerDef serialization roundtrip (WMGR-02, D-12, D-13) +// =========================================================================== + +/// WorkerDef::Managed roundtrip through serde_json preserves all fields. +#[test] +fn workerdef_managed_roundtrip() { + let mut env = HashMap::new(); + env.insert("KEY".to_string(), "value".to_string()); + + let original = WorkerDef::Managed { + image: "ghcr.io/iii-hq/test:latest".to_string(), + env: env.clone(), + resources: Some(WorkerResources { + cpus: Some("2".to_string()), + memory: Some("512Mi".to_string()), + }), + }; + + assert!(original.is_managed()); + assert!(!original.is_binary()); + + let serialized = serde_json::to_value(&original).unwrap(); + let deserialized: WorkerDef = serde_json::from_value(serialized).unwrap(); + + match deserialized { + WorkerDef::Managed { + image, + env: de_env, + resources, + } => { + assert_eq!(image, "ghcr.io/iii-hq/test:latest"); + assert_eq!(de_env.get("KEY").unwrap(), "value"); + let res = resources.unwrap(); + assert_eq!(res.cpus.unwrap(), "2"); + assert_eq!(res.memory.unwrap(), "512Mi"); + } + _ => panic!("expected Managed variant after roundtrip"), + } +} + +/// WorkerDef::Binary roundtrip through serde_json preserves version and config. +#[test] +fn workerdef_binary_roundtrip() { + let original = WorkerDef::Binary { + version: "1.2.3".to_string(), + config: Some(serde_json::json!({"key": "value"})), + }; + + assert!(original.is_binary()); + assert!(!original.is_managed()); + + let serialized = serde_json::to_value(&original).unwrap(); + let deserialized: WorkerDef = serde_json::from_value(serialized).unwrap(); + + match deserialized { + WorkerDef::Binary { version, config } => { + assert_eq!(version, "1.2.3"); + assert_eq!(config.unwrap()["key"], "value"); + } + _ => panic!("expected Binary variant after roundtrip"), + } +} + +/// JSON without a "type" field defaults to Managed variant on deserialization. +/// This tests the custom Deserialize impl's None branch. +#[test] +fn workerdef_missing_type_defaults_to_managed() { + let json = serde_json::json!({ + "image": "ghcr.io/iii-hq/legacy:latest", + "env": { "FOO": "bar" } + }); + + let def: WorkerDef = serde_json::from_value(json).unwrap(); + assert!(def.is_managed()); + + match def { + WorkerDef::Managed { image, env, .. } => { + assert_eq!(image, "ghcr.io/iii-hq/legacy:latest"); + assert_eq!(env.get("FOO").unwrap(), "bar"); + } + _ => panic!("expected Managed variant for input without type field"), + } +} + +/// JSON with an unknown "type" field produces a deserialization error. +/// Validates the custom Deserialize impl's error branch. +#[test] +fn workerdef_unknown_type_errors() { + let json = serde_json::json!({ + "type": "docker", + "image": "test:latest" + }); + + let result: Result = serde_json::from_value(json); + assert!(result.is_err(), "unknown type 'docker' should produce deserialization error"); +} + +/// WorkerDef::Managed with empty env and no resources roundtrips correctly. +#[test] +fn workerdef_managed_empty_env() { + let original = WorkerDef::Managed { + image: "ghcr.io/iii-hq/minimal:latest".to_string(), + env: HashMap::new(), + resources: None, + }; + + let serialized = serde_json::to_value(&original).unwrap(); + let deserialized: WorkerDef = serde_json::from_value(serialized).unwrap(); + + match deserialized { + WorkerDef::Managed { + image, + env, + resources, + } => { + assert_eq!(image, "ghcr.io/iii-hq/minimal:latest"); + assert!(env.is_empty()); + assert!(resources.is_none()); + } + _ => panic!("expected Managed variant"), + } +} + +/// WorkerDef::Binary with config=None roundtrips correctly. +#[test] +fn workerdef_binary_no_config() { + let original = WorkerDef::Binary { + version: "0.1.0".to_string(), + config: None, + }; + + let serialized = serde_json::to_value(&original).unwrap(); + let deserialized: WorkerDef = serde_json::from_value(serialized).unwrap(); + + match deserialized { + WorkerDef::Binary { version, config } => { + assert_eq!(version, "0.1.0"); + assert!(config.is_none()); + } + _ => panic!("expected Binary variant"), + } +} + +/// WorkerResources with mixed Some/None optional fields roundtrip correctly. +#[test] +fn workerdef_resources_optional_fields() { + // Case 1: cpus=Some, memory=None + let res1 = WorkerResources { + cpus: Some("4".to_string()), + memory: None, + }; + let json1 = serde_json::to_value(&res1).unwrap(); + let de_res1: WorkerResources = serde_json::from_value(json1).unwrap(); + assert_eq!(de_res1.cpus.as_deref(), Some("4")); + assert!(de_res1.memory.is_none()); + + // Case 2: cpus=None, memory=Some + let res2 = WorkerResources { + cpus: None, + memory: Some("1Gi".to_string()), + }; + let json2 = serde_json::to_value(&res2).unwrap(); + let de_res2: WorkerResources = serde_json::from_value(json2).unwrap(); + assert!(de_res2.cpus.is_none()); + assert_eq!(de_res2.memory.as_deref(), Some("1Gi")); +} From 57a4d33176b89f46c504fadb4da18db58f023325 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Fri, 10 Apr 2026 11:18:59 -0300 Subject: [PATCH 2/2] rust fixes cargo fmt --all -- --- .github/workflows/ci.yml | 3 +- crates/iii-worker/src/cli/binary_download.rs | 5 +- crates/iii-worker/src/cli/vm_boot.rs | 4 +- .../tests/binary_worker_integration.rs | 13 +-- .../iii-worker/tests/firmware_integration.rs | 43 ++++++--- .../tests/local_worker_integration.rs | 92 ++++++++++++++----- .../tests/oci_worker_integration.rs | 9 +- .../tests/project_detection_integration.rs | 7 +- .../iii-worker/tests/vm_args_integration.rs | 30 +++--- crates/iii-worker/tests/vm_integration.rs | 19 +--- .../tests/worker_manager_integration.rs | 5 +- 11 files changed, 148 insertions(+), 82 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac9b46a31..0c7687606 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,8 +302,7 @@ jobs: worker-test-oci-linux: name: Worker Tests (OCI) - linux - if: github.event_name == 'workflow_dispatch' - runs-on: [self-hosted, linux, oci] + runs-on: ubuntu-latest continue-on-error: true steps: - uses: actions/checkout@v4 diff --git a/crates/iii-worker/src/cli/binary_download.rs b/crates/iii-worker/src/cli/binary_download.rs index a28494241..da2f6fda8 100644 --- a/crates/iii-worker/src/cli/binary_download.rs +++ b/crates/iii-worker/src/cli/binary_download.rs @@ -107,7 +107,10 @@ pub fn checksum_download_url( /// Extracts a named binary from a tar.gz archive. /// /// Looks for an entry whose filename matches `binary_name` (ignoring directory prefixes). -pub fn extract_binary_from_targz(binary_name: &str, archive_bytes: &[u8]) -> Result, String> { +pub fn extract_binary_from_targz( + binary_name: &str, + archive_bytes: &[u8], +) -> Result, String> { let decoder = flate2::read::GzDecoder::new(archive_bytes); let mut archive = tar::Archive::new(decoder); diff --git a/crates/iii-worker/src/cli/vm_boot.rs b/crates/iii-worker/src/cli/vm_boot.rs index 341b196c9..221011beb 100644 --- a/crates/iii-worker/src/cli/vm_boot.rs +++ b/crates/iii-worker/src/cli/vm_boot.rs @@ -236,9 +236,7 @@ fn boot_vm(args: &VmBootArgs) -> Result { let guest_ip = network.guest_ipv4().to_string(); let gateway_ip = network.gateway_ipv4().to_string(); - let rewrite_localhost = |s: &str| -> String { - rewrite_localhost(s, &gateway_ip) - }; + let rewrite_localhost = |s: &str| -> String { rewrite_localhost(s, &gateway_ip) }; let worker_cmd = rewrite_localhost(&worker_cmd); let worker_heap_mib = (args.ram as u64 * 3 / 4).max(128); diff --git a/crates/iii-worker/tests/binary_worker_integration.rs b/crates/iii-worker/tests/binary_worker_integration.rs index 39b750f06..b0f648b33 100644 --- a/crates/iii-worker/tests/binary_worker_integration.rs +++ b/crates/iii-worker/tests/binary_worker_integration.rs @@ -11,8 +11,8 @@ mod common; use iii_worker::cli::binary_download::{ archive_extension, binary_download_url, binary_worker_path, binary_workers_dir, - checksum_download_url, current_target, download_and_install_binary, - extract_binary_from_targz, verify_sha256, + checksum_download_url, current_target, download_and_install_binary, extract_binary_from_targz, + verify_sha256, }; use sha2::{Digest, Sha256}; use std::sync::Mutex; @@ -26,8 +26,8 @@ static ENV_LOCK: Mutex<()> = Mutex::new(()); // --------------------------------------------------------------------------- fn make_targz(file_name: &str, content: &[u8]) -> Vec { - use flate2::write::GzEncoder; use flate2::Compression; + use flate2::write::GzEncoder; let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); { @@ -249,8 +249,8 @@ fn extract_binary_from_targz_finds_by_name() { /// BIN-01: extract_binary_from_targz finds binary in nested path (ignores directory prefix). #[test] fn extract_binary_from_targz_nested_path() { - use flate2::write::GzEncoder; use flate2::Compression; + use flate2::write::GzEncoder; let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); { @@ -392,10 +392,7 @@ fn executable_permissions_roundtrip() { #[tokio::test] async fn download_rejects_invalid_worker_name() { let result = download_and_install_binary("", "owner/repo", "tag", "1.0", &[], false).await; - assert!( - result.is_err(), - "empty worker name should be rejected" - ); + assert!(result.is_err(), "empty worker name should be rejected"); } /// BIN-04 (T-04-03): download_and_install_binary rejects path traversal in worker name. diff --git a/crates/iii-worker/tests/firmware_integration.rs b/crates/iii-worker/tests/firmware_integration.rs index 6b63c2d0c..d6fca5f65 100644 --- a/crates/iii-worker/tests/firmware_integration.rs +++ b/crates/iii-worker/tests/firmware_integration.rs @@ -16,10 +16,12 @@ mod common; use common::assertions::assert_paths_eq; use iii_worker::cli::firmware::constants::{ - check_libkrunfw_platform_support, iii_init_archive_name, libkrunfw_archive_name, - libkrunfw_filename, III_INIT_FILENAME, + III_INIT_FILENAME, check_libkrunfw_platform_support, iii_init_archive_name, + libkrunfw_archive_name, libkrunfw_filename, +}; +use iii_worker::cli::firmware::resolve::{ + lib_path_env_var, resolve_init_binary, resolve_libkrunfw_dir, }; -use iii_worker::cli::firmware::resolve::{lib_path_env_var, resolve_init_binary, resolve_libkrunfw_dir}; use std::sync::Mutex; /// Serializes tests that mutate environment variables (HOME, III_LIBKRUNFW_PATH, III_INIT_PATH). @@ -60,7 +62,10 @@ fn resolve_libkrunfw_dir_finds_local_firmware() { } } - assert!(result.is_some(), "expected Some when firmware exists in ~/.iii/lib/"); + assert!( + result.is_some(), + "expected Some when firmware exists in ~/.iii/lib/" + ); assert_paths_eq(&result.unwrap(), &lib_dir); } @@ -89,7 +94,10 @@ fn resolve_init_binary_finds_local_init() { } } - assert!(result.is_some(), "expected Some when iii-init exists in ~/.iii/lib/"); + assert!( + result.is_some(), + "expected Some when iii-init exists in ~/.iii/lib/" + ); assert_paths_eq(&result.unwrap(), &init_path); } @@ -116,7 +124,10 @@ fn resolve_libkrunfw_dir_env_var_override() { } } - assert!(result.is_some(), "expected Some with III_LIBKRUNFW_PATH override"); + assert!( + result.is_some(), + "expected Some with III_LIBKRUNFW_PATH override" + ); assert_paths_eq(&result.unwrap(), tmp.path()); } @@ -222,13 +233,22 @@ fn check_libkrunfw_platform_support_succeeds_on_supported() { // Available firmware: darwin-aarch64, linux-x86_64, linux-aarch64 // Missing: darwin-x86_64 (Intel Mac) #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - assert!(result.is_ok(), "darwin-aarch64 should be supported: {result:?}"); + assert!( + result.is_ok(), + "darwin-aarch64 should be supported: {result:?}" + ); #[cfg(all(target_os = "linux", target_arch = "x86_64"))] - assert!(result.is_ok(), "linux-x86_64 should be supported: {result:?}"); + assert!( + result.is_ok(), + "linux-x86_64 should be supported: {result:?}" + ); #[cfg(all(target_os = "linux", target_arch = "aarch64"))] - assert!(result.is_ok(), "linux-aarch64 should be supported: {result:?}"); + assert!( + result.is_ok(), + "linux-aarch64 should be supported: {result:?}" + ); #[cfg(all(target_os = "macos", target_arch = "x86_64"))] { @@ -350,9 +370,6 @@ fn libkrunfw_filename_is_platform_correct() { ); } else { // Linux: libkrunfw.so.5.2.1 -- ends with a version number - assert!( - name.contains(".so."), - "Linux should contain '.so.': {name}" - ); + assert!(name.contains(".so."), "Linux should contain '.so.': {name}"); } } diff --git a/crates/iii-worker/tests/local_worker_integration.rs b/crates/iii-worker/tests/local_worker_integration.rs index 026b2f066..16e9b27aa 100644 --- a/crates/iii-worker/tests/local_worker_integration.rs +++ b/crates/iii-worker/tests/local_worker_integration.rs @@ -18,9 +18,9 @@ use common::fixtures::TestConfigBuilder; use common::isolation::in_temp_dir_async; use iii_worker::cli::config_file::{get_worker_path, worker_exists}; use iii_worker::cli::local_worker::{ - build_env_exports, build_libkrun_local_script, build_local_env, clean_workspace_preserving_deps, - copy_dir_contents, detect_lan_ip, handle_local_add, is_local_path, parse_manifest_resources, - resolve_worker_name, shell_escape, + build_env_exports, build_libkrun_local_script, build_local_env, + clean_workspace_preserving_deps, copy_dir_contents, detect_lan_ip, handle_local_add, + is_local_path, parse_manifest_resources, resolve_worker_name, shell_escape, }; use iii_worker::cli::project::{ProjectInfo, WORKER_MANIFEST}; use std::collections::HashMap; @@ -64,14 +64,23 @@ fn shell_escape_no_special_chars() { #[test] fn build_env_exports_excludes_engine_urls() { let mut env = HashMap::new(); - env.insert("III_ENGINE_URL".to_string(), "ws://localhost:49134".to_string()); + env.insert( + "III_ENGINE_URL".to_string(), + "ws://localhost:49134".to_string(), + ); env.insert("III_URL".to_string(), "ws://localhost:49134".to_string()); env.insert("VALID_KEY".to_string(), "value".to_string()); let result = build_env_exports(&env); - assert!(!result.contains("III_ENGINE_URL"), "should exclude III_ENGINE_URL"); + assert!( + !result.contains("III_ENGINE_URL"), + "should exclude III_ENGINE_URL" + ); assert!(!result.contains("III_URL"), "should exclude III_URL"); - assert!(result.contains("export VALID_KEY='value'"), "should include VALID_KEY"); + assert!( + result.contains("export VALID_KEY='value'"), + "should include VALID_KEY" + ); } /// build_env_exports skips keys with invalid characters (spaces, empty). @@ -82,8 +91,14 @@ fn build_env_exports_skips_invalid_keys() { env.insert("".to_string(), "empty-key".to_string()); let result = build_env_exports(&env); - assert!(!result.contains("invalid key"), "should skip keys with spaces"); - assert_eq!(result, "true", "should return 'true' when no valid keys remain"); + assert!( + !result.contains("invalid key"), + "should skip keys with spaces" + ); + assert_eq!( + result, "true", + "should return 'true' when no valid keys remain" + ); } #[test] @@ -103,7 +118,10 @@ fn build_local_env_merges_and_excludes() { project_env.insert("III_URL".to_string(), "skip-this-too".to_string()); let result = build_local_env("ws://localhost:49134", &project_env); - assert_eq!(result.get("III_ENGINE_URL").unwrap(), "ws://localhost:49134"); + assert_eq!( + result.get("III_ENGINE_URL").unwrap(), + "ws://localhost:49134" + ); assert_eq!(result.get("III_URL").unwrap(), "ws://localhost:49134"); assert_eq!(result.get("CUSTOM").unwrap(), "val"); // Engine URL values come from the function argument, not project_env @@ -123,9 +141,15 @@ fn build_libkrun_local_script_not_prepared() { env: HashMap::new(), }; let script = build_libkrun_local_script(&project, false); - assert!(script.contains("apt-get update"), "should include setup_cmd"); + assert!( + script.contains("apt-get update"), + "should include setup_cmd" + ); assert!(script.contains("npm install"), "should include install_cmd"); - assert!(script.contains(".iii-prepared"), "should include prepared marker"); + assert!( + script.contains(".iii-prepared"), + "should include prepared marker" + ); assert!(script.contains("npm start"), "should include run_cmd"); } @@ -141,8 +165,14 @@ fn build_libkrun_local_script_prepared() { env: HashMap::new(), }; let script = build_libkrun_local_script(&project, true); - assert!(!script.contains("apt-get update"), "should omit setup_cmd when prepared"); - assert!(!script.contains("npm install"), "should omit install_cmd when prepared"); + assert!( + !script.contains("apt-get update"), + "should omit setup_cmd when prepared" + ); + assert!( + !script.contains("npm install"), + "should omit install_cmd when prepared" + ); assert!(script.contains("npm start"), "should still include run_cmd"); } @@ -219,15 +249,33 @@ fn copy_dir_contents_copies_files_skips_ignored() { copy_dir_contents(src.path(), dst.path()).unwrap(); // Verify copied files - assert!(dst.path().join("src/main.rs").exists(), "src/main.rs should be copied"); - assert!(dst.path().join("README.md").exists(), "README.md should be copied"); + assert!( + dst.path().join("src/main.rs").exists(), + "src/main.rs should be copied" + ); + assert!( + dst.path().join("README.md").exists(), + "README.md should be copied" + ); // Verify skipped directories - assert!(!dst.path().join("node_modules").exists(), "node_modules should be skipped"); + assert!( + !dst.path().join("node_modules").exists(), + "node_modules should be skipped" + ); assert!(!dst.path().join(".git").exists(), ".git should be skipped"); - assert!(!dst.path().join("target").exists(), "target should be skipped"); - assert!(!dst.path().join("__pycache__").exists(), "__pycache__ should be skipped"); - assert!(!dst.path().join(".venv").exists(), ".venv should be skipped"); + assert!( + !dst.path().join("target").exists(), + "target should be skipped" + ); + assert!( + !dst.path().join("__pycache__").exists(), + "__pycache__ should be skipped" + ); + assert!( + !dst.path().join(".venv").exists(), + ".venv should be skipped" + ); assert!(!dst.path().join("dist").exists(), "dist should be skipped"); } @@ -282,8 +330,7 @@ async fn handle_local_add_valid_path() { std::fs::create_dir_all(&project_dir).unwrap(); std::fs::write(project_dir.join("package.json"), "{}").unwrap(); - let result = - handle_local_add(project_dir.to_str().unwrap(), false, false, true).await; + let result = handle_local_add(project_dir.to_str().unwrap(), false, false, true).await; assert_eq!(result, 0, "handle_local_add should return 0 for valid path"); // Without manifest, resolve_worker_name falls back to directory name @@ -414,8 +461,7 @@ async fn handle_local_add_canonicalizes_relative_path() { #[tokio::test] async fn handle_local_add_invalid_path_returns_error() { in_temp_dir_async(|| async { - let result = - handle_local_add("/nonexistent/path/to/worker", false, false, true).await; + let result = handle_local_add("/nonexistent/path/to/worker", false, false, true).await; assert_eq!(result, 1, "should return 1 for nonexistent path"); }) .await; diff --git a/crates/iii-worker/tests/oci_worker_integration.rs b/crates/iii-worker/tests/oci_worker_integration.rs index b5ed06a26..d624e83ed 100644 --- a/crates/iii-worker/tests/oci_worker_integration.rs +++ b/crates/iii-worker/tests/oci_worker_integration.rs @@ -19,8 +19,8 @@ use iii_worker::cli::worker_manager::oci::{ /// Build a tar.gz archive from a list of (path, content, mode) entries. fn make_layer_targz(entries: &[(&str, &[u8], u32)]) -> Vec { - use flate2::write::GzEncoder; use flate2::Compression; + use flate2::write::GzEncoder; let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); { let mut archive = tar::Builder::new(&mut encoder); @@ -369,9 +369,10 @@ fn rootfs_search_paths_includes_standard_locations() { .any(|p| p.to_string_lossy().contains(".iii/rootfs/node")); assert!(has_home_path, "should include ~/.iii/rootfs/node path"); - let has_system_path = paths - .iter() - .any(|p| p.to_string_lossy().contains("/usr/local/share/iii/rootfs/node")); + let has_system_path = paths.iter().any(|p| { + p.to_string_lossy() + .contains("/usr/local/share/iii/rootfs/node") + }); assert!( has_system_path, "should include /usr/local/share/iii/rootfs/node path" diff --git a/crates/iii-worker/tests/project_detection_integration.rs b/crates/iii-worker/tests/project_detection_integration.rs index f7e4bc4c4..9a1d5c252 100644 --- a/crates/iii-worker/tests/project_detection_integration.rs +++ b/crates/iii-worker/tests/project_detection_integration.rs @@ -16,7 +16,7 @@ mod common; use iii_worker::cli::project::{ - auto_detect_project, infer_scripts, load_from_manifest, load_project_info, WORKER_MANIFEST, + WORKER_MANIFEST, auto_detect_project, infer_scripts, load_from_manifest, load_project_info, }; // --------------------------------------------------------------------------- @@ -119,7 +119,10 @@ fn auto_detect_python_from_requirements_txt() { std::fs::write(dir.path().join("requirements.txt"), "flask").unwrap(); let result = auto_detect_project(dir.path()); - assert!(result.is_some(), "expected Some for requirements.txt project"); + assert!( + result.is_some(), + "expected Some for requirements.txt project" + ); let info = result.unwrap(); assert_eq!(info.name, "python"); assert_eq!(info.language.as_deref(), Some("python")); diff --git a/crates/iii-worker/tests/vm_args_integration.rs b/crates/iii-worker/tests/vm_args_integration.rs index 31a686476..d27e19399 100644 --- a/crates/iii-worker/tests/vm_args_integration.rs +++ b/crates/iii-worker/tests/vm_args_integration.rs @@ -15,10 +15,10 @@ mod common; use clap::Parser; use iii_worker::cli::lifecycle::build_container_spec; use iii_worker::cli::vm_boot::{ - build_worker_cmd, resolve_krunfw_file_path, rewrite_localhost, shell_quote, VmBootArgs, + VmBootArgs, build_worker_cmd, resolve_krunfw_file_path, rewrite_localhost, shell_quote, }; use iii_worker::cli::worker_manager::adapter::RuntimeAdapter; -use iii_worker::cli::worker_manager::libkrun::{k8s_mem_to_mib, LibkrunAdapter}; +use iii_worker::cli::worker_manager::libkrun::{LibkrunAdapter, k8s_mem_to_mib}; use iii_worker::cli::worker_manager::state::{WorkerDef, WorkerResources}; use std::collections::HashMap; @@ -70,10 +70,7 @@ fn vm_boot_args_all_fields() { assert_eq!(cli.args.arg, vec!["script.js"]); assert_eq!(cli.args.slot, 42); assert_eq!(cli.args.pid_file.as_deref(), Some("/tmp/vm.pid")); - assert_eq!( - cli.args.console_output.as_deref(), - Some("/tmp/console.log") - ); + assert_eq!(cli.args.console_output.as_deref(), Some("/tmp/console.log")); } #[test] @@ -95,8 +92,7 @@ fn vm_boot_args_defaults() { #[test] fn vm_boot_args_hyphen_prefixed_args() { let cli = TestCli::parse_from([ - "test", "--rootfs", "/r", "--exec", "/e", "--arg", "--port", "--arg", "3000", "--arg", - "-v", + "test", "--rootfs", "/r", "--exec", "/e", "--arg", "--port", "--arg", "3000", "--arg", "-v", ]); assert_eq!(cli.args.arg, vec!["--port", "3000", "-v"]); } @@ -263,7 +259,13 @@ fn k8s_mem_to_mib_zero() { #[test] fn vm_boot_args_mount_valid_format() { let cli = TestCli::parse_from([ - "test", "--rootfs", "/r", "--exec", "/e", "--mount", "/host:/guest", + "test", + "--rootfs", + "/r", + "--exec", + "/e", + "--mount", + "/host:/guest", ]); assert_eq!(cli.args.mount[0], "/host:/guest"); } @@ -416,7 +418,10 @@ async fn stop_terminates_sleeping_process() { // stop() should return well under 3 seconds. If it had to wait for // SIGKILL escalation, it would take ~5 seconds. let start = std::time::Instant::now(); - adapter.stop(&pid_str, 5).await.expect("stop should succeed"); + adapter + .stop(&pid_str, 5) + .await + .expect("stop should succeed"); let elapsed = start.elapsed(); assert!( @@ -445,7 +450,10 @@ async fn stop_escalates_to_sigkill_when_sigterm_ignored() { let pid_str = pid.to_string(); // Use a 1-second timeout to force SIGKILL escalation quickly. - adapter.stop(&pid_str, 1).await.expect("stop should succeed"); + adapter + .stop(&pid_str, 1) + .await + .expect("stop should succeed"); // Give a moment for the kernel to reap the killed process. tokio::time::sleep(std::time::Duration::from_millis(300)).await; diff --git a/crates/iii-worker/tests/vm_integration.rs b/crates/iii-worker/tests/vm_integration.rs index 9f5d95e54..b716cb284 100644 --- a/crates/iii-worker/tests/vm_integration.rs +++ b/crates/iii-worker/tests/vm_integration.rs @@ -16,7 +16,7 @@ mod common; use clap::Parser; -use iii_worker::cli::vm_boot::{rewrite_localhost, VmBootArgs}; +use iii_worker::cli::vm_boot::{VmBootArgs, rewrite_localhost}; use iii_worker::cli::worker_manager::adapter::RuntimeAdapter; use iii_worker::cli::worker_manager::libkrun::LibkrunAdapter; use std::collections::HashMap; @@ -78,10 +78,7 @@ fn vm_boot_args_realistic_managed_worker() { "--slot", "1", ]); - assert_eq!( - cli.args.rootfs, - "/home/user/.iii/managed/my-worker/rootfs" - ); + assert_eq!(cli.args.rootfs, "/home/user/.iii/managed/my-worker/rootfs"); assert_eq!(cli.args.exec, "/usr/bin/node"); assert_eq!(cli.args.workdir, "/workspace"); assert_eq!(cli.args.vcpus, 4); @@ -175,21 +172,15 @@ fn vm_boot_args_start_uses_correct_console_output_path() { #[test] fn vm_boot_args_max_vcpus_at_u8_boundary() { // u8::MAX (255) should parse fine - let cli = TestCli::parse_from([ - "test", "--rootfs", "/r", "--exec", "/e", "--vcpus", "255", - ]); + let cli = TestCli::parse_from(["test", "--rootfs", "/r", "--exec", "/e", "--vcpus", "255"]); assert_eq!(cli.args.vcpus, 255); // 256 should also parse (VmBootArgs field is u32) - let cli = TestCli::parse_from([ - "test", "--rootfs", "/r", "--exec", "/e", "--vcpus", "256", - ]); + let cli = TestCli::parse_from(["test", "--rootfs", "/r", "--exec", "/e", "--vcpus", "256"]); assert_eq!(cli.args.vcpus, 256); // Large value within u32 range - let cli = TestCli::parse_from([ - "test", "--rootfs", "/r", "--exec", "/e", "--vcpus", "65535", - ]); + let cli = TestCli::parse_from(["test", "--rootfs", "/r", "--exec", "/e", "--vcpus", "65535"]); assert_eq!(cli.args.vcpus, 65535); } diff --git a/crates/iii-worker/tests/worker_manager_integration.rs b/crates/iii-worker/tests/worker_manager_integration.rs index ceb32f072..e0912823d 100644 --- a/crates/iii-worker/tests/worker_manager_integration.rs +++ b/crates/iii-worker/tests/worker_manager_integration.rs @@ -263,7 +263,10 @@ fn workerdef_unknown_type_errors() { }); let result: Result = serde_json::from_value(json); - assert!(result.is_err(), "unknown type 'docker' should produce deserialization error"); + assert!( + result.is_err(), + "unknown type 'docker' should produce deserialization error" + ); } /// WorkerDef::Managed with empty env and no resources roundtrips correctly.