mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 14:01:37 +00:00
- Introduced a logging system using `tracing` and related crates to improve log management and output formatting. - Updated `.gitignore` to include the `.atlas/` directory, ensuring that log files and other generated content are not tracked by Git. - Enhanced project templates to include the new `.gitignore` settings for Atlas in various project types. These changes aim to improve logging capabilities and maintain a cleaner repository by ignoring unnecessary files.
460 lines
16 KiB
Rust
460 lines
16 KiB
Rust
//! 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 _ = tracing_subscriber::fmt::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 _ = tracing_subscriber::fmt::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::<Vec<_>>(),
|
|
second_snapshot.keys().collect::<Vec<_>>(),
|
|
);
|
|
|
|
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<String, Vec<u8>> {
|
|
let mut result = HashMap::new();
|
|
snapshot_dir_inner(root, root, &mut result);
|
|
result
|
|
}
|
|
|
|
fn snapshot_dir_inner(root: &Path, current: &Path, map: &mut HashMap<String, Vec<u8>>) {
|
|
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");
|
|
}
|