Skip to main content

rch_common/
remote_compilation.rs

1//! Remote Compilation Verification via SSH.
2//!
3//! This module provides infrastructure for verifying that remote compilation
4//! actually works correctly by:
5//! 1. Applying a test change to make the binary unique
6//! 2. Building locally for a reference hash
7//! 3. Syncing source to worker via rsync
8//! 4. Building on the remote worker
9//! 5. Syncing artifacts back
10//! 6. Comparing binary hashes to verify correctness
11
12use crate::binary_hash::{BinaryHashResult, binaries_equivalent, compute_binary_hash};
13use crate::mock::{self, MockConfig, MockRsync, MockRsyncConfig, MockSshClient};
14use crate::ssh::{SshClient, SshOptions};
15use crate::test_change::{TestChangeGuard, TestCodeChange};
16use crate::types::WorkerConfig;
17use anyhow::{Context, Result, bail};
18use serde::{Deserialize, Serialize};
19use shell_escape::escape;
20use std::borrow::Cow;
21use std::path::PathBuf;
22use std::process::Stdio;
23use std::time::{Duration, Instant};
24use tokio::process::Command;
25use tracing::{debug, info, warn};
26use uuid::Uuid;
27
28/// Result of a remote compilation verification test.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct VerificationResult {
31    /// Whether the verification succeeded (hashes match).
32    pub success: bool,
33    /// Hash result from local build.
34    pub local_hash: BinaryHashResult,
35    /// Hash result from remote build.
36    pub remote_hash: BinaryHashResult,
37    /// Time spent on local compilation in milliseconds.
38    pub local_build_ms: u64,
39    /// Time spent on rsync upload in milliseconds.
40    pub rsync_up_ms: u64,
41    /// Time spent on remote compilation in milliseconds.
42    pub compilation_ms: u64,
43    /// Time spent on rsync download in milliseconds.
44    pub rsync_down_ms: u64,
45    /// Total time for the entire verification in milliseconds.
46    pub total_ms: u64,
47    /// Error message if verification failed.
48    pub error: Option<String>,
49    /// The test marker ID that was embedded in the binary.
50    pub test_marker: String,
51}
52
53/// Configuration for a remote compilation test.
54#[derive(Debug, Clone)]
55pub struct RemoteCompilationTest {
56    /// Worker to test compilation on.
57    pub worker: WorkerConfig,
58    /// Path to the test project (must be a Rust project with src/main.rs or src/lib.rs).
59    pub test_project: PathBuf,
60    /// Timeout for the entire test operation.
61    pub timeout: Duration,
62    /// SSH options for connections.
63    pub ssh_options: SshOptions,
64    /// Whether to use release mode for builds.
65    pub release_mode: bool,
66    /// Binary name to verify (defaults to project directory name).
67    pub binary_name: Option<String>,
68    /// Remote base directory for builds.
69    pub remote_base: String,
70    /// Unique suffix appended to the remote project directory for this test run.
71    pub remote_path_suffix: String,
72}
73
74fn use_mock_transport(worker: &WorkerConfig) -> bool {
75    mock::is_mock_enabled() || mock::is_mock_worker(worker)
76}
77
78fn sanitize_remote_path_component(component: &str, fallback: &str) -> String {
79    let sanitized = component
80        .chars()
81        .map(|ch| {
82            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
83                ch
84            } else {
85                '-'
86            }
87        })
88        .collect::<String>();
89    let trimmed = sanitized.trim_matches('-');
90    if trimmed.is_empty() {
91        fallback.to_string()
92    } else {
93        trimmed.to_string()
94    }
95}
96
97fn sanitize_remote_path_suffix(suffix: &str) -> String {
98    sanitize_remote_path_component(suffix, "run")
99}
100
101fn join_remote_path(base: &str, child: &str) -> String {
102    let base = base.trim_end_matches('/');
103    if base.is_empty() {
104        format!("/{child}")
105    } else {
106        format!("{base}/{child}")
107    }
108}
109
110impl Default for RemoteCompilationTest {
111    fn default() -> Self {
112        Self {
113            worker: WorkerConfig::default(),
114            test_project: PathBuf::new(),
115            timeout: Duration::from_secs(300),
116            ssh_options: SshOptions::default(),
117            release_mode: true,
118            binary_name: None,
119            remote_base: "/tmp/rch_self_test".to_string(),
120            remote_path_suffix: format!("run-{}", Uuid::new_v4()),
121        }
122    }
123}
124
125impl RemoteCompilationTest {
126    /// Create a new remote compilation test.
127    pub fn new(worker: WorkerConfig, test_project: PathBuf) -> Self {
128        Self {
129            worker,
130            test_project,
131            ..Default::default()
132        }
133    }
134
135    /// Set the timeout for the test.
136    pub fn with_timeout(mut self, timeout: Duration) -> Self {
137        self.timeout = timeout;
138        self
139    }
140
141    /// Set SSH options.
142    pub fn with_ssh_options(mut self, options: SshOptions) -> Self {
143        self.ssh_options = options;
144        self
145    }
146
147    /// Set release mode (default: true).
148    pub fn with_release_mode(mut self, release: bool) -> Self {
149        self.release_mode = release;
150        self
151    }
152
153    /// Set the binary name to verify.
154    pub fn with_binary_name(mut self, name: impl Into<String>) -> Self {
155        self.binary_name = Some(name.into());
156        self
157    }
158
159    /// Set the remote project path suffix.
160    pub fn with_remote_path_suffix(mut self, suffix: impl AsRef<str>) -> Self {
161        self.remote_path_suffix = sanitize_remote_path_suffix(suffix.as_ref());
162        self
163    }
164
165    /// Run the remote compilation verification test.
166    ///
167    /// This performs the full pipeline:
168    /// 1. Apply test change to make binary unique
169    /// 2. Build locally for reference hash
170    /// 3. rsync source to worker
171    /// 4. Build on worker
172    /// 5. rsync artifacts back
173    /// 6. Compare hashes
174    pub async fn run(&self) -> Result<VerificationResult> {
175        let start = Instant::now();
176
177        info!(
178            "Starting remote compilation verification for {:?} on worker {}",
179            self.test_project, self.worker.id
180        );
181
182        // 1. Apply test change to make binary unique
183        let change = TestCodeChange::for_main_rs(&self.test_project)
184            .or_else(|_| TestCodeChange::for_lib_rs(&self.test_project))
185            .context("Failed to create test change - no src/main.rs or src/lib.rs found")?;
186        let _guard = TestChangeGuard::new(change.clone())?;
187        let test_marker = change.change_id.clone();
188
189        info!("Applied test marker: {}", test_marker);
190
191        // 2. Build locally first
192        info!("Building locally for reference hash");
193        let local_build_start = Instant::now();
194        self.build_local().await.context("Local build failed")?;
195        let local_build_ms = local_build_start.elapsed().as_millis() as u64;
196        info!("Local build complete in {}ms", local_build_ms);
197
198        let local_binary_path = self.local_binary_path();
199        let local_hash = compute_binary_hash(&local_binary_path)
200            .with_context(|| format!("Failed to hash local binary: {:?}", local_binary_path))?;
201        info!("Local hash: {}", &local_hash.code_hash[..16]);
202
203        // 3. rsync up to worker
204        info!("Syncing source to worker {}", self.worker.id);
205        let rsync_up_start = Instant::now();
206        self.rsync_to_worker()
207            .await
208            .context("rsync to worker failed")?;
209        let rsync_up_ms = rsync_up_start.elapsed().as_millis() as u64;
210        info!("rsync upload complete in {}ms", rsync_up_ms);
211
212        // 4. Build on worker
213        info!("Building on remote worker");
214        let compile_start = Instant::now();
215        self.build_remote().await.context("Remote build failed")?;
216        let compilation_ms = compile_start.elapsed().as_millis() as u64;
217        info!("Remote build complete in {}ms", compilation_ms);
218
219        // 5. rsync back
220        info!("Syncing artifacts from worker");
221        let rsync_down_start = Instant::now();
222        self.rsync_from_worker()
223            .await
224            .context("rsync from worker failed")?;
225        let rsync_down_ms = rsync_down_start.elapsed().as_millis() as u64;
226        info!("rsync download complete in {}ms", rsync_down_ms);
227
228        // 6. Compute remote binary hash
229        let remote_binary_path = self.remote_binary_path();
230        let remote_hash = compute_binary_hash(&remote_binary_path)
231            .with_context(|| format!("Failed to hash remote binary: {:?}", remote_binary_path))?;
232        info!("Remote hash: {}", &remote_hash.code_hash[..16]);
233
234        // 7. Compare
235        let success = binaries_equivalent(&local_hash, &remote_hash);
236        let total_ms = start.elapsed().as_millis() as u64;
237
238        let error = if success {
239            info!("Verification PASSED: Binary hashes match");
240            None
241        } else {
242            let msg = format!(
243                "Binary hash mismatch: local={} remote={}",
244                &local_hash.code_hash[..16],
245                &remote_hash.code_hash[..16]
246            );
247            warn!("Verification FAILED: {}", msg);
248            Some(msg)
249        };
250
251        Ok(VerificationResult {
252            success,
253            local_hash,
254            remote_hash,
255            local_build_ms,
256            rsync_up_ms,
257            compilation_ms,
258            rsync_down_ms,
259            total_ms,
260            error,
261            test_marker,
262        })
263    }
264
265    /// Get the path to the local binary after build.
266    fn local_binary_path(&self) -> PathBuf {
267        let binary_name = self.get_binary_name();
268        let profile = if self.release_mode {
269            "release"
270        } else {
271            "debug"
272        };
273        self.test_project
274            .join("target")
275            .join(profile)
276            .join(binary_name)
277    }
278
279    /// Get the path where remote binary will be stored locally after retrieval.
280    fn remote_binary_path(&self) -> PathBuf {
281        let binary_name = self.get_binary_name();
282        let profile = if self.release_mode {
283            "release"
284        } else {
285            "debug"
286        };
287        self.test_project
288            .join("target")
289            .join(format!("{}_remote", profile))
290            .join(binary_name)
291    }
292
293    /// Get the binary name to verify.
294    fn get_binary_name(&self) -> String {
295        self.binary_name.clone().unwrap_or_else(|| {
296            self.test_project
297                .file_name()
298                .and_then(|n| n.to_str())
299                .unwrap_or("unknown")
300                .replace('-', "_")
301        })
302    }
303
304    /// Get the remote project path on the worker.
305    fn remote_project_path(&self) -> String {
306        let project_name = self
307            .test_project
308            .file_name()
309            .and_then(|n| n.to_str())
310            .map(|n| sanitize_remote_path_component(n, "self_test"))
311            .unwrap_or_else(|| "self_test".to_string());
312        let remote_path_suffix = sanitize_remote_path_suffix(&self.remote_path_suffix);
313        let project_dir = format!("{project_name}-{remote_path_suffix}");
314        join_remote_path(&self.remote_base, &project_dir)
315    }
316
317    /// Get the remote artifact source for rsync retrieval.
318    fn remote_artifact_source(&self, profile: &str) -> String {
319        let remote_path = self.remote_project_path();
320        let remote_target = format!("{}/target/{profile}/", remote_path.trim_end_matches('/'));
321        format!(
322            "{}@{}:{}",
323            self.worker.user,
324            self.worker.host,
325            escape(Cow::from(remote_target))
326        )
327    }
328
329    /// Build the isolated remote CARGO_HOME path for a single build attempt.
330    fn remote_cargo_home_path(&self, session_id: u32, timestamp: u128) -> String {
331        let worker_id = sanitize_remote_path_component(self.worker.id.as_str(), "worker");
332        format!("/tmp/rch-cargo-home-{worker_id}-{session_id}-{timestamp}")
333    }
334
335    /// Build the project locally.
336    async fn build_local(&self) -> Result<()> {
337        let mut cmd = Command::new("cargo");
338        cmd.arg("build");
339
340        if self.release_mode {
341            cmd.arg("--release");
342        }
343
344        cmd.current_dir(&self.test_project)
345            .env("CARGO_INCREMENTAL", "0") // Disable incremental for reproducibility
346            .stdout(Stdio::piped())
347            .stderr(Stdio::piped());
348
349        debug!(
350            "Running local build: cargo build {}",
351            if self.release_mode { "--release" } else { "" }
352        );
353
354        let output = cmd
355            .output()
356            .await
357            .context("Failed to execute cargo build")?;
358
359        if !output.status.success() {
360            let stderr = String::from_utf8_lossy(&output.stderr);
361            bail!("Local build failed: {}", stderr);
362        }
363
364        Ok(())
365    }
366
367    /// Sync source files to the worker via rsync.
368    async fn rsync_to_worker(&self) -> Result<()> {
369        let remote_path = self.remote_project_path();
370        let escaped_remote_path = escape(Cow::from(&remote_path));
371
372        if use_mock_transport(&self.worker) {
373            let mut client = MockSshClient::new(self.worker.clone(), MockConfig::from_env());
374            client.connect().await?;
375            let mkdir_cmd = format!("mkdir -p -- {}", escaped_remote_path);
376            let mkdir_result = client.execute(&mkdir_cmd).await?;
377            client.disconnect().await?;
378
379            if !mkdir_result.success() {
380                bail!("Failed to create remote directory: {}", mkdir_result.stderr);
381            }
382
383            let rsync = MockRsync::new(MockRsyncConfig::from_env());
384            let destination = format!(
385                "{}@{}:{}",
386                self.worker.user, self.worker.host, escaped_remote_path
387            );
388            rsync
389                .sync_to_remote(&self.test_project.display().to_string(), &destination, &[])
390                .await?;
391            return Ok(());
392        }
393
394        // First ensure remote directory exists
395        let mut client = SshClient::new(self.worker.clone(), self.ssh_options.clone());
396        client.connect().await?;
397        let mkdir_cmd = format!("mkdir -p -- {}", escaped_remote_path);
398        let mkdir_result = client.execute(&mkdir_cmd).await?;
399        client.disconnect().await?;
400
401        if !mkdir_result.success() {
402            bail!("Failed to create remote directory: {}", mkdir_result.stderr);
403        }
404
405        let destination = format!(
406            "{}@{}:{}",
407            self.worker.user, self.worker.host, escaped_remote_path
408        );
409
410        let identity_file = shellexpand::tilde(&self.worker.identity_file);
411        let escaped_identity = escape(Cow::from(identity_file.as_ref()));
412
413        let mut cmd = Command::new("rsync");
414        cmd.arg("-az")
415            .arg("--delete")
416            .arg("--exclude")
417            .arg("target/")
418            .arg("--exclude")
419            .arg(".git/")
420            .arg("-e")
421            .arg(format!(
422                "ssh -i {} -o StrictHostKeyChecking=accept-new -o BatchMode=yes",
423                escaped_identity
424            ))
425            .arg(format!("{}/", self.test_project.display()))
426            .arg(&destination)
427            .stdout(Stdio::piped())
428            .stderr(Stdio::piped());
429
430        debug!(
431            "Running rsync to worker: {:?}",
432            cmd.as_std().get_args().collect::<Vec<_>>()
433        );
434
435        let output = cmd.output().await.context("Failed to execute rsync")?;
436
437        if !output.status.success() {
438            let stderr = String::from_utf8_lossy(&output.stderr);
439            bail!("rsync to worker failed: {}", stderr);
440        }
441
442        Ok(())
443    }
444
445    /// Build the project on the remote worker.
446    async fn build_remote(&self) -> Result<()> {
447        let remote_path = self.remote_project_path();
448        let escaped_remote_path = escape(Cow::from(&remote_path));
449
450        // Generate unique cargo home and target dir per worker session to prevent cache lock contention
451        let session_id = std::process::id();
452        let timestamp = std::time::SystemTime::now()
453            .duration_since(std::time::UNIX_EPOCH)
454            .unwrap_or_default()
455            .as_nanos();
456        let cargo_home = self.remote_cargo_home_path(session_id, timestamp);
457        let cargo_target_dir = format!("{}/target", remote_path);
458        let escaped_cargo_home = escape(Cow::from(&cargo_home));
459        let escaped_cargo_target_dir = escape(Cow::from(&cargo_target_dir));
460
461        let cargo_args = if self.release_mode {
462            "cargo build --release"
463        } else {
464            "cargo build"
465        };
466        // Preserve the cargo status after removing the isolated cache.
467        let build_cmd = format!(
468            "mkdir -p -- {} {} && cd {} && CARGO_HOME={} CARGO_TARGET_DIR={} CARGO_INCREMENTAL=0 {}; status=$?; rm -rf -- {}; exit $status",
469            escaped_cargo_home,
470            escaped_cargo_target_dir,
471            escaped_remote_path,
472            escaped_cargo_home,
473            escaped_cargo_target_dir,
474            cargo_args,
475            escaped_cargo_home
476        );
477
478        debug!(
479            "Running remote build with isolated cargo cache: {}",
480            build_cmd
481        );
482
483        if use_mock_transport(&self.worker) {
484            let mut client = MockSshClient::new(self.worker.clone(), MockConfig::from_env());
485            client.connect().await?;
486            let result = client.execute(&build_cmd).await?;
487            client.disconnect().await?;
488
489            if !result.success() {
490                bail!(
491                    "Remote build failed (exit {}): {}",
492                    result.exit_code,
493                    result.stderr
494                );
495            }
496
497            return Ok(());
498        }
499
500        let mut client = SshClient::new(self.worker.clone(), self.ssh_options.clone());
501        client.connect().await?;
502        let result = client.execute(&build_cmd).await?;
503        client.disconnect().await?;
504
505        if !result.success() {
506            bail!(
507                "Remote build failed (exit {}): {}",
508                result.exit_code,
509                result.stderr
510            );
511        }
512
513        Ok(())
514    }
515
516    /// Sync build artifacts from the worker via rsync.
517    async fn rsync_from_worker(&self) -> Result<()> {
518        let profile = if self.release_mode {
519            "release"
520        } else {
521            "debug"
522        };
523
524        // Create local destination directory for remote artifacts
525        let local_dest = self
526            .test_project
527            .join("target")
528            .join(format!("{}_remote", profile));
529        std::fs::create_dir_all(&local_dest)
530            .with_context(|| format!("Failed to create directory: {:?}", local_dest))?;
531
532        if use_mock_transport(&self.worker) {
533            let rsync = MockRsync::new(MockRsyncConfig::from_env());
534            let remote_target = self.remote_artifact_source(profile);
535            rsync
536                .retrieve_artifacts(&remote_target, &local_dest.display().to_string(), &[])
537                .await?;
538            return Ok(());
539        }
540
541        let remote_target = self.remote_artifact_source(profile);
542
543        let identity_file = shellexpand::tilde(&self.worker.identity_file);
544        let escaped_identity = escape(Cow::from(identity_file.as_ref()));
545
546        let mut cmd = Command::new("rsync");
547        cmd.arg("-az")
548            .arg("-e")
549            .arg(format!(
550                "ssh -i {} -o StrictHostKeyChecking=accept-new -o BatchMode=yes",
551                escaped_identity
552            ))
553            .arg(&remote_target)
554            .arg(format!("{}/", local_dest.display()))
555            .stdout(Stdio::piped())
556            .stderr(Stdio::piped());
557
558        debug!(
559            "Running rsync from worker: {:?}",
560            cmd.as_std().get_args().collect::<Vec<_>>()
561        );
562
563        let output = cmd.output().await.context("Failed to retrieve artifacts")?;
564
565        if !output.status.success() {
566            let stderr = String::from_utf8_lossy(&output.stderr);
567            bail!("rsync from worker failed: {}", stderr);
568        }
569
570        Ok(())
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::mock::{
578        MockConfig, MockRsyncConfig, clear_global_invocations, clear_mock_overrides,
579        global_rsync_invocations_snapshot, global_ssh_invocations_snapshot, is_mock_enabled,
580        set_mock_enabled_override, set_mock_rsync_config_override, set_mock_ssh_config_override,
581    };
582    use crate::test_guard;
583    use crate::testing::{TestLogger, TestPhase};
584    use crate::types::WorkerId;
585
586    fn init_test_logging() {
587        let _ = tracing_subscriber::fmt()
588            .with_test_writer()
589            .with_max_level(tracing::Level::DEBUG)
590            .try_init();
591    }
592
593    // ========================
594    // VerificationResult tests
595    // ========================
596
597    #[test]
598    fn verification_result_serializes_roundtrip() {
599        let _guard = test_guard!();
600        let logger = TestLogger::for_test("verification_result_serializes_roundtrip");
601
602        let result = VerificationResult {
603            success: true,
604            local_hash: BinaryHashResult {
605                full_hash: "abc123".to_string(),
606                code_hash: "def456".to_string(),
607                text_section_size: 12345,
608                is_debug: false,
609            },
610            remote_hash: BinaryHashResult {
611                full_hash: "abc123".to_string(),
612                code_hash: "def456".to_string(),
613                text_section_size: 12345,
614                is_debug: false,
615            },
616            local_build_ms: 1200,
617            rsync_up_ms: 100,
618            compilation_ms: 5000,
619            rsync_down_ms: 200,
620            total_ms: 5300,
621            error: None,
622            test_marker: "RCH_TEST_12345".to_string(),
623        };
624
625        logger.log(TestPhase::Execute, "Serializing VerificationResult to JSON");
626        let json = serde_json::to_string(&result).unwrap();
627        logger.log_with_data(
628            TestPhase::Execute,
629            "Serialized result",
630            serde_json::json!({ "json_len": json.len() }),
631        );
632
633        let restored: VerificationResult = serde_json::from_str(&json).unwrap();
634
635        logger.log(TestPhase::Verify, "Checking restored fields");
636        assert!(restored.success);
637        assert_eq!(restored.rsync_up_ms, 100);
638        assert_eq!(restored.compilation_ms, 5000);
639        assert_eq!(restored.test_marker, "RCH_TEST_12345");
640
641        logger.pass();
642    }
643
644    #[test]
645    fn verification_result_with_error() {
646        let _guard = test_guard!();
647        let logger = TestLogger::for_test("verification_result_with_error");
648
649        let result = VerificationResult {
650            success: false,
651            local_hash: BinaryHashResult {
652                full_hash: "abc".to_string(),
653                code_hash: "local_hash".to_string(),
654                text_section_size: 1000,
655                is_debug: false,
656            },
657            remote_hash: BinaryHashResult {
658                full_hash: "xyz".to_string(),
659                code_hash: "remote_hash".to_string(),
660                text_section_size: 1000,
661                is_debug: false,
662            },
663            local_build_ms: 900,
664            rsync_up_ms: 50,
665            compilation_ms: 3000,
666            rsync_down_ms: 50,
667            total_ms: 3100,
668            error: Some("Binary hash mismatch".to_string()),
669            test_marker: "RCH_TEST_99999".to_string(),
670        };
671
672        logger.log(TestPhase::Verify, "Checking failed verification result");
673        assert!(!result.success);
674        assert!(result.error.is_some());
675        assert_eq!(result.error.as_ref().unwrap(), "Binary hash mismatch");
676
677        logger.log_with_data(
678            TestPhase::Verify,
679            "Error captured correctly",
680            serde_json::json!({ "error": result.error }),
681        );
682        logger.pass();
683    }
684
685    #[test]
686    fn verification_result_serializes_with_all_fields() {
687        let _guard = test_guard!();
688        let logger = TestLogger::for_test("verification_result_serializes_with_all_fields");
689
690        let result = VerificationResult {
691            success: false,
692            local_hash: BinaryHashResult {
693                full_hash: "full_local".to_string(),
694                code_hash: "code_local".to_string(),
695                text_section_size: 5000,
696                is_debug: true,
697            },
698            remote_hash: BinaryHashResult {
699                full_hash: "full_remote".to_string(),
700                code_hash: "code_remote".to_string(),
701                text_section_size: 5001,
702                is_debug: true,
703            },
704            local_build_ms: 2500,
705            rsync_up_ms: 300,
706            compilation_ms: 15000,
707            rsync_down_ms: 400,
708            total_ms: 18200,
709            error: Some("Size mismatch: 5000 vs 5001".to_string()),
710            test_marker: "RCH_FULL_TEST".to_string(),
711        };
712
713        logger.log(TestPhase::Execute, "Serializing result with all fields");
714        let json = serde_json::to_string_pretty(&result).unwrap();
715
716        // Verify all fields are present in serialization
717        assert!(json.contains("\"success\": false"));
718        assert!(json.contains("\"local_build_ms\": 2500"));
719        assert!(json.contains("\"rsync_up_ms\": 300"));
720        assert!(json.contains("\"compilation_ms\": 15000"));
721        assert!(json.contains("\"rsync_down_ms\": 400"));
722        assert!(json.contains("\"total_ms\": 18200"));
723        assert!(json.contains("RCH_FULL_TEST"));
724        assert!(json.contains("Size mismatch"));
725
726        logger.log_with_data(
727            TestPhase::Verify,
728            "All fields serialized",
729            serde_json::json!({ "fields_checked": 8 }),
730        );
731        logger.pass();
732    }
733
734    // ========================
735    // RemoteCompilationTest tests
736    // ========================
737
738    #[test]
739    fn remote_compilation_test_default_values() {
740        let _guard = test_guard!();
741        let logger = TestLogger::for_test("remote_compilation_test_default_values");
742
743        let test = RemoteCompilationTest::default();
744
745        logger.log(TestPhase::Verify, "Checking default values");
746        assert_eq!(test.timeout, Duration::from_secs(300));
747        assert!(test.release_mode);
748        assert!(test.binary_name.is_none());
749        assert_eq!(test.remote_base, "/tmp/rch_self_test");
750        assert!(test.remote_path_suffix.starts_with("run-"));
751
752        logger.log_with_data(
753            TestPhase::Verify,
754            "Default values correct",
755            serde_json::json!({
756                "timeout_secs": 300,
757                "release_mode": true,
758                "remote_base": "/tmp/rch_self_test",
759                "remote_path_suffix": &test.remote_path_suffix
760            }),
761        );
762        logger.pass();
763    }
764
765    #[test]
766    fn remote_compilation_test_builder_pattern() {
767        let _guard = test_guard!();
768        let logger = TestLogger::for_test("remote_compilation_test_builder_pattern");
769
770        let worker = WorkerConfig {
771            id: WorkerId::new("test-worker"),
772            host: "worker.example.com".to_string(),
773            user: "builder".to_string(),
774            identity_file: "~/.ssh/id_rsa".to_string(),
775            total_slots: 4,
776            ..Default::default()
777        };
778
779        logger.log(TestPhase::Execute, "Building test with custom options");
780        let test = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/tmp/project"))
781            .with_timeout(Duration::from_secs(120))
782            .with_release_mode(false)
783            .with_binary_name("my_binary");
784
785        logger.log(TestPhase::Verify, "Checking builder-set values");
786        assert_eq!(test.timeout, Duration::from_secs(120));
787        assert!(!test.release_mode);
788        assert_eq!(test.binary_name, Some("my_binary".to_string()));
789        assert_eq!(test.worker.id.as_str(), "test-worker");
790
791        logger.pass();
792    }
793
794    #[test]
795    fn remote_compilation_test_with_ssh_options() {
796        let _guard = test_guard!();
797        let logger = TestLogger::for_test("remote_compilation_test_with_ssh_options");
798
799        let worker = WorkerConfig {
800            id: WorkerId::new("w1"),
801            host: "host".to_string(),
802            user: "user".to_string(),
803            identity_file: "~/.ssh/id_rsa".to_string(),
804            total_slots: 1,
805            ..Default::default()
806        };
807
808        let ssh_opts = SshOptions {
809            connect_timeout: Duration::from_secs(30),
810            command_timeout: Duration::from_secs(600),
811            ..Default::default()
812        };
813
814        logger.log(TestPhase::Execute, "Creating test with custom SSH options");
815        let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/project"))
816            .with_ssh_options(ssh_opts);
817
818        logger.log(TestPhase::Verify, "Checking SSH options");
819        assert_eq!(test.ssh_options.connect_timeout, Duration::from_secs(30));
820        assert_eq!(test.ssh_options.command_timeout, Duration::from_secs(600));
821
822        logger.pass();
823    }
824
825    #[test]
826    fn remote_compilation_test_binary_path_release() {
827        let _guard = test_guard!();
828        let logger = TestLogger::for_test("remote_compilation_test_binary_path_release");
829
830        let worker = WorkerConfig {
831            id: WorkerId::new("w1"),
832            host: "host".to_string(),
833            user: "user".to_string(),
834            identity_file: "~/.ssh/id_rsa".to_string(),
835            total_slots: 1,
836            ..Default::default()
837        };
838
839        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/my-project"))
840            .with_release_mode(true)
841            .with_binary_name("my_binary");
842
843        let local_path = test.local_binary_path();
844        let remote_path = test.remote_binary_path();
845
846        logger.log_with_data(
847            TestPhase::Execute,
848            "Computed binary paths",
849            serde_json::json!({
850                "local": local_path.to_string_lossy(),
851                "remote": remote_path.to_string_lossy()
852            }),
853        );
854
855        assert!(
856            local_path
857                .to_string_lossy()
858                .contains("target/release/my_binary")
859        );
860        assert!(
861            remote_path
862                .to_string_lossy()
863                .contains("target/release_remote/my_binary")
864        );
865
866        logger.pass();
867    }
868
869    #[test]
870    fn remote_compilation_test_binary_path_debug() {
871        let _guard = test_guard!();
872        let logger = TestLogger::for_test("remote_compilation_test_binary_path_debug");
873
874        let worker = WorkerConfig {
875            id: WorkerId::new("w1"),
876            host: "host".to_string(),
877            user: "user".to_string(),
878            identity_file: "~/.ssh/id_rsa".to_string(),
879            total_slots: 1,
880            ..Default::default()
881        };
882
883        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/my-project"))
884            .with_release_mode(false)
885            .with_binary_name("my_binary");
886
887        let local_path = test.local_binary_path();
888        let remote_path = test.remote_binary_path();
889
890        logger.log_with_data(
891            TestPhase::Execute,
892            "Computed debug binary paths",
893            serde_json::json!({
894                "local": local_path.to_string_lossy(),
895                "remote": remote_path.to_string_lossy()
896            }),
897        );
898
899        assert!(
900            local_path
901                .to_string_lossy()
902                .contains("target/debug/my_binary")
903        );
904        assert!(
905            remote_path
906                .to_string_lossy()
907                .contains("target/debug_remote/my_binary")
908        );
909
910        logger.pass();
911    }
912
913    #[test]
914    fn remote_compilation_test_infers_binary_name() {
915        let _guard = test_guard!();
916        let logger = TestLogger::for_test("remote_compilation_test_infers_binary_name");
917
918        let worker = WorkerConfig {
919            id: WorkerId::new("w1"),
920            host: "host".to_string(),
921            user: "user".to_string(),
922            identity_file: "~/.ssh/id_rsa".to_string(),
923            total_slots: 1,
924            ..Default::default()
925        };
926
927        // Test with hyphenated project name (should convert to underscore)
928        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/my-cool-project"));
929
930        let binary_name = test.get_binary_name();
931        logger.log_with_data(
932            TestPhase::Execute,
933            "Inferred binary name",
934            serde_json::json!({ "binary_name": &binary_name }),
935        );
936
937        assert_eq!(binary_name, "my_cool_project");
938
939        logger.pass();
940    }
941
942    #[test]
943    fn remote_compilation_test_infers_binary_name_edge_cases() {
944        let _guard = test_guard!();
945        let logger = TestLogger::for_test("remote_compilation_test_infers_binary_name_edge_cases");
946
947        let worker = WorkerConfig {
948            id: WorkerId::new("w1"),
949            host: "host".to_string(),
950            user: "user".to_string(),
951            identity_file: "~/.ssh/id_rsa".to_string(),
952            total_slots: 1,
953            ..Default::default()
954        };
955
956        // Test with multiple hyphens
957        let test1 =
958            RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my-very-cool-project"));
959        assert_eq!(test1.get_binary_name(), "my_very_cool_project");
960
961        // Test with underscores (should remain unchanged)
962        let test2 =
963            RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my_project_name"));
964        assert_eq!(test2.get_binary_name(), "my_project_name");
965
966        // Test with mixed
967        let test3 = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my-project_v2"));
968        assert_eq!(test3.get_binary_name(), "my_project_v2");
969
970        // Test with explicit binary name (should override)
971        let test4 = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my-project"))
972            .with_binary_name("custom");
973        assert_eq!(test4.get_binary_name(), "custom");
974
975        logger.log_with_data(
976            TestPhase::Verify,
977            "All edge cases handled",
978            serde_json::json!({ "cases_tested": 4 }),
979        );
980        logger.pass();
981    }
982
983    #[test]
984    fn remote_compilation_test_remote_project_path() {
985        let _guard = test_guard!();
986        let logger = TestLogger::for_test("remote_compilation_test_remote_project_path");
987
988        let worker = WorkerConfig {
989            id: WorkerId::new("w1"),
990            host: "host".to_string(),
991            user: "user".to_string(),
992            identity_file: "~/.ssh/id_rsa".to_string(),
993            total_slots: 1,
994            ..Default::default()
995        };
996
997        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/test-project"));
998
999        let remote_path = test.remote_project_path();
1000        logger.log_with_data(
1001            TestPhase::Execute,
1002            "Computed remote path",
1003            serde_json::json!({ "remote_path": &remote_path }),
1004        );
1005
1006        assert!(
1007            remote_path.starts_with("/tmp/rch_self_test/test-project-run-"),
1008            "remote path should include a unique run suffix: {remote_path}"
1009        );
1010
1011        logger.pass();
1012    }
1013
1014    #[test]
1015    fn remote_compilation_test_custom_remote_path_suffix() {
1016        let _guard = test_guard!();
1017        let logger = TestLogger::for_test("remote_compilation_test_custom_remote_path_suffix");
1018
1019        let worker = WorkerConfig {
1020            id: WorkerId::new("w1"),
1021            host: "host".to_string(),
1022            user: "user".to_string(),
1023            identity_file: "~/.ssh/id_rsa".to_string(),
1024            total_slots: 1,
1025            ..Default::default()
1026        };
1027
1028        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/test-project"))
1029            .with_remote_path_suffix("run/42 attempt 1");
1030
1031        let remote_path = test.remote_project_path();
1032        logger.log_with_data(
1033            TestPhase::Execute,
1034            "Computed remote path with custom suffix",
1035            serde_json::json!({ "remote_path": &remote_path }),
1036        );
1037
1038        assert_eq!(
1039            remote_path,
1040            "/tmp/rch_self_test/test-project-run-42-attempt-1"
1041        );
1042
1043        logger.pass();
1044    }
1045
1046    #[test]
1047    fn remote_compilation_test_remote_path_sanitizes_public_suffix_field() {
1048        let _guard = test_guard!();
1049        let logger = TestLogger::for_test(
1050            "remote_compilation_test_remote_path_sanitizes_public_suffix_field",
1051        );
1052
1053        let worker = WorkerConfig {
1054            id: WorkerId::new("w1"),
1055            host: "host".to_string(),
1056            user: "user".to_string(),
1057            identity_file: "~/.ssh/id_rsa".to_string(),
1058            total_slots: 1,
1059            ..Default::default()
1060        };
1061
1062        let mut test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/test-project"));
1063        test.remote_path_suffix = "../unsafe path".to_string();
1064
1065        let remote_path = test.remote_project_path();
1066        logger.log_with_data(
1067            TestPhase::Execute,
1068            "Computed remote path with directly-mutated suffix",
1069            serde_json::json!({ "remote_path": &remote_path }),
1070        );
1071
1072        assert_eq!(
1073            remote_path,
1074            "/tmp/rch_self_test/test-project-..-unsafe-path"
1075        );
1076
1077        logger.pass();
1078    }
1079
1080    #[test]
1081    fn remote_compilation_test_instances_do_not_share_remote_paths() {
1082        let _guard = test_guard!();
1083        let logger =
1084            TestLogger::for_test("remote_compilation_test_instances_do_not_share_remote_paths");
1085
1086        let worker = WorkerConfig {
1087            id: WorkerId::new("w1"),
1088            host: "host".to_string(),
1089            user: "user".to_string(),
1090            identity_file: "~/.ssh/id_rsa".to_string(),
1091            total_slots: 1,
1092            ..Default::default()
1093        };
1094
1095        let first = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/home/user/project"));
1096        let second = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"));
1097
1098        let first_path = first.remote_project_path();
1099        let second_path = second.remote_project_path();
1100        logger.log_with_data(
1101            TestPhase::Verify,
1102            "Computed independent remote paths",
1103            serde_json::json!({
1104                "first_path": &first_path,
1105                "second_path": &second_path
1106            }),
1107        );
1108
1109        assert_ne!(first_path, second_path);
1110
1111        logger.pass();
1112    }
1113
1114    #[test]
1115    fn remote_compilation_test_with_custom_remote_base() {
1116        let _guard = test_guard!();
1117        let logger = TestLogger::for_test("remote_compilation_test_with_custom_remote_base");
1118
1119        let worker = WorkerConfig {
1120            id: WorkerId::new("w1"),
1121            host: "host".to_string(),
1122            user: "user".to_string(),
1123            identity_file: "~/.ssh/id_rsa".to_string(),
1124            total_slots: 1,
1125            ..Default::default()
1126        };
1127
1128        let mut test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"))
1129            .with_remote_path_suffix("custom-suffix");
1130        test.remote_base = "/custom/build/dir/".to_string();
1131
1132        let remote_path = test.remote_project_path();
1133        logger.log_with_data(
1134            TestPhase::Execute,
1135            "Computed remote path with custom base",
1136            serde_json::json!({
1137                "remote_base": "/custom/build/dir/",
1138                "remote_path": &remote_path
1139            }),
1140        );
1141
1142        assert_eq!(remote_path, "/custom/build/dir/project-custom-suffix");
1143
1144        logger.pass();
1145    }
1146
1147    #[test]
1148    fn remote_compilation_test_artifact_source_quotes_remote_base() {
1149        let _guard = test_guard!();
1150        let logger =
1151            TestLogger::for_test("remote_compilation_test_artifact_source_quotes_remote_base");
1152
1153        let worker = WorkerConfig {
1154            id: WorkerId::new("w1"),
1155            host: "host".to_string(),
1156            user: "user".to_string(),
1157            identity_file: "~/.ssh/id_rsa".to_string(),
1158            total_slots: 1,
1159            ..Default::default()
1160        };
1161
1162        let mut test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"))
1163            .with_remote_path_suffix("run-1");
1164        test.remote_base = "/tmp/rch self test".to_string();
1165
1166        let source = test.remote_artifact_source("release");
1167        logger.log_with_data(
1168            TestPhase::Verify,
1169            "Computed quoted artifact source",
1170            serde_json::json!({ "source": &source }),
1171        );
1172
1173        assert_eq!(
1174            source,
1175            "user@host:'/tmp/rch self test/project-run-1/target/release/'"
1176        );
1177
1178        logger.pass();
1179    }
1180
1181    #[test]
1182    fn remote_compilation_test_cargo_home_sanitizes_worker_id() {
1183        let _guard = test_guard!();
1184        let logger = TestLogger::for_test("remote_compilation_test_cargo_home_sanitizes_worker_id");
1185
1186        let worker = WorkerConfig {
1187            id: WorkerId::new("worker/one with spaces"),
1188            host: "host".to_string(),
1189            user: "user".to_string(),
1190            identity_file: "~/.ssh/id_rsa".to_string(),
1191            total_slots: 1,
1192            ..Default::default()
1193        };
1194
1195        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"));
1196        let cargo_home = test.remote_cargo_home_path(7, 42);
1197        logger.log_with_data(
1198            TestPhase::Verify,
1199            "Computed sanitized cargo home",
1200            serde_json::json!({ "cargo_home": &cargo_home }),
1201        );
1202
1203        assert_eq!(
1204            cargo_home,
1205            "/tmp/rch-cargo-home-worker-one-with-spaces-7-42"
1206        );
1207
1208        logger.pass();
1209    }
1210
1211    #[test]
1212    fn verification_result_timing_fields() {
1213        let _guard = test_guard!();
1214        let logger = TestLogger::for_test("verification_result_timing_fields");
1215
1216        let result = VerificationResult {
1217            success: true,
1218            local_hash: BinaryHashResult {
1219                full_hash: "a".to_string(),
1220                code_hash: "b".to_string(),
1221                text_section_size: 100,
1222                is_debug: false,
1223            },
1224            remote_hash: BinaryHashResult {
1225                full_hash: "a".to_string(),
1226                code_hash: "b".to_string(),
1227                text_section_size: 100,
1228                is_debug: false,
1229            },
1230            local_build_ms: 1200,
1231            rsync_up_ms: 150,
1232            compilation_ms: 8000,
1233            rsync_down_ms: 200,
1234            total_ms: 8500,
1235            error: None,
1236            test_marker: "RCH_TEST_1".to_string(),
1237        };
1238
1239        // Verify timing breakdown makes sense
1240        let sum = result.rsync_up_ms + result.compilation_ms + result.rsync_down_ms;
1241        logger.log_with_data(
1242            TestPhase::Verify,
1243            "Checking timing consistency",
1244            serde_json::json!({
1245                "sum_of_phases": sum,
1246                "total_reported": result.total_ms
1247            }),
1248        );
1249
1250        // Total should be >= sum (may include overhead)
1251        assert!(result.total_ms >= sum - 100); // Allow some timing variance
1252
1253        logger.pass();
1254    }
1255
1256    // ========================
1257    // use_mock_transport tests
1258    // ========================
1259
1260    #[test]
1261    fn use_mock_transport_with_mock_host() {
1262        let _guard = test_guard!();
1263        let logger = TestLogger::for_test("use_mock_transport_with_mock_host");
1264
1265        let worker = WorkerConfig {
1266            id: WorkerId::new("mock-worker"),
1267            host: "mock://localhost".to_string(),
1268            user: "user".to_string(),
1269            identity_file: "~/.ssh/id".to_string(),
1270            total_slots: 4,
1271            ..Default::default()
1272        };
1273
1274        logger.log(TestPhase::Execute, "Testing mock:// host detection");
1275        assert!(use_mock_transport(&worker));
1276
1277        logger.pass();
1278    }
1279
1280    /// Test use_mock_transport with real host and mock disabled.
1281    /// Uses global mock state - run with `cargo test -- --test-threads=1`.
1282    #[test]
1283    #[ignore = "uses global mock state; run with --test-threads=1"]
1284    fn use_mock_transport_with_real_host() {
1285        let logger = TestLogger::for_test("use_mock_transport_with_real_host");
1286
1287        // Ensure mock mode is disabled
1288        set_mock_enabled_override(Some(false));
1289
1290        let worker = WorkerConfig {
1291            id: WorkerId::new("real-worker"),
1292            host: "192.168.1.100".to_string(),
1293            user: "user".to_string(),
1294            identity_file: "~/.ssh/id".to_string(),
1295            total_slots: 4,
1296            ..Default::default()
1297        };
1298
1299        logger.log(TestPhase::Execute, "Testing real host (mock disabled)");
1300        assert!(!use_mock_transport(&worker));
1301
1302        clear_mock_overrides();
1303        logger.pass();
1304    }
1305
1306    /// Test use_mock_transport with real host but global mock override.
1307    /// Uses global mock state - run with `cargo test -- --test-threads=1`.
1308    #[test]
1309    #[ignore = "uses global mock state; run with --test-threads=1"]
1310    fn use_mock_transport_with_global_override() {
1311        let logger = TestLogger::for_test("use_mock_transport_with_global_override");
1312
1313        // Enable mock mode globally
1314        set_mock_enabled_override(Some(true));
1315
1316        let worker = WorkerConfig {
1317            id: WorkerId::new("real-worker"),
1318            host: "192.168.1.100".to_string(),
1319            user: "user".to_string(),
1320            identity_file: "~/.ssh/id".to_string(),
1321            total_slots: 4,
1322            ..Default::default()
1323        };
1324
1325        logger.log(
1326            TestPhase::Execute,
1327            "Testing real host with global mock override",
1328        );
1329        assert!(use_mock_transport(&worker));
1330
1331        clear_mock_overrides();
1332        logger.pass();
1333    }
1334
1335    // ========================
1336    // Async transport tests (using mock)
1337    // These tests use global mock state and must be run with --test-threads=1
1338    // ========================
1339
1340    /// Test rsync_to_worker with mock transport succeeds.
1341    #[tokio::test]
1342    #[ignore = "uses global mock state; run with --test-threads=1"]
1343    async fn rsync_to_worker_mock_success() {
1344        init_test_logging();
1345        let logger = TestLogger::for_test("rsync_to_worker_mock_success");
1346
1347        // Enable mock mode
1348        set_mock_enabled_override(Some(true));
1349        set_mock_ssh_config_override(Some(MockConfig::success()));
1350        set_mock_rsync_config_override(Some(MockRsyncConfig::success()));
1351        clear_global_invocations();
1352
1353        let worker = WorkerConfig {
1354            id: WorkerId::new("mock-worker"),
1355            host: "mock://localhost".to_string(),
1356            user: "testuser".to_string(),
1357            identity_file: "~/.ssh/mock_key".to_string(),
1358            total_slots: 4,
1359            ..Default::default()
1360        };
1361
1362        // Create a temporary directory for testing
1363        let temp_dir = tempfile::tempdir().unwrap();
1364        let test_project = temp_dir.path().to_path_buf();
1365
1366        let test = RemoteCompilationTest::new(worker, test_project);
1367
1368        logger.log(
1369            TestPhase::Execute,
1370            "Calling rsync_to_worker with mock transport",
1371        );
1372        let result = test.rsync_to_worker().await;
1373
1374        logger.log_with_data(
1375            TestPhase::Verify,
1376            "Checking rsync result",
1377            serde_json::json!({ "success": result.is_ok() }),
1378        );
1379
1380        assert!(result.is_ok());
1381
1382        // Verify invocations were recorded
1383        let ssh_invocations = global_ssh_invocations_snapshot();
1384        let rsync_invocations = global_rsync_invocations_snapshot();
1385
1386        logger.log_with_data(
1387            TestPhase::Verify,
1388            "Mock invocations recorded",
1389            serde_json::json!({
1390                "ssh_calls": ssh_invocations.len(),
1391                "rsync_calls": rsync_invocations.len()
1392            }),
1393        );
1394
1395        // Should have SSH calls (connect, mkdir, disconnect) and rsync call
1396        assert!(!ssh_invocations.is_empty());
1397        assert!(!rsync_invocations.is_empty());
1398
1399        clear_mock_overrides();
1400        logger.pass();
1401    }
1402
1403    /// Test rsync_to_worker fails when SSH connection fails.
1404    #[tokio::test]
1405    #[ignore = "uses global mock state; run with --test-threads=1"]
1406    async fn rsync_to_worker_mock_ssh_failure() {
1407        init_test_logging();
1408        let logger = TestLogger::for_test("rsync_to_worker_mock_ssh_failure");
1409
1410        // Enable mock mode with SSH connection failure
1411        set_mock_enabled_override(Some(true));
1412        set_mock_ssh_config_override(Some(MockConfig::connection_failure()));
1413        clear_global_invocations();
1414
1415        let worker = WorkerConfig {
1416            id: WorkerId::new("mock-worker"),
1417            host: "mock://localhost".to_string(),
1418            user: "testuser".to_string(),
1419            identity_file: "~/.ssh/mock_key".to_string(),
1420            total_slots: 4,
1421            ..Default::default()
1422        };
1423
1424        let temp_dir = tempfile::tempdir().unwrap();
1425        let test_project = temp_dir.path().to_path_buf();
1426
1427        let test = RemoteCompilationTest::new(worker, test_project);
1428
1429        logger.log(
1430            TestPhase::Execute,
1431            "Calling rsync_to_worker with failing SSH",
1432        );
1433        let result = test.rsync_to_worker().await;
1434
1435        logger.log_with_data(
1436            TestPhase::Verify,
1437            "Checking failure result",
1438            serde_json::json!({
1439                "is_err": result.is_err(),
1440                "error": result.as_ref().err().map(|e| e.to_string())
1441            }),
1442        );
1443
1444        assert!(result.is_err());
1445        let err_msg = result.unwrap_err().to_string();
1446        assert!(err_msg.contains("Connection failed") || err_msg.contains("failed"));
1447
1448        clear_mock_overrides();
1449        logger.pass();
1450    }
1451
1452    /// Test build_remote with mock transport succeeds.
1453    #[tokio::test]
1454    #[ignore = "uses global mock state; run with --test-threads=1"]
1455    async fn build_remote_mock_success() {
1456        init_test_logging();
1457        let logger = TestLogger::for_test("build_remote_mock_success");
1458
1459        set_mock_enabled_override(Some(true));
1460        set_mock_ssh_config_override(Some(MockConfig::success()));
1461        clear_global_invocations();
1462
1463        let worker = WorkerConfig {
1464            id: WorkerId::new("mock-worker"),
1465            host: "mock://localhost".to_string(),
1466            user: "testuser".to_string(),
1467            identity_file: "~/.ssh/mock_key".to_string(),
1468            total_slots: 4,
1469            ..Default::default()
1470        };
1471
1472        let temp_dir = tempfile::tempdir().unwrap();
1473        let test_project = temp_dir.path().to_path_buf();
1474
1475        let test = RemoteCompilationTest::new(worker, test_project);
1476
1477        logger.log(
1478            TestPhase::Execute,
1479            "Calling build_remote with mock transport",
1480        );
1481        let result = test.build_remote().await;
1482
1483        logger.log_with_data(
1484            TestPhase::Verify,
1485            "Checking build result",
1486            serde_json::json!({ "success": result.is_ok() }),
1487        );
1488
1489        assert!(result.is_ok());
1490
1491        // Verify SSH execution happened
1492        let ssh_invocations = global_ssh_invocations_snapshot();
1493        assert!(ssh_invocations.iter().any(|inv| {
1494            inv.command
1495                .as_ref()
1496                .is_some_and(|c| c.contains("cargo build"))
1497        }));
1498
1499        clear_mock_overrides();
1500        logger.pass();
1501    }
1502
1503    /// Test build_remote fails when remote command fails.
1504    #[tokio::test]
1505    #[ignore = "uses global mock state; run with --test-threads=1"]
1506    async fn build_remote_mock_command_failure() {
1507        init_test_logging();
1508        let logger = TestLogger::for_test("build_remote_mock_command_failure");
1509
1510        // Configure mock to fail command execution
1511        set_mock_enabled_override(Some(true));
1512        set_mock_ssh_config_override(Some(MockConfig::command_failure(1, "compilation error")));
1513        clear_global_invocations();
1514
1515        let worker = WorkerConfig {
1516            id: WorkerId::new("mock-worker"),
1517            host: "mock://localhost".to_string(),
1518            user: "testuser".to_string(),
1519            identity_file: "~/.ssh/mock_key".to_string(),
1520            total_slots: 4,
1521            ..Default::default()
1522        };
1523
1524        let temp_dir = tempfile::tempdir().unwrap();
1525        let test_project = temp_dir.path().to_path_buf();
1526
1527        let test = RemoteCompilationTest::new(worker, test_project);
1528
1529        logger.log(
1530            TestPhase::Execute,
1531            "Calling build_remote with failing command",
1532        );
1533        let result = test.build_remote().await;
1534
1535        logger.log_with_data(
1536            TestPhase::Verify,
1537            "Checking failure",
1538            serde_json::json!({
1539                "is_err": result.is_err(),
1540                "error": result.as_ref().err().map(|e| e.to_string())
1541            }),
1542        );
1543
1544        // Note: command_failure sets fail_execute=true, which returns Err
1545        assert!(result.is_err());
1546
1547        clear_mock_overrides();
1548        logger.pass();
1549    }
1550
1551    /// Test rsync_from_worker with mock transport succeeds.
1552    #[tokio::test]
1553    #[ignore = "uses global mock state; run with --test-threads=1"]
1554    async fn rsync_from_worker_mock_success() {
1555        init_test_logging();
1556        let logger = TestLogger::for_test("rsync_from_worker_mock_success");
1557
1558        set_mock_enabled_override(Some(true));
1559        set_mock_rsync_config_override(Some(MockRsyncConfig::success()));
1560        clear_global_invocations();
1561
1562        let worker = WorkerConfig {
1563            id: WorkerId::new("mock-worker"),
1564            host: "mock://localhost".to_string(),
1565            user: "testuser".to_string(),
1566            identity_file: "~/.ssh/mock_key".to_string(),
1567            total_slots: 4,
1568            ..Default::default()
1569        };
1570
1571        let temp_dir = tempfile::tempdir().unwrap();
1572        let test_project = temp_dir.path().to_path_buf();
1573
1574        let test = RemoteCompilationTest::new(worker, test_project);
1575
1576        logger.log(
1577            TestPhase::Execute,
1578            "Calling rsync_from_worker with mock transport",
1579        );
1580        let result = test.rsync_from_worker().await;
1581
1582        logger.log_with_data(
1583            TestPhase::Verify,
1584            "Checking rsync result",
1585            serde_json::json!({ "success": result.is_ok() }),
1586        );
1587
1588        assert!(result.is_ok());
1589
1590        // Verify artifact retrieval was called
1591        let rsync_invocations = global_rsync_invocations_snapshot();
1592        assert!(!rsync_invocations.is_empty());
1593
1594        clear_mock_overrides();
1595        logger.pass();
1596    }
1597
1598    /// Test rsync_from_worker fails when artifact retrieval fails.
1599    #[tokio::test]
1600    #[ignore = "uses global mock state; run with --test-threads=1"]
1601    async fn rsync_from_worker_mock_artifact_failure() {
1602        init_test_logging();
1603        let logger = TestLogger::for_test("rsync_from_worker_mock_artifact_failure");
1604
1605        set_mock_enabled_override(Some(true));
1606        set_mock_rsync_config_override(Some(MockRsyncConfig::artifact_failure()));
1607        clear_global_invocations();
1608
1609        let worker = WorkerConfig {
1610            id: WorkerId::new("mock-worker"),
1611            host: "mock://localhost".to_string(),
1612            user: "testuser".to_string(),
1613            identity_file: "~/.ssh/mock_key".to_string(),
1614            total_slots: 4,
1615            ..Default::default()
1616        };
1617
1618        let temp_dir = tempfile::tempdir().unwrap();
1619        let test_project = temp_dir.path().to_path_buf();
1620
1621        let test = RemoteCompilationTest::new(worker, test_project);
1622
1623        logger.log(
1624            TestPhase::Execute,
1625            "Calling rsync_from_worker with failing artifacts",
1626        );
1627        let result = test.rsync_from_worker().await;
1628
1629        logger.log_with_data(
1630            TestPhase::Verify,
1631            "Checking failure",
1632            serde_json::json!({
1633                "is_err": result.is_err(),
1634                "error": result.as_ref().err().map(|e| e.to_string())
1635            }),
1636        );
1637
1638        assert!(result.is_err());
1639
1640        clear_mock_overrides();
1641        logger.pass();
1642    }
1643
1644    // ========================
1645    // Edge case tests
1646    // ========================
1647
1648    #[test]
1649    fn remote_compilation_test_empty_project_path() {
1650        let _guard = test_guard!();
1651        let logger = TestLogger::for_test("remote_compilation_test_empty_project_path");
1652
1653        let worker = WorkerConfig {
1654            id: WorkerId::new("w1"),
1655            host: "host".to_string(),
1656            user: "user".to_string(),
1657            identity_file: "~/.ssh/id_rsa".to_string(),
1658            total_slots: 1,
1659            ..Default::default()
1660        };
1661
1662        // Empty path should still work (uses "unknown" as fallback)
1663        let test = RemoteCompilationTest::new(worker, PathBuf::new());
1664
1665        let binary_name = test.get_binary_name();
1666        logger.log_with_data(
1667            TestPhase::Execute,
1668            "Binary name for empty path",
1669            serde_json::json!({ "binary_name": &binary_name }),
1670        );
1671
1672        // Should fall back to "unknown"
1673        assert_eq!(binary_name, "unknown");
1674
1675        logger.pass();
1676    }
1677
1678    #[test]
1679    fn verification_result_zero_timings() {
1680        let _guard = test_guard!();
1681        let logger = TestLogger::for_test("verification_result_zero_timings");
1682
1683        let result = VerificationResult {
1684            success: true,
1685            local_hash: BinaryHashResult {
1686                full_hash: "h".to_string(),
1687                code_hash: "c".to_string(),
1688                text_section_size: 0,
1689                is_debug: false,
1690            },
1691            remote_hash: BinaryHashResult {
1692                full_hash: "h".to_string(),
1693                code_hash: "c".to_string(),
1694                text_section_size: 0,
1695                is_debug: false,
1696            },
1697            local_build_ms: 0,
1698            rsync_up_ms: 0,
1699            compilation_ms: 0,
1700            rsync_down_ms: 0,
1701            total_ms: 0,
1702            error: None,
1703            test_marker: "ZERO".to_string(),
1704        };
1705
1706        logger.log(TestPhase::Execute, "Serializing zero-timing result");
1707        let json = serde_json::to_string(&result).unwrap();
1708        let restored: VerificationResult = serde_json::from_str(&json).unwrap();
1709
1710        assert_eq!(restored.local_build_ms, 0);
1711        assert_eq!(restored.total_ms, 0);
1712        assert!(restored.success);
1713
1714        logger.pass();
1715    }
1716
1717    #[test]
1718    fn verification_result_max_timings() {
1719        let _guard = test_guard!();
1720        let logger = TestLogger::for_test("verification_result_max_timings");
1721
1722        let result = VerificationResult {
1723            success: true,
1724            local_hash: BinaryHashResult {
1725                full_hash: "h".to_string(),
1726                code_hash: "c".to_string(),
1727                text_section_size: u64::MAX,
1728                is_debug: false,
1729            },
1730            remote_hash: BinaryHashResult {
1731                full_hash: "h".to_string(),
1732                code_hash: "c".to_string(),
1733                text_section_size: u64::MAX,
1734                is_debug: false,
1735            },
1736            local_build_ms: u64::MAX,
1737            rsync_up_ms: u64::MAX,
1738            compilation_ms: u64::MAX,
1739            rsync_down_ms: u64::MAX,
1740            total_ms: u64::MAX,
1741            error: None,
1742            test_marker: "MAX".to_string(),
1743        };
1744
1745        logger.log(TestPhase::Execute, "Serializing max-timing result");
1746        let json = serde_json::to_string(&result).unwrap();
1747        let restored: VerificationResult = serde_json::from_str(&json).unwrap();
1748
1749        assert_eq!(restored.total_ms, u64::MAX);
1750        assert!(restored.success);
1751
1752        logger.pass();
1753    }
1754
1755    #[test]
1756    fn is_mock_enabled_respects_overrides() {
1757        let _guard = test_guard!();
1758        let logger = TestLogger::for_test("is_mock_enabled_respects_overrides");
1759
1760        // Start with clean state
1761        clear_mock_overrides();
1762
1763        // Without override, depends on env (we'll set it false)
1764        set_mock_enabled_override(Some(false));
1765        assert!(!is_mock_enabled());
1766
1767        // With override true
1768        set_mock_enabled_override(Some(true));
1769        assert!(is_mock_enabled());
1770
1771        // Clear and test again
1772        clear_mock_overrides();
1773        set_mock_enabled_override(Some(false));
1774        assert!(!is_mock_enabled());
1775
1776        clear_mock_overrides();
1777        logger.pass();
1778    }
1779}