mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Add audit plan for slugify branch pass 3 and implement critical fixes
- Introduced a comprehensive audit plan for the slugify branch, detailing findings from a holistic review of 110 files, including 1 critical bug and 2 correctness issues. - Completed todos addressing model JSON renaming, syncback updates, and test enhancements for roundtrip fidelity. - Added tests for reserved names and ensured correct handling of slugified instances, preserving compound extensions in model files. - Refactored change processing logic to correctly manage the `name` field in model files, enhancing metadata handling and ensuring compliance with round-trip identity standards.
This commit is contained in:
parent
2a917bf8e1
commit
bb266789c0
14 changed files with 843 additions and 23 deletions
|
|
@ -69,6 +69,7 @@ gen_build_tests! {
|
|||
model_json_name_override,
|
||||
dedup_suffix_with_meta,
|
||||
init_meta_name_override,
|
||||
reserved_name_override,
|
||||
}
|
||||
|
||||
fn run_build_test(test_name: &str) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
//! 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};
|
||||
|
|
@ -284,6 +285,11 @@ roundtrip_tests! {
|
|||
|
||||
// Issue regression tests
|
||||
issue_546,
|
||||
|
||||
// Slugified names and metadata overrides
|
||||
meta_name_override,
|
||||
model_json_name_override,
|
||||
dedup_suffix_with_meta,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -337,3 +343,111 @@ fn infer_service_name() {
|
|||
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::<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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5197,7 +5197,8 @@ fn rename_clean_to_slugified() {
|
|||
}
|
||||
|
||||
/// Renaming a slugified instance back to a clean name should remove the
|
||||
/// meta `name` field (or the entire meta file if it had no other fields).
|
||||
/// meta `name` field. When the meta file had no other fields, the file
|
||||
/// itself must be deleted (the `RemoveNameOutcome::FileDeleted` path).
|
||||
#[test]
|
||||
fn rename_slugified_to_clean() {
|
||||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||
|
|
@ -5231,15 +5232,360 @@ fn rename_slugified_to_clean() {
|
|||
"What_Module.meta.json5 should be gone after rename",
|
||||
);
|
||||
|
||||
// The new meta file should either not exist (clean name needs no
|
||||
// name override) or, if it exists, should NOT contain a "name" field.
|
||||
if new_meta.exists() {
|
||||
let meta_content = fs::read_to_string(&new_meta).unwrap();
|
||||
// The meta file had only a "name" field, so it should be deleted
|
||||
// entirely (not just emptied). This validates the FileDeleted path.
|
||||
poll_not_exists(
|
||||
&new_meta,
|
||||
"CleanName.meta.json5 should NOT exist (name-only meta → deleted)",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Model JSON rename (compound extension preservation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Renaming a .model.json5 instance must preserve the `.model` compound
|
||||
/// extension and update the `name` field inside the model file (not in
|
||||
/// adjacent .meta.json5).
|
||||
#[test]
|
||||
fn rename_model_json_preserves_compound_extension() {
|
||||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||
let (session_id, model_id) = get_encoded_names_instance(&session, "What?Model");
|
||||
|
||||
let src = session.path().join("src");
|
||||
let old_path = src.join("What_Model.model.json5");
|
||||
assert_file_exists(&old_path, "What_Model.model.json5 before rename");
|
||||
|
||||
// Rename to another name needing slugification
|
||||
send_update(
|
||||
&session,
|
||||
&session_id,
|
||||
InstanceUpdate {
|
||||
id: model_id,
|
||||
changed_name: Some("Why?Model".to_string()),
|
||||
changed_class_name: None,
|
||||
changed_properties: UstrMap::default(),
|
||||
changed_metadata: None,
|
||||
},
|
||||
);
|
||||
|
||||
// The .model compound extension MUST be preserved
|
||||
let new_path = src.join("Why_Model.model.json5");
|
||||
poll_file_exists(&new_path, "Why_Model.model.json5 should exist after rename");
|
||||
poll_not_exists(
|
||||
&old_path,
|
||||
"What_Model.model.json5 should be gone after rename",
|
||||
);
|
||||
|
||||
// The WRONG path (lost .model extension) must NOT exist
|
||||
let wrong_path = src.join("Why_Model.json5");
|
||||
assert_not_exists(
|
||||
&wrong_path,
|
||||
"Why_Model.json5 (without .model) must NOT exist",
|
||||
);
|
||||
|
||||
// The name field must be INSIDE the model file, not in adjacent meta.
|
||||
// JSON5 serialization uses unquoted keys, so check for key without
|
||||
// surrounding double-quotes.
|
||||
let model_content = fs::read_to_string(&new_path).unwrap();
|
||||
assert!(
|
||||
model_content.contains("name") && model_content.contains("Why?Model"),
|
||||
"Model file should contain a name field with \"Why?Model\", got: {}",
|
||||
model_content
|
||||
);
|
||||
|
||||
// No adjacent .meta.json5 should be created for model files
|
||||
let wrong_meta = src.join("Why_Model.meta.json5");
|
||||
assert_not_exists(
|
||||
&wrong_meta,
|
||||
"Adjacent .meta.json5 should NOT be created for model files",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Renaming a .model.json5 instance to a clean name should remove the
|
||||
/// `name` field from inside the model file.
|
||||
#[test]
|
||||
fn rename_model_json_to_clean_name() {
|
||||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||
let (session_id, model_id) = get_encoded_names_instance(&session, "What?Model");
|
||||
|
||||
let src = session.path().join("src");
|
||||
let old_path = src.join("What_Model.model.json5");
|
||||
assert_file_exists(&old_path, "What_Model.model.json5 before rename");
|
||||
|
||||
send_update(
|
||||
&session,
|
||||
&session_id,
|
||||
InstanceUpdate {
|
||||
id: model_id,
|
||||
changed_name: Some("CleanModel".to_string()),
|
||||
changed_class_name: None,
|
||||
changed_properties: UstrMap::default(),
|
||||
changed_metadata: None,
|
||||
},
|
||||
);
|
||||
|
||||
let new_path = src.join("CleanModel.model.json5");
|
||||
poll_file_exists(
|
||||
&new_path,
|
||||
"CleanModel.model.json5 should exist after rename",
|
||||
);
|
||||
poll_not_exists(&old_path, "What_Model.model.json5 should be gone");
|
||||
|
||||
// The model file should NOT have a "name" key when the
|
||||
// filesystem name matches the instance name.
|
||||
// JSON5 uses unquoted keys (`name:` not `"name":`), so we
|
||||
// check for the key pattern at line start.
|
||||
let model_content = fs::read_to_string(&new_path).unwrap();
|
||||
let has_name_key = model_content
|
||||
.lines()
|
||||
.any(|l| l.trim().starts_with("name:") || l.trim().starts_with("\"name\":"));
|
||||
assert!(
|
||||
!has_name_key,
|
||||
"CleanModel.model.json5 should not have a name key, got: {}",
|
||||
model_content
|
||||
);
|
||||
let has_classname = model_content
|
||||
.lines()
|
||||
.any(|l| l.trim().starts_with("className:") || l.trim().starts_with("\"className\":"));
|
||||
assert!(
|
||||
has_classname,
|
||||
"Model file should still have className, got: {}",
|
||||
model_content
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: 3+ instance collision deduplication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Adding 3 instances that all slug to the same name (X/Y, X:Y, X|Y → X_Y)
|
||||
/// should produce X_Y.luau, X_Y~1.luau, X_Y~2.luau with correct meta files.
|
||||
#[test]
|
||||
fn add_three_colliding_instances_deduplicates() {
|
||||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||
let info = session.get_api_rojo().unwrap();
|
||||
let root_read = session.get_api_read(info.root_instance_id).unwrap();
|
||||
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||||
|
||||
let names = ["X/Y", "X:Y", "X|Y"];
|
||||
let mut added_map = HashMap::new();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let mut props = HashMap::new();
|
||||
props.insert(
|
||||
"Source".to_string(),
|
||||
Variant::String(format!("-- instance {}", i + 1)),
|
||||
);
|
||||
added_map.insert(
|
||||
Ref::new(),
|
||||
AddedInstance {
|
||||
parent: Some(rs_id),
|
||||
name: name.to_string(),
|
||||
class_name: "ModuleScript".to_string(),
|
||||
properties: props,
|
||||
children: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
let write_request = WriteRequest {
|
||||
session_id: info.session_id,
|
||||
removed: vec![],
|
||||
added: added_map,
|
||||
updated: vec![],
|
||||
};
|
||||
session.post_api_write(&write_request).unwrap();
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
let src = session.path().join("src");
|
||||
let base = src.join("X_Y.luau");
|
||||
let dedup1 = src.join("X_Y~1.luau");
|
||||
let dedup2 = src.join("X_Y~2.luau");
|
||||
|
||||
assert!(
|
||||
base.exists() && dedup1.exists() && dedup2.exists(),
|
||||
"All three slugified files should exist.\n\
|
||||
X_Y.luau: {}, X_Y~1.luau: {}, X_Y~2.luau: {}",
|
||||
base.exists(),
|
||||
dedup1.exists(),
|
||||
dedup2.exists()
|
||||
);
|
||||
|
||||
// All three should have meta files with the original names
|
||||
let base_meta = src.join("X_Y.meta.json5");
|
||||
let dedup1_meta = src.join("X_Y~1.meta.json5");
|
||||
let dedup2_meta = src.join("X_Y~2.meta.json5");
|
||||
assert_file_exists(&base_meta, "Meta for base slug");
|
||||
assert_file_exists(&dedup1_meta, "Meta for ~1 dedup");
|
||||
assert_file_exists(&dedup2_meta, "Meta for ~2 dedup");
|
||||
|
||||
// Each meta should contain one of the original names
|
||||
let all_metas = [
|
||||
fs::read_to_string(&base_meta).unwrap(),
|
||||
fs::read_to_string(&dedup1_meta).unwrap(),
|
||||
fs::read_to_string(&dedup2_meta).unwrap(),
|
||||
];
|
||||
for meta in &all_metas {
|
||||
let has_name =
|
||||
meta.contains("\"X/Y\"") || meta.contains("\"X:Y\"") || meta.contains("\"X|Y\"");
|
||||
assert!(
|
||||
!meta_content.contains("\"name\""),
|
||||
"CleanName.meta.json5 should not have a \"name\" field, got: {}",
|
||||
meta_content
|
||||
has_name,
|
||||
"Meta should contain one of X/Y, X:Y, X|Y, got: {}",
|
||||
meta
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Directory rename with forbidden chars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Renaming a directory-format script to a name with forbidden chars should
|
||||
/// rename the directory using the slugified name and write a meta file with
|
||||
/// the real name.
|
||||
#[test]
|
||||
fn rename_directory_format_to_slugified_name() {
|
||||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||||
let (session_id, dir_module_id) =
|
||||
get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||||
|
||||
let src = session.path().join("src");
|
||||
let old_dir = src.join("DirModuleWithChildren");
|
||||
assert!(old_dir.is_dir(), "Old directory should exist before rename");
|
||||
|
||||
// Rename to a name with forbidden chars
|
||||
send_update(
|
||||
&session,
|
||||
&session_id,
|
||||
InstanceUpdate {
|
||||
id: dir_module_id,
|
||||
changed_name: Some("Dir/With:Chars".to_string()),
|
||||
changed_class_name: None,
|
||||
changed_properties: UstrMap::default(),
|
||||
changed_metadata: None,
|
||||
},
|
||||
);
|
||||
|
||||
let new_dir = src.join("Dir_With_Chars");
|
||||
poll_not_exists(&old_dir, "Old directory should be gone after rename");
|
||||
|
||||
// Poll for the new directory
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if new_dir.is_dir() {
|
||||
break;
|
||||
}
|
||||
if start.elapsed() > Duration::from_millis(API_POLL_TIMEOUT_MS) {
|
||||
panic!(
|
||||
"New directory Dir_With_Chars should exist after rename (timed out after {}ms)",
|
||||
API_POLL_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
// init.luau should be inside the renamed directory
|
||||
let init_file = new_dir.join("init.luau");
|
||||
assert_file_exists(&init_file, "init.luau inside renamed directory");
|
||||
|
||||
// Children should be present in the renamed directory
|
||||
let child_a = new_dir.join("ChildA.luau");
|
||||
assert_file_exists(&child_a, "ChildA.luau inside renamed directory");
|
||||
|
||||
// For init-file directories, the name is stored inside the
|
||||
// directory in init.meta.json5, not adjacent to it.
|
||||
let init_meta = new_dir.join("init.meta.json5");
|
||||
poll_file_exists(
|
||||
&init_meta,
|
||||
"init.meta.json5 inside Dir_With_Chars should exist with name override",
|
||||
);
|
||||
let meta_content = fs::read_to_string(&init_meta).unwrap();
|
||||
assert!(
|
||||
meta_content.contains("Dir/With:Chars"),
|
||||
"init.meta.json5 should contain real name \"Dir/With:Chars\", got: {}",
|
||||
meta_content
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Case-insensitive collision
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Adding two instances whose names differ only in case (Foo and foo) should
|
||||
/// be deduplicated, since they slug to the same lowercase key.
|
||||
#[test]
|
||||
fn add_case_insensitive_colliding_instances() {
|
||||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||||
let info = session.get_api_rojo().unwrap();
|
||||
let root_read = session.get_api_read(info.root_instance_id).unwrap();
|
||||
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||||
|
||||
let mut added_map = HashMap::new();
|
||||
let mut props1 = HashMap::new();
|
||||
props1.insert(
|
||||
"Source".to_string(),
|
||||
Variant::String("-- upper".to_string()),
|
||||
);
|
||||
added_map.insert(
|
||||
Ref::new(),
|
||||
AddedInstance {
|
||||
parent: Some(rs_id),
|
||||
name: "CaseTest".to_string(),
|
||||
class_name: "ModuleScript".to_string(),
|
||||
properties: props1,
|
||||
children: vec![],
|
||||
},
|
||||
);
|
||||
let mut props2 = HashMap::new();
|
||||
props2.insert(
|
||||
"Source".to_string(),
|
||||
Variant::String("-- lower".to_string()),
|
||||
);
|
||||
added_map.insert(
|
||||
Ref::new(),
|
||||
AddedInstance {
|
||||
parent: Some(rs_id),
|
||||
name: "casetest".to_string(),
|
||||
class_name: "ModuleScript".to_string(),
|
||||
properties: props2,
|
||||
children: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
let write_request = WriteRequest {
|
||||
session_id: info.session_id,
|
||||
removed: vec![],
|
||||
added: added_map,
|
||||
updated: vec![],
|
||||
};
|
||||
session.post_api_write(&write_request).unwrap();
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
let src = session.path().join("src");
|
||||
|
||||
// One should get the base name, the other should get ~1
|
||||
// The exact casing depends on processing order, but both files
|
||||
// must exist with distinct names.
|
||||
let base = src.join("CaseTest.luau");
|
||||
let base_lower = src.join("casetest.luau");
|
||||
let dedup = src.join("CaseTest~1.luau");
|
||||
let dedup_lower = src.join("casetest~1.luau");
|
||||
|
||||
let base_exists = base.exists() || base_lower.exists();
|
||||
let dedup_exists = dedup.exists() || dedup_lower.exists();
|
||||
|
||||
assert!(
|
||||
base_exists && dedup_exists,
|
||||
"Both case-insensitive colliding instances should exist as separate files.\n\
|
||||
CaseTest.luau: {}, casetest.luau: {}, CaseTest~1.luau: {}, casetest~1.luau: {}",
|
||||
base.exists(),
|
||||
base_lower.exists(),
|
||||
dedup.exists(),
|
||||
dedup_lower.exists()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue