//! Build -> Syncback -> Rebuild roundtrip tests. //! //! These tests verify that: //! 1. Building a project to rbxl //! 2. Syncing back to a fresh directory //! 3. Rebuilding from the syncback result //! //! Produces semantically identical output. This validates that the syncback //! system correctly reconstructs project structure from binary place files. use std::collections::HashMap; use std::{fs, path::Path}; use rbx_dom_weak::{ustr, WeakDom}; use tempfile::tempdir; use crate::rojo_test::io_util::BUILD_TESTS_PATH; use crate::rojo_test::roundtrip_util::{ copy_project_dir, ensure_project_dirs_exist, run_rojo_build, run_rojo_syncback_clean, }; /// Generate roundtrip tests for build-test projects. /// /// Each test: /// 1. Builds the project to an rbxl file /// 2. Creates a fresh directory with just the project file /// 3. Syncs back from the rbxl to the fresh directory /// 4. Rebuilds from the syncback result /// 5. Compares the two rbxl files for semantic equality macro_rules! roundtrip_tests { ($($test_name:ident),* $(,)?) => {$( #[test] fn $test_name() { run_roundtrip_test(stringify!($test_name)); } )*}; } fn run_roundtrip_test(build_test_name: &str) { let _ = env_logger::try_init(); let project_path = Path::new(BUILD_TESTS_PATH).join(build_test_name); // 1. Build original project -> rbxm (model format, not place) let (_tmp1, original_rbxm) = run_rojo_build(&project_path, "original.rbxm"); // 2. Create fresh syncback target with full project structure let syncback_dir = tempdir().expect("Failed to create temp dir"); copy_project_dir(&project_path, syncback_dir.path()); // 3. Syncback from rbxm to directory assert!( run_rojo_syncback_clean(syncback_dir.path(), &original_rbxm), "Syncback should succeed for {}", build_test_name ); // 3b. Ensure base project directories still exist after syncback. // Clean mode may remove directories that only contain meta files or .gitkeep, // but the project still needs them for the rebuild. ensure_project_dirs_exist(syncback_dir.path()); // 4. Build from syncback result -> rbxm let (_tmp2, roundtrip_rbxm) = run_rojo_build(syncback_dir.path(), "roundtrip.rbxm"); // 5. Compare both rbxm files (semantic comparison) assert_rbx_equal(&original_rbxm, &roundtrip_rbxm, build_test_name); } /// Compare two rbxl files for semantic equality. /// /// This compares the instance trees, checking that all instances have the same /// names, class names, and properties (excluding transient properties like Ref). fn assert_rbx_equal(file_a: &Path, file_b: &Path, test_name: &str) { let data_a = fs::read(file_a).expect("Failed to read original rbxl"); let data_b = fs::read(file_b).expect("Failed to read roundtrip rbxl"); let dom_a = rbx_binary::from_reader(data_a.as_slice()).expect("Failed to parse original rbxl"); let dom_b = rbx_binary::from_reader(data_b.as_slice()).expect("Failed to parse roundtrip rbxl"); // Compare the root's children (DataModel children) compare_children( &dom_a, dom_a.root_ref(), &dom_b, dom_b.root_ref(), test_name, ); } /// Recursively compare children of two instances. /// /// Uses sorted Vec-based comparison (not HashMap) so that duplicate-named /// children are correctly handled. HashMap would silently overwrite entries /// sharing a name, making the test blind to lost duplicates. fn compare_children( dom_a: &WeakDom, ref_a: rbx_dom_weak::types::Ref, dom_b: &WeakDom, ref_b: rbx_dom_weak::types::Ref, test_name: &str, ) { let inst_a = dom_a.get_by_ref(ref_a).expect("Instance A should exist"); let inst_b = dom_b.get_by_ref(ref_b).expect("Instance B should exist"); let children_a = inst_a.children(); let children_b = inst_b.children(); if children_a.len() != children_b.len() { let names_a: Vec<&str> = children_a .iter() .filter_map(|r| dom_a.get_by_ref(*r).map(|i| i.name.as_str())) .collect(); let names_b: Vec<&str> = children_b .iter() .filter_map(|r| dom_b.get_by_ref(*r).map(|i| i.name.as_str())) .collect(); panic!( "[{}] Child count mismatch under '{}': original has {} children {:?}, \ roundtrip has {} children {:?}", test_name, inst_a.name, children_a.len(), names_a, children_b.len(), names_b, ); } let mut pairs_a: Vec<(&str, rbx_dom_weak::types::Ref)> = children_a .iter() .filter_map(|&r| dom_a.get_by_ref(r).map(|i| (i.name.as_str(), r))) .collect(); let mut pairs_b: Vec<(&str, rbx_dom_weak::types::Ref)> = children_b .iter() .filter_map(|&r| dom_b.get_by_ref(r).map(|i| (i.name.as_str(), r))) .collect(); pairs_a.sort_by_key(|(name, _)| *name); pairs_b.sort_by_key(|(name, _)| *name); for (i, ((name_a, ref_child_a), (name_b, ref_child_b))) in pairs_a.iter().zip(pairs_b.iter()).enumerate() { if name_a != name_b { panic!( "[{}] Child name mismatch under '{}' at sorted index {}: \ original='{}', roundtrip='{}'", test_name, inst_a.name, i, name_a, name_b, ); } let child_a = dom_a.get_by_ref(*ref_child_a).unwrap(); let child_b = dom_b.get_by_ref(*ref_child_b).unwrap(); if child_a.class != child_b.class { panic!( "[{}] Class mismatch for '{}' (sorted index {}): original={}, roundtrip={}", test_name, name_a, i, child_a.class, child_b.class ); } compare_properties(child_a, child_b, test_name); compare_children(dom_a, *ref_child_a, dom_b, *ref_child_b, test_name); } } /// Compare properties of two instances. /// /// Focuses on properties that matter for roundtrip correctness: /// - Source (for scripts) /// - Value (for value types) /// - Other serializable properties fn compare_properties( inst_a: &rbx_dom_weak::Instance, inst_b: &rbx_dom_weak::Instance, test_name: &str, ) { // Check Source property for scripts if let (Some(source_a), Some(source_b)) = ( inst_a.properties.get(&ustr("Source")), inst_b.properties.get(&ustr("Source")), ) { if source_a != source_b { panic!( "[{}] Source property differs for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}", test_name, inst_a.name, inst_a.class, source_a, source_b ); } } // Check Value property for value types if let (Some(value_a), Some(value_b)) = ( inst_a.properties.get(&ustr("Value")), inst_b.properties.get(&ustr("Value")), ) { if value_a != value_b { panic!( "[{}] Value property differs for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}", test_name, inst_a.name, inst_a.class, value_a, value_b ); } } // Check Attributes if present if let (Some(attrs_a), Some(attrs_b)) = ( inst_a.properties.get(&ustr("Attributes")), inst_b.properties.get(&ustr("Attributes")), ) { if attrs_a != attrs_b { panic!( "[{}] Attributes differ for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}", test_name, inst_a.name, inst_a.class, attrs_a, attrs_b ); } } // Check Tags if present if let (Some(tags_a), Some(tags_b)) = ( inst_a.properties.get(&ustr("Tags")), inst_b.properties.get(&ustr("Tags")), ) { if tags_a != tags_b { panic!( "[{}] Tags differ for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}", test_name, inst_a.name, inst_a.class, tags_a, tags_b ); } } } // ============================================================================= // ROUNDTRIP TESTS // ============================================================================= // // These are build-test projects that should roundtrip cleanly through // build -> syncback -> rebuild. // // Some projects are excluded because they: // - Use sync rules with custom extensions (sync_rule_* projects) // - Have CSV files that may not roundtrip identically // - Have special project configurations that affect syncback roundtrip_tests! { // Basic script types client_in_folder, client_init, module_in_folder, module_init, server_in_folder, server_init, // Nested structures deep_nesting, init_with_children, // Meta files and properties // Note: init_meta_class_name and init_meta_properties are tested separately // below with #[ignore] because they have meta-only directories script_meta_disabled, // JSON models json_model_in_folder, json_model_legacy_name, // Text files txt, txt_in_folder, // Binary models (rbxm/rbxmx) rbxm_in_folder, rbxmx_in_folder, // Note: rbxmx_ref is tested separately below with #[ignore] because // Ref IDs differ between builds (expected behavior) // Composed projects project_composed_default, project_composed_file, project_root_name, // Special cases attributes, gitkeep, optional, weldconstraint, // Service inference // Note: infer_service_name is tested separately below with #[ignore] // because it has project-only instances infer_starter_player, // Issue regression tests issue_546, // Slugified names and metadata overrides meta_name_override, model_json_name_override, dedup_suffix_with_meta, } // ============================================================================= // TESTS WITH KNOWN LIMITATIONS // ============================================================================= /// Test for project with meta-only directory that sets className. /// /// IGNORED: This project has `$path: "Lighting"` pointing to a directory that /// only contains `init.meta.json5` (which sets className to Lighting). Clean mode /// removes the meta file as it has no script content, then `ensure_project_dirs_exist` /// recreates it as an empty directory. The rebuild then creates a Folder instead /// of Lighting because the className info is lost. /// /// This is expected behavior for clean mode - meta files that only set class/properties /// (without script content) are not preserved during roundtrip. #[test] #[ignore = "Clean mode removes meta-only directories, losing className info"] fn init_meta_class_name() { run_roundtrip_test("init_meta_class_name"); } /// Test for project with meta-only directory that sets properties. /// /// IGNORED: Same issue as init_meta_class_name - the meta file sets properties /// but has no script content, so clean mode removes it. #[test] #[ignore = "Clean mode removes meta-only directories, losing properties info"] fn init_meta_properties() { run_roundtrip_test("init_meta_properties"); } /// Test for project with instances defined only in the project file. /// /// IGNORED: The project defines HttpService directly in the project file without /// any `$path`. This instance doesn't get serialized to the rbxm (it has no content), /// so syncback fails when it can't find HttpService in the input file. #[test] #[ignore = "Project-only instances (without $path) don't exist in rbxm"] fn infer_service_name() { run_roundtrip_test("infer_service_name"); } /// Test for rbxmx files with Ref properties (ObjectValue). /// /// IGNORED: Ref property IDs are internal identifiers that differ between builds. /// This is expected behavior - Refs point to other instances by internal ID, and /// those IDs are regenerated each time the file is built. #[test] #[ignore = "Ref property IDs differ between builds (expected)"] fn rbxmx_ref() { run_roundtrip_test("rbxmx_ref"); } // ============================================================================= // SYNCBACK IDEMPOTENCY TEST // ============================================================================= /// Build -> syncback -> syncback again. The second syncback must produce /// zero filesystem changes (i.e., the result is identical to the first). /// This validates that syncback output is stable and self-consistent. #[test] fn syncback_idempotency() { let _ = env_logger::try_init(); // Use dedup_suffix_with_meta as it exercises slugified names + meta let project_path = Path::new(BUILD_TESTS_PATH).join("dedup_suffix_with_meta"); // 1. Build original -> rbxm let (_tmp1, original_rbxm) = run_rojo_build(&project_path, "original.rbxm"); // 2. First syncback let first_dir = tempdir().expect("Failed to create temp dir"); copy_project_dir(&project_path, first_dir.path()); assert!( run_rojo_syncback_clean(first_dir.path(), &original_rbxm), "First syncback should succeed" ); ensure_project_dirs_exist(first_dir.path()); // Snapshot all files after first syncback let first_snapshot = snapshot_dir(first_dir.path()); // 3. Build from first syncback result -> rbxm let (_tmp2, first_rbxm) = run_rojo_build(first_dir.path(), "first.rbxm"); // 4. Second syncback (into a copy of the first syncback result) let second_dir = tempdir().expect("Failed to create temp dir"); copy_project_dir(first_dir.path(), second_dir.path()); assert!( run_rojo_syncback_clean(second_dir.path(), &first_rbxm), "Second syncback should succeed" ); ensure_project_dirs_exist(second_dir.path()); // 5. Snapshot all files after second syncback let second_snapshot = snapshot_dir(second_dir.path()); // 6. Compare snapshots — must be identical assert_eq!( first_snapshot.len(), second_snapshot.len(), "File count should be identical between first and second syncback.\n\ First: {:?}\nSecond: {:?}", first_snapshot.keys().collect::>(), second_snapshot.keys().collect::>(), ); for (path, first_content) in &first_snapshot { let second_content = second_snapshot.get(path).unwrap_or_else(|| { panic!( "File {:?} exists after first syncback but not after second", path ) }); assert_eq!( first_content, second_content, "File {:?} changed between first and second syncback", path ); } } /// Snapshot all files under a directory into a map of relative_path -> contents. fn snapshot_dir(root: &Path) -> HashMap> { let mut result = HashMap::new(); snapshot_dir_inner(root, root, &mut result); result } fn snapshot_dir_inner(root: &Path, current: &Path, map: &mut HashMap>) { if let Ok(entries) = fs::read_dir(current) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { snapshot_dir_inner(root, &path, map); } else { let relative = path .strip_prefix(root) .unwrap() .to_string_lossy() .replace('\\', "/"); if let Ok(content) = fs::read(&path) { map.insert(relative, content); } } } } } // ============================================================================= // WINDOWS RESERVED NAME ROUNDTRIP TEST // ============================================================================= /// Instances named after Windows reserved names (CON, PRN) should round-trip /// correctly: slugified on disk, real name in meta, rebuild produces correct /// instance names. #[test] fn reserved_name_roundtrip() { run_roundtrip_test("reserved_name_override"); }