1use crate::binary_hash::{
13 BinaryHashResult, CanaryVerdict, binary_contains_marker, classify_canary, compute_binary_hash,
14};
15use crate::gc_roots::{remote_cargo_home_base_prelude, remote_cargo_home_expr};
16use crate::mock::{self, MockConfig, MockRsync, MockRsyncConfig, MockSshClient};
17use crate::ssh::{SshClient, SshOptions};
18use crate::test_change::{TestChangeGuard, TestCodeChange};
19use crate::types::WorkerConfig;
20use anyhow::{Context, Result, bail};
21use serde::{Deserialize, Serialize};
22use shell_escape::escape;
23use std::borrow::Cow;
24use std::path::{Path, PathBuf};
25use std::process::Stdio;
26use std::time::{Duration, Instant};
27use tokio::process::Command;
28use tracing::{debug, info, warn};
29use uuid::Uuid;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct VerificationResult {
34 pub success: bool,
36 pub local_hash: BinaryHashResult,
38 pub remote_hash: BinaryHashResult,
40 pub local_build_ms: u64,
42 pub rsync_up_ms: u64,
44 pub compilation_ms: u64,
46 pub rsync_down_ms: u64,
48 pub total_ms: u64,
50 pub error: Option<String>,
52 pub test_marker: String,
54 pub verdict: String,
60}
61
62#[derive(Debug, Clone)]
64pub struct RemoteCompilationTest {
65 pub worker: WorkerConfig,
67 pub test_project: PathBuf,
69 pub timeout: Duration,
71 pub ssh_options: SshOptions,
73 pub release_mode: bool,
75 pub binary_name: Option<String>,
77 pub remote_base: String,
79 pub remote_path_suffix: String,
81}
82
83fn resolve_cargo_binary() -> std::ffi::OsString {
92 resolve_cargo_binary_from(std::env::var_os("CARGO"), std::env::var_os("HOME"))
93}
94
95fn resolve_cargo_binary_from(
99 cargo_override: Option<std::ffi::OsString>,
100 home: Option<std::ffi::OsString>,
101) -> std::ffi::OsString {
102 if let Some(explicit) = cargo_override
103 && !explicit.is_empty()
104 {
105 return explicit;
106 }
107 if let Some(home) = home {
108 let candidate = PathBuf::from(home).join(".cargo").join("bin").join("cargo");
109 if candidate.is_file() {
110 return candidate.into_os_string();
111 }
112 }
113 std::ffi::OsString::from("cargo")
114}
115
116fn cargo_package_name(project: &Path) -> Option<String> {
121 let manifest = std::fs::read_to_string(project.join("Cargo.toml")).ok()?;
122 let table = toml::from_str::<toml::Table>(&manifest).ok()?;
123 table
124 .get("package")
125 .and_then(toml::Value::as_table)
126 .and_then(|pkg| pkg.get("name"))
127 .and_then(toml::Value::as_str)
128 .map(ToString::to_string)
129}
130
131fn use_mock_transport(worker: &WorkerConfig) -> bool {
132 mock::is_mock_enabled() || mock::is_mock_worker(worker)
133}
134
135fn sanitize_remote_path_component(component: &str, fallback: &str) -> String {
136 let sanitized = component
137 .chars()
138 .map(|ch| {
139 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
140 ch
141 } else {
142 '-'
143 }
144 })
145 .collect::<String>();
146 let trimmed = sanitized.trim_matches('-');
147 if trimmed.is_empty() {
148 fallback.to_string()
149 } else {
150 trimmed.to_string()
151 }
152}
153
154fn sanitize_remote_path_suffix(suffix: &str) -> String {
155 sanitize_remote_path_component(suffix, "run")
156}
157
158fn join_remote_path(base: &str, child: &str) -> String {
159 let base = base.trim_end_matches('/');
160 if base.is_empty() {
161 format!("/{child}")
162 } else {
163 format!("{base}/{child}")
164 }
165}
166
167impl Default for RemoteCompilationTest {
168 fn default() -> Self {
169 Self {
170 worker: WorkerConfig::default(),
171 test_project: PathBuf::new(),
172 timeout: Duration::from_secs(300),
173 ssh_options: SshOptions::default(),
174 release_mode: true,
175 binary_name: None,
176 remote_base: "/tmp/rch_self_test".to_string(),
177 remote_path_suffix: format!("run-{}", Uuid::new_v4()),
178 }
179 }
180}
181
182impl RemoteCompilationTest {
183 pub fn new(worker: WorkerConfig, test_project: PathBuf) -> Self {
185 Self {
186 worker,
187 test_project,
188 ..Default::default()
189 }
190 }
191
192 pub fn with_timeout(mut self, timeout: Duration) -> Self {
194 self.timeout = timeout;
195 self
196 }
197
198 pub fn with_ssh_options(mut self, options: SshOptions) -> Self {
200 self.ssh_options = options;
201 self
202 }
203
204 pub fn with_release_mode(mut self, release: bool) -> Self {
206 self.release_mode = release;
207 self
208 }
209
210 pub fn with_binary_name(mut self, name: impl Into<String>) -> Self {
212 self.binary_name = Some(name.into());
213 self
214 }
215
216 pub fn with_remote_path_suffix(mut self, suffix: impl AsRef<str>) -> Self {
218 self.remote_path_suffix = sanitize_remote_path_suffix(suffix.as_ref());
219 self
220 }
221
222 pub async fn run(&self) -> Result<VerificationResult> {
232 let start = Instant::now();
233
234 info!(
235 "Starting remote compilation verification for {:?} on worker {}",
236 self.test_project, self.worker.id
237 );
238
239 let change = TestCodeChange::for_main_rs(&self.test_project)
241 .or_else(|_| TestCodeChange::for_lib_rs(&self.test_project))
242 .context("Failed to create test change - no src/main.rs or src/lib.rs found")?;
243 let _guard = TestChangeGuard::new(change.clone())?;
244 let test_marker = change.change_id.clone();
245
246 info!("Applied test marker: {}", test_marker);
247
248 info!("Building locally for reference hash");
250 let local_build_start = Instant::now();
251 self.build_local().await.context("Local build failed")?;
252 let local_build_ms = local_build_start.elapsed().as_millis() as u64;
253 info!("Local build complete in {}ms", local_build_ms);
254
255 let local_binary_path = self.local_binary_path();
256 let local_hash = compute_binary_hash(&local_binary_path)
257 .with_context(|| format!("Failed to hash local binary: {:?}", local_binary_path))?;
258 info!("Local hash: {}", &local_hash.code_hash[..16]);
259
260 info!("Syncing source to worker {}", self.worker.id);
262 let rsync_up_start = Instant::now();
263 self.rsync_to_worker()
264 .await
265 .context("rsync to worker failed")?;
266 let rsync_up_ms = rsync_up_start.elapsed().as_millis() as u64;
267 info!("rsync upload complete in {}ms", rsync_up_ms);
268
269 info!("Building on remote worker");
271 let compile_start = Instant::now();
272 self.build_remote().await.context("Remote build failed")?;
273 let compilation_ms = compile_start.elapsed().as_millis() as u64;
274 info!("Remote build complete in {}ms", compilation_ms);
275
276 info!("Syncing artifacts from worker");
278 let rsync_down_start = Instant::now();
279 self.rsync_from_worker()
280 .await
281 .context("rsync from worker failed")?;
282 let rsync_down_ms = rsync_down_start.elapsed().as_millis() as u64;
283 info!("rsync download complete in {}ms", rsync_down_ms);
284
285 let remote_binary_path = self.remote_binary_path();
287 let remote_hash = compute_binary_hash(&remote_binary_path)
288 .with_context(|| format!("Failed to hash remote binary: {:?}", remote_binary_path))?;
289 info!("Remote hash: {}", &remote_hash.code_hash[..16]);
290
291 let marker_present =
301 binary_contains_marker(&remote_binary_path, &test_marker).unwrap_or(true);
302 let verdict = classify_canary(&local_hash, &remote_hash, marker_present, None);
303 let success = verdict.passed();
304 let total_ms = start.elapsed().as_millis() as u64;
305
306 let local_short = &local_hash.code_hash[..local_hash.code_hash.len().min(16)];
307 let remote_short = &remote_hash.code_hash[..remote_hash.code_hash.len().min(16)];
308 let error = if success {
309 if verdict.is_advisory() {
310 info!(
311 "Verification PASSED (advisory {}): worker healthy; codegen differs from the \
312 orchestrator toolchain (local={local_short} remote={remote_short})",
313 verdict.reason_code().unwrap_or("RCH-I018"),
314 );
315 } else {
316 info!("Verification PASSED: remote build reproduced the reference binary");
317 }
318 None
319 } else {
320 let code = verdict.reason_code().unwrap_or("RCH-E203");
321 let msg = if verdict == CanaryVerdict::MissingMarker {
322 format!(
323 "[{code}] canary missing test marker '{test_marker}' — remote build is corrupt or not ours"
324 )
325 } else {
326 format!(
327 "[{code}] canary {} — local={local_short} remote={remote_short}",
328 verdict.as_str(),
329 )
330 };
331 warn!("Verification FAILED: {}", msg);
332 Some(msg)
333 };
334
335 Ok(VerificationResult {
336 success,
337 local_hash,
338 remote_hash,
339 local_build_ms,
340 rsync_up_ms,
341 compilation_ms,
342 rsync_down_ms,
343 total_ms,
344 error,
345 test_marker,
346 verdict: verdict.as_str().to_string(),
347 })
348 }
349
350 fn local_binary_path(&self) -> PathBuf {
352 let binary_name = self.get_binary_name();
353 let profile = if self.release_mode {
354 "release"
355 } else {
356 "debug"
357 };
358 self.test_project
359 .join("target")
360 .join(profile)
361 .join(binary_name)
362 }
363
364 fn remote_binary_path(&self) -> PathBuf {
366 let binary_name = self.get_binary_name();
367 let profile = if self.release_mode {
368 "release"
369 } else {
370 "debug"
371 };
372 self.test_project
373 .join("target")
374 .join(format!("{}_remote", profile))
375 .join(binary_name)
376 }
377
378 fn get_binary_name(&self) -> String {
388 if let Some(name) = self.binary_name.clone() {
389 return name;
390 }
391 if !self.test_project.as_os_str().is_empty()
397 && let Some(pkg) = cargo_package_name(&self.test_project)
398 {
399 return pkg.replace('-', "_");
400 }
401 self.test_project
402 .file_name()
403 .and_then(|n| n.to_str())
404 .unwrap_or("unknown")
405 .replace('-', "_")
406 }
407
408 fn remote_project_path(&self) -> String {
410 let project_name = self
411 .test_project
412 .file_name()
413 .and_then(|n| n.to_str())
414 .map(|n| sanitize_remote_path_component(n, "self_test"))
415 .unwrap_or_else(|| "self_test".to_string());
416 let remote_path_suffix = sanitize_remote_path_suffix(&self.remote_path_suffix);
417 let project_dir = format!("{project_name}-{remote_path_suffix}");
418 join_remote_path(&self.remote_base, &project_dir)
419 }
420
421 fn remote_artifact_source(&self, profile: &str) -> String {
423 let remote_path = self.remote_project_path();
424 let remote_target = format!("{}/target/{profile}/", remote_path.trim_end_matches('/'));
425 format!(
426 "{}@{}:{}",
427 self.worker.user,
428 self.worker.host,
429 escape(Cow::from(remote_target))
430 )
431 }
432
433 fn remote_cargo_home_path(&self, session_id: &str) -> String {
440 let worker_id = sanitize_remote_path_component(self.worker.id.as_str(), "worker");
441 let session_id = sanitize_remote_path_component(session_id, "session");
442 remote_cargo_home_expr(&format!("{worker_id}-{session_id}"))
443 }
444
445 async fn build_local(&self) -> Result<()> {
447 let mut cmd = Command::new(resolve_cargo_binary());
448 cmd.arg("build");
449
450 if self.release_mode {
451 cmd.arg("--release");
452 }
453
454 cmd.current_dir(&self.test_project)
455 .env("CARGO_INCREMENTAL", "0") .env("CARGO_TARGET_DIR", self.test_project.join("target"))
462 .stdout(Stdio::piped())
463 .stderr(Stdio::piped());
464
465 debug!(
466 "Running local build: cargo build {}",
467 if self.release_mode { "--release" } else { "" }
468 );
469
470 let output = cmd
471 .output()
472 .await
473 .context("Failed to execute cargo build")?;
474
475 if !output.status.success() {
476 let stderr = String::from_utf8_lossy(&output.stderr);
477 bail!("Local build failed: {}", stderr);
478 }
479
480 Ok(())
481 }
482
483 async fn rsync_to_worker(&self) -> Result<()> {
485 let remote_path = self.remote_project_path();
486 let escaped_remote_path = escape(Cow::from(&remote_path));
487
488 if use_mock_transport(&self.worker) {
489 let mut client = MockSshClient::new(self.worker.clone(), MockConfig::from_env());
490 client.connect().await?;
491 let mkdir_cmd = format!("mkdir -p -- {}", escaped_remote_path);
492 let mkdir_result = client.execute(&mkdir_cmd).await?;
493 client.disconnect().await?;
494
495 if !mkdir_result.success() {
496 bail!("Failed to create remote directory: {}", mkdir_result.stderr);
497 }
498
499 let rsync = MockRsync::new(MockRsyncConfig::from_env());
500 let destination = format!(
501 "{}@{}:{}",
502 self.worker.user, self.worker.host, escaped_remote_path
503 );
504 rsync
505 .sync_to_remote(&self.test_project.display().to_string(), &destination, &[])
506 .await?;
507 return Ok(());
508 }
509
510 let mut client = SshClient::new(self.worker.clone(), self.ssh_options.clone());
512 client.connect().await?;
513 let mkdir_cmd = format!("mkdir -p -- {}", escaped_remote_path);
514 let mkdir_result = client.execute(&mkdir_cmd).await;
518 if let Err(e) = client.disconnect().await {
519 warn!(
520 "Failed to disconnect from {} after remote mkdir: {}",
521 self.worker.id, e
522 );
523 }
524 let mkdir_result = mkdir_result?;
525
526 if !mkdir_result.success() {
527 bail!("Failed to create remote directory: {}", mkdir_result.stderr);
528 }
529
530 let destination = format!(
531 "{}@{}:{}",
532 self.worker.user, self.worker.host, escaped_remote_path
533 );
534
535 let identity_file = shellexpand::tilde(&self.worker.identity_file);
536 let escaped_identity = escape(Cow::from(identity_file.as_ref()));
537
538 let mut cmd = Command::new("rsync");
539 cmd.arg("-az")
540 .arg("--no-owner")
541 .arg("--no-group")
542 .arg("--delete")
543 .arg("--exclude")
544 .arg("target/")
545 .arg("--exclude")
546 .arg(".git/")
547 .arg("-e")
548 .arg(format!(
549 "ssh -i {} -o StrictHostKeyChecking=accept-new -o BatchMode=yes",
550 escaped_identity
551 ))
552 .arg(format!("{}/", self.test_project.display()))
553 .arg(&destination)
554 .stdout(Stdio::piped())
555 .stderr(Stdio::piped());
556
557 debug!(
558 "Running rsync to worker: {:?}",
559 cmd.as_std().get_args().collect::<Vec<_>>()
560 );
561
562 let output = cmd.output().await.context("Failed to execute rsync")?;
563
564 if !output.status.success() {
565 let stderr = String::from_utf8_lossy(&output.stderr);
566 bail!("rsync to worker failed: {}", stderr);
567 }
568
569 Ok(())
570 }
571
572 async fn build_remote(&self) -> Result<()> {
574 let remote_path = self.remote_project_path();
575 let escaped_remote_path = escape(Cow::from(&remote_path));
576
577 let session_id = Uuid::new_v4().simple().to_string();
579 let cargo_home = self.remote_cargo_home_path(&session_id);
583 let quoted_cargo_home = format!("\"{cargo_home}\"");
584 let cargo_target_dir = format!("{}/target", remote_path);
585 let escaped_cargo_target_dir = escape(Cow::from(&cargo_target_dir));
586 let cargo_home_base_prelude = remote_cargo_home_base_prelude();
587
588 let cargo_args = if self.release_mode {
589 "cargo build --release"
590 } else {
591 "cargo build"
592 };
593 let build_cmd = format!(
595 "{cargo_home_base_prelude}; mkdir -p -- {} {} && cd {} && CARGO_HOME={} CARGO_TARGET_DIR={} CARGO_INCREMENTAL=0 {}; status=$?; rm -rf -- {}; exit $status",
596 quoted_cargo_home,
597 escaped_cargo_target_dir,
598 escaped_remote_path,
599 quoted_cargo_home,
600 escaped_cargo_target_dir,
601 cargo_args,
602 quoted_cargo_home
603 );
604
605 debug!(
606 "Running remote build with isolated cargo cache: {}",
607 build_cmd
608 );
609
610 if use_mock_transport(&self.worker) {
611 let mut client = MockSshClient::new(self.worker.clone(), MockConfig::from_env());
612 client.connect().await?;
613 let result = client.execute(&build_cmd).await?;
614 client.disconnect().await?;
615
616 if !result.success() {
617 bail!(
618 "Remote build failed (exit {}): {}",
619 result.exit_code,
620 result.stderr
621 );
622 }
623
624 return Ok(());
625 }
626
627 let mut client = SshClient::new(self.worker.clone(), self.ssh_options.clone());
628 client.connect().await?;
629 let result = client.execute(&build_cmd).await;
638 if let Err(e) = client.disconnect().await {
639 warn!(
640 "Failed to disconnect from {} after remote build: {}",
641 self.worker.id, e
642 );
643 }
644 let result = result?;
645
646 if !result.success() {
647 bail!(
648 "Remote build failed (exit {}): {}",
649 result.exit_code,
650 result.stderr
651 );
652 }
653
654 Ok(())
655 }
656
657 async fn rsync_from_worker(&self) -> Result<()> {
659 let profile = if self.release_mode {
660 "release"
661 } else {
662 "debug"
663 };
664
665 let local_dest = self
667 .test_project
668 .join("target")
669 .join(format!("{}_remote", profile));
670 std::fs::create_dir_all(&local_dest)
671 .with_context(|| format!("Failed to create directory: {:?}", local_dest))?;
672
673 if use_mock_transport(&self.worker) {
674 let rsync = MockRsync::new(MockRsyncConfig::from_env());
675 let remote_target = self.remote_artifact_source(profile);
676 rsync
677 .retrieve_artifacts(&remote_target, &local_dest.display().to_string(), &[])
678 .await?;
679 return Ok(());
680 }
681
682 let remote_target = self.remote_artifact_source(profile);
683
684 let identity_file = shellexpand::tilde(&self.worker.identity_file);
685 let escaped_identity = escape(Cow::from(identity_file.as_ref()));
686
687 let mut cmd = Command::new("rsync");
688 cmd.arg("-az")
689 .arg("--no-owner")
690 .arg("--no-group")
691 .arg("-e")
692 .arg(format!(
693 "ssh -i {} -o StrictHostKeyChecking=accept-new -o BatchMode=yes",
694 escaped_identity
695 ))
696 .arg(&remote_target)
697 .arg(format!("{}/", local_dest.display()))
698 .stdout(Stdio::piped())
699 .stderr(Stdio::piped());
700
701 debug!(
702 "Running rsync from worker: {:?}",
703 cmd.as_std().get_args().collect::<Vec<_>>()
704 );
705
706 let output = cmd.output().await.context("Failed to retrieve artifacts")?;
707
708 if !output.status.success() {
709 let stderr = String::from_utf8_lossy(&output.stderr);
710 bail!("rsync from worker failed: {}", stderr);
711 }
712
713 Ok(())
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use crate::gc_roots::RCH_CARGO_HOME_BASE_VAR;
721 use crate::mock::{
722 MockConfig, MockRsyncConfig, clear_global_invocations, clear_mock_overrides,
723 global_rsync_invocations_snapshot, global_ssh_invocations_snapshot, is_mock_enabled,
724 set_mock_enabled_override, set_mock_rsync_config_override, set_mock_ssh_config_override,
725 };
726 use crate::test_guard;
727 use crate::testing::{TestLogger, TestPhase};
728 use crate::types::WorkerId;
729
730 #[test]
731 fn resolve_cargo_binary_prefers_override_then_rustup_then_path() {
732 use std::ffi::OsString;
733
734 assert_eq!(
736 resolve_cargo_binary_from(Some(OsString::from("/opt/cargo")), None),
737 OsString::from("/opt/cargo")
738 );
739
740 assert_eq!(
742 resolve_cargo_binary_from(Some(OsString::new()), None),
743 OsString::from("cargo")
744 );
745
746 let empty_home = tempfile::tempdir().unwrap();
749 assert_eq!(
750 resolve_cargo_binary_from(None, Some(empty_home.path().as_os_str().to_os_string())),
751 OsString::from("cargo")
752 );
753
754 let home = tempfile::tempdir().unwrap();
757 let cargo_bin = home.path().join(".cargo").join("bin");
758 std::fs::create_dir_all(&cargo_bin).unwrap();
759 let cargo_path = cargo_bin.join("cargo");
760 std::fs::write(&cargo_path, b"#!/bin/sh\n").unwrap();
761 assert_eq!(
762 resolve_cargo_binary_from(None, Some(home.path().as_os_str().to_os_string())),
763 cargo_path.into_os_string()
764 );
765 }
766
767 #[test]
768 fn cargo_package_name_reads_package_not_directory() {
769 let dir = tempfile::tempdir().unwrap();
773 let proj = dir.path().join("project");
774 std::fs::create_dir_all(&proj).unwrap();
775 std::fs::write(
776 proj.join("Cargo.toml"),
777 "[package]\nname = \"rch_self_test\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n",
778 )
779 .unwrap();
780 assert_eq!(cargo_package_name(&proj).as_deref(), Some("rch_self_test"));
781
782 let empty = dir.path().join("empty");
784 std::fs::create_dir_all(&empty).unwrap();
785 assert_eq!(cargo_package_name(&empty), None);
786 }
787
788 fn init_test_logging() {
789 let _ = tracing_subscriber::fmt()
790 .with_test_writer()
791 .with_max_level(tracing::Level::DEBUG)
792 .try_init();
793 }
794
795 #[test]
800 fn verification_result_serializes_roundtrip() {
801 let _guard = test_guard!();
802 let logger = TestLogger::for_test("verification_result_serializes_roundtrip");
803
804 let result = VerificationResult {
805 success: true,
806 local_hash: BinaryHashResult {
807 full_hash: "abc123".to_string(),
808 code_hash: "def456".to_string(),
809 text_section_size: 12345,
810 is_debug: false,
811 },
812 remote_hash: BinaryHashResult {
813 full_hash: "abc123".to_string(),
814 code_hash: "def456".to_string(),
815 text_section_size: 12345,
816 is_debug: false,
817 },
818 local_build_ms: 1200,
819 rsync_up_ms: 100,
820 compilation_ms: 5000,
821 rsync_down_ms: 200,
822 total_ms: 5300,
823 error: None,
824 test_marker: "RCH_TEST_12345".to_string(),
825 verdict: "reproduced".to_string(),
826 };
827
828 logger.log(TestPhase::Execute, "Serializing VerificationResult to JSON");
829 let json = serde_json::to_string(&result).unwrap();
830 logger.log_with_data(
831 TestPhase::Execute,
832 "Serialized result",
833 serde_json::json!({ "json_len": json.len() }),
834 );
835
836 let restored: VerificationResult = serde_json::from_str(&json).unwrap();
837
838 logger.log(TestPhase::Verify, "Checking restored fields");
839 assert!(restored.success);
840 assert_eq!(restored.rsync_up_ms, 100);
841 assert_eq!(restored.compilation_ms, 5000);
842 assert_eq!(restored.test_marker, "RCH_TEST_12345");
843
844 logger.pass();
845 }
846
847 #[test]
848 fn verification_result_with_error() {
849 let _guard = test_guard!();
850 let logger = TestLogger::for_test("verification_result_with_error");
851
852 let result = VerificationResult {
853 success: false,
854 local_hash: BinaryHashResult {
855 full_hash: "abc".to_string(),
856 code_hash: "local_hash".to_string(),
857 text_section_size: 1000,
858 is_debug: false,
859 },
860 remote_hash: BinaryHashResult {
861 full_hash: "xyz".to_string(),
862 code_hash: "remote_hash".to_string(),
863 text_section_size: 1000,
864 is_debug: false,
865 },
866 local_build_ms: 900,
867 rsync_up_ms: 50,
868 compilation_ms: 3000,
869 rsync_down_ms: 50,
870 total_ms: 3100,
871 error: Some("Binary hash mismatch".to_string()),
872 test_marker: "RCH_TEST_99999".to_string(),
873 verdict: "reproduced".to_string(),
874 };
875
876 logger.log(TestPhase::Verify, "Checking failed verification result");
877 assert!(!result.success);
878 assert!(result.error.is_some());
879 assert_eq!(result.error.as_ref().unwrap(), "Binary hash mismatch");
880
881 logger.log_with_data(
882 TestPhase::Verify,
883 "Error captured correctly",
884 serde_json::json!({ "error": result.error }),
885 );
886 logger.pass();
887 }
888
889 #[test]
890 fn verification_result_serializes_with_all_fields() {
891 let _guard = test_guard!();
892 let logger = TestLogger::for_test("verification_result_serializes_with_all_fields");
893
894 let result = VerificationResult {
895 success: false,
896 local_hash: BinaryHashResult {
897 full_hash: "full_local".to_string(),
898 code_hash: "code_local".to_string(),
899 text_section_size: 5000,
900 is_debug: true,
901 },
902 remote_hash: BinaryHashResult {
903 full_hash: "full_remote".to_string(),
904 code_hash: "code_remote".to_string(),
905 text_section_size: 5001,
906 is_debug: true,
907 },
908 local_build_ms: 2500,
909 rsync_up_ms: 300,
910 compilation_ms: 15000,
911 rsync_down_ms: 400,
912 total_ms: 18200,
913 error: Some("Size mismatch: 5000 vs 5001".to_string()),
914 test_marker: "RCH_FULL_TEST".to_string(),
915 verdict: "reproduced".to_string(),
916 };
917
918 logger.log(TestPhase::Execute, "Serializing result with all fields");
919 let json = serde_json::to_string_pretty(&result).unwrap();
920
921 assert!(json.contains("\"success\": false"));
923 assert!(json.contains("\"local_build_ms\": 2500"));
924 assert!(json.contains("\"rsync_up_ms\": 300"));
925 assert!(json.contains("\"compilation_ms\": 15000"));
926 assert!(json.contains("\"rsync_down_ms\": 400"));
927 assert!(json.contains("\"total_ms\": 18200"));
928 assert!(json.contains("RCH_FULL_TEST"));
929 assert!(json.contains("Size mismatch"));
930
931 logger.log_with_data(
932 TestPhase::Verify,
933 "All fields serialized",
934 serde_json::json!({ "fields_checked": 8 }),
935 );
936 logger.pass();
937 }
938
939 #[test]
944 fn remote_compilation_test_default_values() {
945 let _guard = test_guard!();
946 let logger = TestLogger::for_test("remote_compilation_test_default_values");
947
948 let test = RemoteCompilationTest::default();
949
950 logger.log(TestPhase::Verify, "Checking default values");
951 assert_eq!(test.timeout, Duration::from_secs(300));
952 assert!(test.release_mode);
953 assert!(test.binary_name.is_none());
954 assert_eq!(test.remote_base, "/tmp/rch_self_test");
955 assert!(test.remote_path_suffix.starts_with("run-"));
956
957 logger.log_with_data(
958 TestPhase::Verify,
959 "Default values correct",
960 serde_json::json!({
961 "timeout_secs": 300,
962 "release_mode": true,
963 "remote_base": "/tmp/rch_self_test",
964 "remote_path_suffix": &test.remote_path_suffix
965 }),
966 );
967 logger.pass();
968 }
969
970 #[test]
971 fn remote_compilation_test_builder_pattern() {
972 let _guard = test_guard!();
973 let logger = TestLogger::for_test("remote_compilation_test_builder_pattern");
974
975 let worker = WorkerConfig {
976 id: WorkerId::new("test-worker"),
977 host: "worker.example.com".to_string(),
978 user: "builder".to_string(),
979 identity_file: "~/.ssh/id_rsa".to_string(),
980 total_slots: 4,
981 ..Default::default()
982 };
983
984 logger.log(TestPhase::Execute, "Building test with custom options");
985 let test = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/tmp/project"))
986 .with_timeout(Duration::from_secs(120))
987 .with_release_mode(false)
988 .with_binary_name("my_binary");
989
990 logger.log(TestPhase::Verify, "Checking builder-set values");
991 assert_eq!(test.timeout, Duration::from_secs(120));
992 assert!(!test.release_mode);
993 assert_eq!(test.binary_name, Some("my_binary".to_string()));
994 assert_eq!(test.worker.id.as_str(), "test-worker");
995
996 logger.pass();
997 }
998
999 #[test]
1000 fn remote_compilation_test_with_ssh_options() {
1001 let _guard = test_guard!();
1002 let logger = TestLogger::for_test("remote_compilation_test_with_ssh_options");
1003
1004 let worker = WorkerConfig {
1005 id: WorkerId::new("w1"),
1006 host: "host".to_string(),
1007 user: "user".to_string(),
1008 identity_file: "~/.ssh/id_rsa".to_string(),
1009 total_slots: 1,
1010 ..Default::default()
1011 };
1012
1013 let ssh_opts = SshOptions {
1014 connect_timeout: Duration::from_secs(30),
1015 command_timeout: Duration::from_secs(600),
1016 ..Default::default()
1017 };
1018
1019 logger.log(TestPhase::Execute, "Creating test with custom SSH options");
1020 let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/project"))
1021 .with_ssh_options(ssh_opts);
1022
1023 logger.log(TestPhase::Verify, "Checking SSH options");
1024 assert_eq!(test.ssh_options.connect_timeout, Duration::from_secs(30));
1025 assert_eq!(test.ssh_options.command_timeout, Duration::from_secs(600));
1026
1027 logger.pass();
1028 }
1029
1030 #[test]
1031 fn remote_compilation_test_binary_path_release() {
1032 let _guard = test_guard!();
1033 let logger = TestLogger::for_test("remote_compilation_test_binary_path_release");
1034
1035 let worker = WorkerConfig {
1036 id: WorkerId::new("w1"),
1037 host: "host".to_string(),
1038 user: "user".to_string(),
1039 identity_file: "~/.ssh/id_rsa".to_string(),
1040 total_slots: 1,
1041 ..Default::default()
1042 };
1043
1044 let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/my-project"))
1045 .with_release_mode(true)
1046 .with_binary_name("my_binary");
1047
1048 let local_path = test.local_binary_path();
1049 let remote_path = test.remote_binary_path();
1050
1051 logger.log_with_data(
1052 TestPhase::Execute,
1053 "Computed binary paths",
1054 serde_json::json!({
1055 "local": local_path.to_string_lossy(),
1056 "remote": remote_path.to_string_lossy()
1057 }),
1058 );
1059
1060 assert!(
1061 local_path
1062 .to_string_lossy()
1063 .contains("target/release/my_binary")
1064 );
1065 assert!(
1066 remote_path
1067 .to_string_lossy()
1068 .contains("target/release_remote/my_binary")
1069 );
1070
1071 logger.pass();
1072 }
1073
1074 #[test]
1075 fn remote_compilation_test_binary_path_debug() {
1076 let _guard = test_guard!();
1077 let logger = TestLogger::for_test("remote_compilation_test_binary_path_debug");
1078
1079 let worker = WorkerConfig {
1080 id: WorkerId::new("w1"),
1081 host: "host".to_string(),
1082 user: "user".to_string(),
1083 identity_file: "~/.ssh/id_rsa".to_string(),
1084 total_slots: 1,
1085 ..Default::default()
1086 };
1087
1088 let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/my-project"))
1089 .with_release_mode(false)
1090 .with_binary_name("my_binary");
1091
1092 let local_path = test.local_binary_path();
1093 let remote_path = test.remote_binary_path();
1094
1095 logger.log_with_data(
1096 TestPhase::Execute,
1097 "Computed debug binary paths",
1098 serde_json::json!({
1099 "local": local_path.to_string_lossy(),
1100 "remote": remote_path.to_string_lossy()
1101 }),
1102 );
1103
1104 assert!(
1105 local_path
1106 .to_string_lossy()
1107 .contains("target/debug/my_binary")
1108 );
1109 assert!(
1110 remote_path
1111 .to_string_lossy()
1112 .contains("target/debug_remote/my_binary")
1113 );
1114
1115 logger.pass();
1116 }
1117
1118 #[test]
1119 fn remote_compilation_test_infers_binary_name() {
1120 let _guard = test_guard!();
1121 let logger = TestLogger::for_test("remote_compilation_test_infers_binary_name");
1122
1123 let worker = WorkerConfig {
1124 id: WorkerId::new("w1"),
1125 host: "host".to_string(),
1126 user: "user".to_string(),
1127 identity_file: "~/.ssh/id_rsa".to_string(),
1128 total_slots: 1,
1129 ..Default::default()
1130 };
1131
1132 let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/my-cool-project"));
1134
1135 let binary_name = test.get_binary_name();
1136 logger.log_with_data(
1137 TestPhase::Execute,
1138 "Inferred binary name",
1139 serde_json::json!({ "binary_name": &binary_name }),
1140 );
1141
1142 assert_eq!(binary_name, "my_cool_project");
1143
1144 logger.pass();
1145 }
1146
1147 #[test]
1148 fn remote_compilation_test_infers_binary_name_edge_cases() {
1149 let _guard = test_guard!();
1150 let logger = TestLogger::for_test("remote_compilation_test_infers_binary_name_edge_cases");
1151
1152 let worker = WorkerConfig {
1153 id: WorkerId::new("w1"),
1154 host: "host".to_string(),
1155 user: "user".to_string(),
1156 identity_file: "~/.ssh/id_rsa".to_string(),
1157 total_slots: 1,
1158 ..Default::default()
1159 };
1160
1161 let test1 =
1163 RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my-very-cool-project"));
1164 assert_eq!(test1.get_binary_name(), "my_very_cool_project");
1165
1166 let test2 =
1168 RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my_project_name"));
1169 assert_eq!(test2.get_binary_name(), "my_project_name");
1170
1171 let test3 = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my-project_v2"));
1173 assert_eq!(test3.get_binary_name(), "my_project_v2");
1174
1175 let test4 = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my-project"))
1177 .with_binary_name("custom");
1178 assert_eq!(test4.get_binary_name(), "custom");
1179
1180 logger.log_with_data(
1181 TestPhase::Verify,
1182 "All edge cases handled",
1183 serde_json::json!({ "cases_tested": 4 }),
1184 );
1185 logger.pass();
1186 }
1187
1188 #[test]
1189 fn remote_compilation_test_remote_project_path() {
1190 let _guard = test_guard!();
1191 let logger = TestLogger::for_test("remote_compilation_test_remote_project_path");
1192
1193 let worker = WorkerConfig {
1194 id: WorkerId::new("w1"),
1195 host: "host".to_string(),
1196 user: "user".to_string(),
1197 identity_file: "~/.ssh/id_rsa".to_string(),
1198 total_slots: 1,
1199 ..Default::default()
1200 };
1201
1202 let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/test-project"));
1203
1204 let remote_path = test.remote_project_path();
1205 logger.log_with_data(
1206 TestPhase::Execute,
1207 "Computed remote path",
1208 serde_json::json!({ "remote_path": &remote_path }),
1209 );
1210
1211 assert!(
1212 remote_path.starts_with("/tmp/rch_self_test/test-project-run-"),
1213 "remote path should include a unique run suffix: {remote_path}"
1214 );
1215
1216 logger.pass();
1217 }
1218
1219 #[test]
1220 fn remote_compilation_test_custom_remote_path_suffix() {
1221 let _guard = test_guard!();
1222 let logger = TestLogger::for_test("remote_compilation_test_custom_remote_path_suffix");
1223
1224 let worker = WorkerConfig {
1225 id: WorkerId::new("w1"),
1226 host: "host".to_string(),
1227 user: "user".to_string(),
1228 identity_file: "~/.ssh/id_rsa".to_string(),
1229 total_slots: 1,
1230 ..Default::default()
1231 };
1232
1233 let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/test-project"))
1234 .with_remote_path_suffix("run/42 attempt 1");
1235
1236 let remote_path = test.remote_project_path();
1237 logger.log_with_data(
1238 TestPhase::Execute,
1239 "Computed remote path with custom suffix",
1240 serde_json::json!({ "remote_path": &remote_path }),
1241 );
1242
1243 assert_eq!(
1244 remote_path,
1245 "/tmp/rch_self_test/test-project-run-42-attempt-1"
1246 );
1247
1248 logger.pass();
1249 }
1250
1251 #[test]
1252 fn remote_compilation_test_remote_path_sanitizes_public_suffix_field() {
1253 let _guard = test_guard!();
1254 let logger = TestLogger::for_test(
1255 "remote_compilation_test_remote_path_sanitizes_public_suffix_field",
1256 );
1257
1258 let worker = WorkerConfig {
1259 id: WorkerId::new("w1"),
1260 host: "host".to_string(),
1261 user: "user".to_string(),
1262 identity_file: "~/.ssh/id_rsa".to_string(),
1263 total_slots: 1,
1264 ..Default::default()
1265 };
1266
1267 let mut test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/test-project"));
1268 test.remote_path_suffix = "../unsafe path".to_string();
1269
1270 let remote_path = test.remote_project_path();
1271 logger.log_with_data(
1272 TestPhase::Execute,
1273 "Computed remote path with directly-mutated suffix",
1274 serde_json::json!({ "remote_path": &remote_path }),
1275 );
1276
1277 assert_eq!(
1278 remote_path,
1279 "/tmp/rch_self_test/test-project-..-unsafe-path"
1280 );
1281
1282 logger.pass();
1283 }
1284
1285 #[test]
1286 fn remote_compilation_test_instances_do_not_share_remote_paths() {
1287 let _guard = test_guard!();
1288 let logger =
1289 TestLogger::for_test("remote_compilation_test_instances_do_not_share_remote_paths");
1290
1291 let worker = WorkerConfig {
1292 id: WorkerId::new("w1"),
1293 host: "host".to_string(),
1294 user: "user".to_string(),
1295 identity_file: "~/.ssh/id_rsa".to_string(),
1296 total_slots: 1,
1297 ..Default::default()
1298 };
1299
1300 let first = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/home/user/project"));
1301 let second = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"));
1302
1303 let first_path = first.remote_project_path();
1304 let second_path = second.remote_project_path();
1305 logger.log_with_data(
1306 TestPhase::Verify,
1307 "Computed independent remote paths",
1308 serde_json::json!({
1309 "first_path": &first_path,
1310 "second_path": &second_path
1311 }),
1312 );
1313
1314 assert_ne!(first_path, second_path);
1315
1316 logger.pass();
1317 }
1318
1319 #[test]
1320 fn remote_compilation_test_with_custom_remote_base() {
1321 let _guard = test_guard!();
1322 let logger = TestLogger::for_test("remote_compilation_test_with_custom_remote_base");
1323
1324 let worker = WorkerConfig {
1325 id: WorkerId::new("w1"),
1326 host: "host".to_string(),
1327 user: "user".to_string(),
1328 identity_file: "~/.ssh/id_rsa".to_string(),
1329 total_slots: 1,
1330 ..Default::default()
1331 };
1332
1333 let mut test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"))
1334 .with_remote_path_suffix("custom-suffix");
1335 test.remote_base = "/custom/build/dir/".to_string();
1336
1337 let remote_path = test.remote_project_path();
1338 logger.log_with_data(
1339 TestPhase::Execute,
1340 "Computed remote path with custom base",
1341 serde_json::json!({
1342 "remote_base": "/custom/build/dir/",
1343 "remote_path": &remote_path
1344 }),
1345 );
1346
1347 assert_eq!(remote_path, "/custom/build/dir/project-custom-suffix");
1348
1349 logger.pass();
1350 }
1351
1352 #[test]
1353 fn remote_compilation_test_artifact_source_quotes_remote_base() {
1354 let _guard = test_guard!();
1355 let logger =
1356 TestLogger::for_test("remote_compilation_test_artifact_source_quotes_remote_base");
1357
1358 let worker = WorkerConfig {
1359 id: WorkerId::new("w1"),
1360 host: "host".to_string(),
1361 user: "user".to_string(),
1362 identity_file: "~/.ssh/id_rsa".to_string(),
1363 total_slots: 1,
1364 ..Default::default()
1365 };
1366
1367 let mut test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"))
1368 .with_remote_path_suffix("run-1");
1369 test.remote_base = "/tmp/rch self test".to_string();
1370
1371 let source = test.remote_artifact_source("release");
1372 logger.log_with_data(
1373 TestPhase::Verify,
1374 "Computed quoted artifact source",
1375 serde_json::json!({ "source": &source }),
1376 );
1377
1378 assert_eq!(
1379 source,
1380 "user@host:'/tmp/rch self test/project-run-1/target/release/'"
1381 );
1382
1383 logger.pass();
1384 }
1385
1386 #[test]
1387 fn remote_compilation_test_cargo_home_sanitizes_worker_id() {
1388 let _guard = test_guard!();
1389 let logger = TestLogger::for_test("remote_compilation_test_cargo_home_sanitizes_worker_id");
1390
1391 let worker = WorkerConfig {
1392 id: WorkerId::new("worker/one with spaces"),
1393 host: "host".to_string(),
1394 user: "user".to_string(),
1395 identity_file: "~/.ssh/id_rsa".to_string(),
1396 total_slots: 1,
1397 ..Default::default()
1398 };
1399
1400 let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"));
1401 let cargo_home = test.remote_cargo_home_path("run/7 with spaces");
1402 logger.log_with_data(
1403 TestPhase::Verify,
1404 "Computed sanitized cargo home",
1405 serde_json::json!({ "cargo_home": &cargo_home }),
1406 );
1407
1408 assert_eq!(
1412 cargo_home,
1413 "${RCH_CH_BASE}/rch-cargo-home-worker-one-with-spaces-run-7-with-spaces"
1414 );
1415
1416 logger.pass();
1417 }
1418
1419 #[test]
1424 fn cargo_home_base_prelude_does_not_hardcode_tmp() {
1425 let prelude = remote_cargo_home_base_prelude();
1426 assert!(prelude.contains(RCH_CARGO_HOME_BASE_VAR), "{prelude}");
1428 assert!(
1429 prelude.contains("$TMPDIR") || prelude.contains("${TMPDIR"),
1430 "{prelude}"
1431 );
1432 assert!(prelude.contains("/data/tmp"), "{prelude}");
1433 assert!(prelude.contains("[ -n \"${RCH_CH_BASE}\" ]"), "{prelude}");
1436 assert!(prelude.contains("[ -d \"${RCH_CH_BASE}\" ]"), "{prelude}");
1437 assert!(!prelude.contains("[ -n \"RCH_CH_BASE\" ]"), "{prelude}");
1438 assert!(!prelude.contains("[ -d \"RCH_CH_BASE\" ]"), "{prelude}");
1439 assert!(
1441 !prelude.contains("/tmp/rch"),
1442 "must not bake a /tmp path: {prelude}"
1443 );
1444 }
1445
1446 #[cfg(unix)]
1449 #[test]
1450 fn cargo_home_base_prelude_resolves_tmpdir_first_then_data_tmp() {
1451 use std::process::Command;
1452 use tempfile::tempdir;
1453
1454 let prelude = remote_cargo_home_base_prelude();
1455 let var = RCH_CARGO_HOME_BASE_VAR;
1456 let run = |tmpdir_env: Option<&str>| -> String {
1458 let mut cmd = Command::new("sh");
1459 cmd.arg("-c")
1460 .arg(format!("{prelude}; printf '%s' \"${var}\""));
1461 cmd.env_remove("TMPDIR");
1462 if let Some(v) = tmpdir_env {
1463 cmd.env("TMPDIR", v);
1464 }
1465 let out = cmd.output().expect("run prelude under sh");
1466 String::from_utf8_lossy(&out.stdout).into_owned()
1467 };
1468
1469 let dir = tempdir().expect("tempdir");
1471 let real = dir.path().to_str().unwrap();
1472 assert_eq!(run(Some(real)), real, "a real $TMPDIR must be honored");
1473
1474 let resolved = run(None);
1477 assert!(
1478 resolved == "/data/tmp" || resolved == "/tmp",
1479 "unset $TMPDIR must fall back to /data/tmp or /tmp, got {resolved:?}"
1480 );
1481
1482 let bogus = run(Some("/nonexistent-rch-tmpdir-xyz"));
1485 assert!(
1486 bogus == "/data/tmp" || bogus == "/tmp",
1487 "a non-directory $TMPDIR must be rejected, got {bogus:?}"
1488 );
1489 }
1490
1491 #[test]
1492 fn verification_result_timing_fields() {
1493 let _guard = test_guard!();
1494 let logger = TestLogger::for_test("verification_result_timing_fields");
1495
1496 let result = VerificationResult {
1497 success: true,
1498 local_hash: BinaryHashResult {
1499 full_hash: "a".to_string(),
1500 code_hash: "b".to_string(),
1501 text_section_size: 100,
1502 is_debug: false,
1503 },
1504 remote_hash: BinaryHashResult {
1505 full_hash: "a".to_string(),
1506 code_hash: "b".to_string(),
1507 text_section_size: 100,
1508 is_debug: false,
1509 },
1510 local_build_ms: 1200,
1511 rsync_up_ms: 150,
1512 compilation_ms: 8000,
1513 rsync_down_ms: 200,
1514 total_ms: 8500,
1515 error: None,
1516 test_marker: "RCH_TEST_1".to_string(),
1517 verdict: "reproduced".to_string(),
1518 };
1519
1520 let sum = result.rsync_up_ms + result.compilation_ms + result.rsync_down_ms;
1522 logger.log_with_data(
1523 TestPhase::Verify,
1524 "Checking timing consistency",
1525 serde_json::json!({
1526 "sum_of_phases": sum,
1527 "total_reported": result.total_ms
1528 }),
1529 );
1530
1531 assert!(result.total_ms >= sum - 100); logger.pass();
1535 }
1536
1537 #[test]
1542 fn use_mock_transport_with_mock_host() {
1543 let _guard = test_guard!();
1544 let logger = TestLogger::for_test("use_mock_transport_with_mock_host");
1545
1546 let worker = WorkerConfig {
1547 id: WorkerId::new("mock-worker"),
1548 host: "mock://localhost".to_string(),
1549 user: "user".to_string(),
1550 identity_file: "~/.ssh/id".to_string(),
1551 total_slots: 4,
1552 ..Default::default()
1553 };
1554
1555 logger.log(TestPhase::Execute, "Testing mock:// host detection");
1556 assert!(use_mock_transport(&worker));
1557
1558 logger.pass();
1559 }
1560
1561 #[test]
1564 #[ignore = "uses global mock state; run with --test-threads=1"]
1565 fn use_mock_transport_with_real_host() {
1566 let logger = TestLogger::for_test("use_mock_transport_with_real_host");
1567
1568 set_mock_enabled_override(Some(false));
1570
1571 let worker = WorkerConfig {
1572 id: WorkerId::new("real-worker"),
1573 host: "192.168.1.100".to_string(),
1574 user: "user".to_string(),
1575 identity_file: "~/.ssh/id".to_string(),
1576 total_slots: 4,
1577 ..Default::default()
1578 };
1579
1580 logger.log(TestPhase::Execute, "Testing real host (mock disabled)");
1581 assert!(!use_mock_transport(&worker));
1582
1583 clear_mock_overrides();
1584 logger.pass();
1585 }
1586
1587 #[test]
1590 #[ignore = "uses global mock state; run with --test-threads=1"]
1591 fn use_mock_transport_with_global_override() {
1592 let logger = TestLogger::for_test("use_mock_transport_with_global_override");
1593
1594 set_mock_enabled_override(Some(true));
1596
1597 let worker = WorkerConfig {
1598 id: WorkerId::new("real-worker"),
1599 host: "192.168.1.100".to_string(),
1600 user: "user".to_string(),
1601 identity_file: "~/.ssh/id".to_string(),
1602 total_slots: 4,
1603 ..Default::default()
1604 };
1605
1606 logger.log(
1607 TestPhase::Execute,
1608 "Testing real host with global mock override",
1609 );
1610 assert!(use_mock_transport(&worker));
1611
1612 clear_mock_overrides();
1613 logger.pass();
1614 }
1615
1616 #[tokio::test]
1623 #[ignore = "uses global mock state; run with --test-threads=1"]
1624 async fn rsync_to_worker_mock_success() {
1625 init_test_logging();
1626 let logger = TestLogger::for_test("rsync_to_worker_mock_success");
1627
1628 set_mock_enabled_override(Some(true));
1630 set_mock_ssh_config_override(Some(MockConfig::success()));
1631 set_mock_rsync_config_override(Some(MockRsyncConfig::success()));
1632 clear_global_invocations();
1633
1634 let worker = WorkerConfig {
1635 id: WorkerId::new("mock-worker"),
1636 host: "mock://localhost".to_string(),
1637 user: "testuser".to_string(),
1638 identity_file: "~/.ssh/mock_key".to_string(),
1639 total_slots: 4,
1640 ..Default::default()
1641 };
1642
1643 let temp_dir = tempfile::tempdir().unwrap();
1645 let test_project = temp_dir.path().to_path_buf();
1646
1647 let test = RemoteCompilationTest::new(worker, test_project);
1648
1649 logger.log(
1650 TestPhase::Execute,
1651 "Calling rsync_to_worker with mock transport",
1652 );
1653 let result = test.rsync_to_worker().await;
1654
1655 logger.log_with_data(
1656 TestPhase::Verify,
1657 "Checking rsync result",
1658 serde_json::json!({ "success": result.is_ok() }),
1659 );
1660
1661 assert!(result.is_ok());
1662
1663 let ssh_invocations = global_ssh_invocations_snapshot();
1665 let rsync_invocations = global_rsync_invocations_snapshot();
1666
1667 logger.log_with_data(
1668 TestPhase::Verify,
1669 "Mock invocations recorded",
1670 serde_json::json!({
1671 "ssh_calls": ssh_invocations.len(),
1672 "rsync_calls": rsync_invocations.len()
1673 }),
1674 );
1675
1676 assert!(!ssh_invocations.is_empty());
1678 assert!(!rsync_invocations.is_empty());
1679
1680 clear_mock_overrides();
1681 logger.pass();
1682 }
1683
1684 #[tokio::test]
1686 #[ignore = "uses global mock state; run with --test-threads=1"]
1687 async fn rsync_to_worker_mock_ssh_failure() {
1688 init_test_logging();
1689 let logger = TestLogger::for_test("rsync_to_worker_mock_ssh_failure");
1690
1691 set_mock_enabled_override(Some(true));
1693 set_mock_ssh_config_override(Some(MockConfig::connection_failure()));
1694 clear_global_invocations();
1695
1696 let worker = WorkerConfig {
1697 id: WorkerId::new("mock-worker"),
1698 host: "mock://localhost".to_string(),
1699 user: "testuser".to_string(),
1700 identity_file: "~/.ssh/mock_key".to_string(),
1701 total_slots: 4,
1702 ..Default::default()
1703 };
1704
1705 let temp_dir = tempfile::tempdir().unwrap();
1706 let test_project = temp_dir.path().to_path_buf();
1707
1708 let test = RemoteCompilationTest::new(worker, test_project);
1709
1710 logger.log(
1711 TestPhase::Execute,
1712 "Calling rsync_to_worker with failing SSH",
1713 );
1714 let result = test.rsync_to_worker().await;
1715
1716 logger.log_with_data(
1717 TestPhase::Verify,
1718 "Checking failure result",
1719 serde_json::json!({
1720 "is_err": result.is_err(),
1721 "error": result.as_ref().err().map(|e| e.to_string())
1722 }),
1723 );
1724
1725 assert!(result.is_err());
1726 let err_msg = result.unwrap_err().to_string();
1727 assert!(err_msg.contains("Connection failed") || err_msg.contains("failed"));
1728
1729 clear_mock_overrides();
1730 logger.pass();
1731 }
1732
1733 #[tokio::test]
1735 #[ignore = "uses global mock state; run with --test-threads=1"]
1736 async fn build_remote_mock_success() {
1737 init_test_logging();
1738 let logger = TestLogger::for_test("build_remote_mock_success");
1739
1740 set_mock_enabled_override(Some(true));
1741 set_mock_ssh_config_override(Some(MockConfig::success()));
1742 clear_global_invocations();
1743
1744 let worker = WorkerConfig {
1745 id: WorkerId::new("mock-worker"),
1746 host: "mock://localhost".to_string(),
1747 user: "testuser".to_string(),
1748 identity_file: "~/.ssh/mock_key".to_string(),
1749 total_slots: 4,
1750 ..Default::default()
1751 };
1752
1753 let temp_dir = tempfile::tempdir().unwrap();
1754 let test_project = temp_dir.path().to_path_buf();
1755
1756 let test = RemoteCompilationTest::new(worker, test_project);
1757
1758 logger.log(
1759 TestPhase::Execute,
1760 "Calling build_remote with mock transport",
1761 );
1762 let result = test.build_remote().await;
1763
1764 logger.log_with_data(
1765 TestPhase::Verify,
1766 "Checking build result",
1767 serde_json::json!({ "success": result.is_ok() }),
1768 );
1769
1770 assert!(result.is_ok());
1771
1772 let ssh_invocations = global_ssh_invocations_snapshot();
1774 assert!(ssh_invocations.iter().any(|inv| {
1775 inv.command
1776 .as_ref()
1777 .is_some_and(|c| c.contains("cargo build"))
1778 }));
1779
1780 clear_mock_overrides();
1781 logger.pass();
1782 }
1783
1784 #[tokio::test]
1786 #[ignore = "uses global mock state; run with --test-threads=1"]
1787 async fn build_remote_mock_command_failure() {
1788 init_test_logging();
1789 let logger = TestLogger::for_test("build_remote_mock_command_failure");
1790
1791 set_mock_enabled_override(Some(true));
1793 set_mock_ssh_config_override(Some(MockConfig::command_failure(1, "compilation error")));
1794 clear_global_invocations();
1795
1796 let worker = WorkerConfig {
1797 id: WorkerId::new("mock-worker"),
1798 host: "mock://localhost".to_string(),
1799 user: "testuser".to_string(),
1800 identity_file: "~/.ssh/mock_key".to_string(),
1801 total_slots: 4,
1802 ..Default::default()
1803 };
1804
1805 let temp_dir = tempfile::tempdir().unwrap();
1806 let test_project = temp_dir.path().to_path_buf();
1807
1808 let test = RemoteCompilationTest::new(worker, test_project);
1809
1810 logger.log(
1811 TestPhase::Execute,
1812 "Calling build_remote with failing command",
1813 );
1814 let result = test.build_remote().await;
1815
1816 logger.log_with_data(
1817 TestPhase::Verify,
1818 "Checking failure",
1819 serde_json::json!({
1820 "is_err": result.is_err(),
1821 "error": result.as_ref().err().map(|e| e.to_string())
1822 }),
1823 );
1824
1825 assert!(result.is_err());
1827
1828 clear_mock_overrides();
1829 logger.pass();
1830 }
1831
1832 #[tokio::test]
1834 #[ignore = "uses global mock state; run with --test-threads=1"]
1835 async fn rsync_from_worker_mock_success() {
1836 init_test_logging();
1837 let logger = TestLogger::for_test("rsync_from_worker_mock_success");
1838
1839 set_mock_enabled_override(Some(true));
1840 set_mock_rsync_config_override(Some(MockRsyncConfig::success()));
1841 clear_global_invocations();
1842
1843 let worker = WorkerConfig {
1844 id: WorkerId::new("mock-worker"),
1845 host: "mock://localhost".to_string(),
1846 user: "testuser".to_string(),
1847 identity_file: "~/.ssh/mock_key".to_string(),
1848 total_slots: 4,
1849 ..Default::default()
1850 };
1851
1852 let temp_dir = tempfile::tempdir().unwrap();
1853 let test_project = temp_dir.path().to_path_buf();
1854
1855 let test = RemoteCompilationTest::new(worker, test_project);
1856
1857 logger.log(
1858 TestPhase::Execute,
1859 "Calling rsync_from_worker with mock transport",
1860 );
1861 let result = test.rsync_from_worker().await;
1862
1863 logger.log_with_data(
1864 TestPhase::Verify,
1865 "Checking rsync result",
1866 serde_json::json!({ "success": result.is_ok() }),
1867 );
1868
1869 assert!(result.is_ok());
1870
1871 let rsync_invocations = global_rsync_invocations_snapshot();
1873 assert!(!rsync_invocations.is_empty());
1874
1875 clear_mock_overrides();
1876 logger.pass();
1877 }
1878
1879 #[tokio::test]
1881 #[ignore = "uses global mock state; run with --test-threads=1"]
1882 async fn rsync_from_worker_mock_artifact_failure() {
1883 init_test_logging();
1884 let logger = TestLogger::for_test("rsync_from_worker_mock_artifact_failure");
1885
1886 set_mock_enabled_override(Some(true));
1887 set_mock_rsync_config_override(Some(MockRsyncConfig::artifact_failure()));
1888 clear_global_invocations();
1889
1890 let worker = WorkerConfig {
1891 id: WorkerId::new("mock-worker"),
1892 host: "mock://localhost".to_string(),
1893 user: "testuser".to_string(),
1894 identity_file: "~/.ssh/mock_key".to_string(),
1895 total_slots: 4,
1896 ..Default::default()
1897 };
1898
1899 let temp_dir = tempfile::tempdir().unwrap();
1900 let test_project = temp_dir.path().to_path_buf();
1901
1902 let test = RemoteCompilationTest::new(worker, test_project);
1903
1904 logger.log(
1905 TestPhase::Execute,
1906 "Calling rsync_from_worker with failing artifacts",
1907 );
1908 let result = test.rsync_from_worker().await;
1909
1910 logger.log_with_data(
1911 TestPhase::Verify,
1912 "Checking failure",
1913 serde_json::json!({
1914 "is_err": result.is_err(),
1915 "error": result.as_ref().err().map(|e| e.to_string())
1916 }),
1917 );
1918
1919 assert!(result.is_err());
1920
1921 clear_mock_overrides();
1922 logger.pass();
1923 }
1924
1925 #[test]
1930 fn remote_compilation_test_empty_project_path() {
1931 let _guard = test_guard!();
1932 let logger = TestLogger::for_test("remote_compilation_test_empty_project_path");
1933
1934 let worker = WorkerConfig {
1935 id: WorkerId::new("w1"),
1936 host: "host".to_string(),
1937 user: "user".to_string(),
1938 identity_file: "~/.ssh/id_rsa".to_string(),
1939 total_slots: 1,
1940 ..Default::default()
1941 };
1942
1943 let test = RemoteCompilationTest::new(worker, PathBuf::new());
1945
1946 let binary_name = test.get_binary_name();
1947 logger.log_with_data(
1948 TestPhase::Execute,
1949 "Binary name for empty path",
1950 serde_json::json!({ "binary_name": &binary_name }),
1951 );
1952
1953 assert_eq!(binary_name, "unknown");
1955
1956 logger.pass();
1957 }
1958
1959 #[test]
1960 fn verification_result_zero_timings() {
1961 let _guard = test_guard!();
1962 let logger = TestLogger::for_test("verification_result_zero_timings");
1963
1964 let result = VerificationResult {
1965 success: true,
1966 local_hash: BinaryHashResult {
1967 full_hash: "h".to_string(),
1968 code_hash: "c".to_string(),
1969 text_section_size: 0,
1970 is_debug: false,
1971 },
1972 remote_hash: BinaryHashResult {
1973 full_hash: "h".to_string(),
1974 code_hash: "c".to_string(),
1975 text_section_size: 0,
1976 is_debug: false,
1977 },
1978 local_build_ms: 0,
1979 rsync_up_ms: 0,
1980 compilation_ms: 0,
1981 rsync_down_ms: 0,
1982 total_ms: 0,
1983 error: None,
1984 test_marker: "ZERO".to_string(),
1985 verdict: "reproduced".to_string(),
1986 };
1987
1988 logger.log(TestPhase::Execute, "Serializing zero-timing result");
1989 let json = serde_json::to_string(&result).unwrap();
1990 let restored: VerificationResult = serde_json::from_str(&json).unwrap();
1991
1992 assert_eq!(restored.local_build_ms, 0);
1993 assert_eq!(restored.total_ms, 0);
1994 assert!(restored.success);
1995
1996 logger.pass();
1997 }
1998
1999 #[test]
2000 fn verification_result_max_timings() {
2001 let _guard = test_guard!();
2002 let logger = TestLogger::for_test("verification_result_max_timings");
2003
2004 let result = VerificationResult {
2005 success: true,
2006 local_hash: BinaryHashResult {
2007 full_hash: "h".to_string(),
2008 code_hash: "c".to_string(),
2009 text_section_size: u64::MAX,
2010 is_debug: false,
2011 },
2012 remote_hash: BinaryHashResult {
2013 full_hash: "h".to_string(),
2014 code_hash: "c".to_string(),
2015 text_section_size: u64::MAX,
2016 is_debug: false,
2017 },
2018 local_build_ms: u64::MAX,
2019 rsync_up_ms: u64::MAX,
2020 compilation_ms: u64::MAX,
2021 rsync_down_ms: u64::MAX,
2022 total_ms: u64::MAX,
2023 error: None,
2024 test_marker: "MAX".to_string(),
2025 verdict: "reproduced".to_string(),
2026 };
2027
2028 logger.log(TestPhase::Execute, "Serializing max-timing result");
2029 let json = serde_json::to_string(&result).unwrap();
2030 let restored: VerificationResult = serde_json::from_str(&json).unwrap();
2031
2032 assert_eq!(restored.total_ms, u64::MAX);
2033 assert!(restored.success);
2034
2035 logger.pass();
2036 }
2037
2038 #[test]
2039 fn is_mock_enabled_respects_overrides() {
2040 let _guard = test_guard!();
2041 let logger = TestLogger::for_test("is_mock_enabled_respects_overrides");
2042
2043 clear_mock_overrides();
2045
2046 set_mock_enabled_override(Some(false));
2048 assert!(!is_mock_enabled());
2049
2050 set_mock_enabled_override(Some(true));
2052 assert!(is_mock_enabled());
2053
2054 clear_mock_overrides();
2056 set_mock_enabled_override(Some(false));
2057 assert!(!is_mock_enabled());
2058
2059 clear_mock_overrides();
2060 logger.pass();
2061 }
2062}