1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct VerificationResult {
31 pub success: bool,
33 pub local_hash: BinaryHashResult,
35 pub remote_hash: BinaryHashResult,
37 pub local_build_ms: u64,
39 pub rsync_up_ms: u64,
41 pub compilation_ms: u64,
43 pub rsync_down_ms: u64,
45 pub total_ms: u64,
47 pub error: Option<String>,
49 pub test_marker: String,
51}
52
53#[derive(Debug, Clone)]
55pub struct RemoteCompilationTest {
56 pub worker: WorkerConfig,
58 pub test_project: PathBuf,
60 pub timeout: Duration,
62 pub ssh_options: SshOptions,
64 pub release_mode: bool,
66 pub binary_name: Option<String>,
68 pub remote_base: String,
70 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 pub fn new(worker: WorkerConfig, test_project: PathBuf) -> Self {
128 Self {
129 worker,
130 test_project,
131 ..Default::default()
132 }
133 }
134
135 pub fn with_timeout(mut self, timeout: Duration) -> Self {
137 self.timeout = timeout;
138 self
139 }
140
141 pub fn with_ssh_options(mut self, options: SshOptions) -> Self {
143 self.ssh_options = options;
144 self
145 }
146
147 pub fn with_release_mode(mut self, release: bool) -> Self {
149 self.release_mode = release;
150 self
151 }
152
153 pub fn with_binary_name(mut self, name: impl Into<String>) -> Self {
155 self.binary_name = Some(name.into());
156 self
157 }
158
159 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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") .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 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 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 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 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 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 async fn rsync_from_worker(&self) -> Result<()> {
518 let profile = if self.release_mode {
519 "release"
520 } else {
521 "debug"
522 };
523
524 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 #[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 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 #[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 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 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 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 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 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 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 assert!(result.total_ms >= sum - 100); logger.pass();
1254 }
1255
1256 #[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]
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 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]
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 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 #[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 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 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 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 assert!(!ssh_invocations.is_empty());
1397 assert!(!rsync_invocations.is_empty());
1398
1399 clear_mock_overrides();
1400 logger.pass();
1401 }
1402
1403 #[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 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 #[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 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 #[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 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 assert!(result.is_err());
1546
1547 clear_mock_overrides();
1548 logger.pass();
1549 }
1550
1551 #[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 let rsync_invocations = global_rsync_invocations_snapshot();
1592 assert!(!rsync_invocations.is_empty());
1593
1594 clear_mock_overrides();
1595 logger.pass();
1596 }
1597
1598 #[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 #[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 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 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 clear_mock_overrides();
1762
1763 set_mock_enabled_override(Some(false));
1765 assert!(!is_mock_enabled());
1766
1767 set_mock_enabled_override(Some(true));
1769 assert!(is_mock_enabled());
1770
1771 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}