diff --git a/.cursor/plans/syncback_roundtrip_tests_f980a65b.plan.md b/.cursor/plans/syncback_roundtrip_tests_f980a65b.plan.md new file mode 100644 index 00000000..46531b1d --- /dev/null +++ b/.cursor/plans/syncback_roundtrip_tests_f980a65b.plan.md @@ -0,0 +1,620 @@ +--- +name: Syncback Roundtrip Tests +overview: Add comprehensive build-syncback roundtrip tests and mutation stress tests to validate clean mode produces consistent results, complementing the existing 100+ syncback tests. +todos: + - id: roundtrip-utils + content: "Create `tests/rojo_test/roundtrip_util.rs` with helpers: `run_rojo_build`, `assert_dirs_equal`, `Mutation` enum and `apply_mutation`" + status: pending + - id: roundtrip-tests + content: Create `tests/tests/syncback_roundtrip.rs` with build -> syncback -> rebuild roundtrip tests using existing build-test projects + status: pending + - id: clean-stress-tests + content: Create `tests/tests/clean_mode_stress.rs` with clean-equals-fresh tests covering 12+ mutation scenarios + status: pending + - id: wire-modules + content: Update `tests/rojo_test/mod.rs` and `tests/tests/mod.rs` to include new test modules + status: pending +isProject: false +--- + +# Syncback Roundtrip and Clean Mode Stress Tests + +## Context: The Syncback Work Done + +This testing plan builds on significant syncback improvements: + +### 1. Destructive/Clean Mode (Default) + +From [destructive_syncback_mode plan](destructive_syncback_mode_acb8cb54.plan.md): + +- Clean mode ignores existing file structure (`old = None`) +- Scans filesystem for ALL existing paths before syncback +- Removes orphaned files after syncback completes +- `--incremental` flag to opt into legacy structure-preserving behavior + +Key implementation in `src/syncback/mod.rs`: + +- Collects existing paths from filesystem (not just tree) +- Sets `old_ref = None` in clean mode so all children treated as new +- Removes orphaned paths after computing new structure + +### 2. Plugin API Architecture Fix + +From [architecture fix plan](rojo_syncback_architecture_fix_bc0c13ab.plan.md): + +- Tree-based instance lookup before format decisions +- `detect_existing_script_format()` checks filesystem state +- Format preservation to prevent duplicate file creation +- Prevents the "duplicate creation cycle" bug + +Key implementation in `src/web/api.rs`: + +- `ExistingFileFormat` enum (None, Standalone, Directory) +- Checks if `Name/init.luau` or `Name.luau` exists before deciding format +- Plugin syncback respects existing file structure + +--- + +## Current Test Coverage + + +| Test File | Count | Purpose | +| -------------------------------- | ----- | ---------------------------------------------------------------------- | +| `syncback.rs` | 25 | CLI syncback with pre-built rbxl/rbxm files (18 clean + 7 incremental) | +| `syncback_format_transitions.rs` | 47 | Plugin API format preservation via `/api/write` | +| `clean_mode.rs` | 6 | Clean vs incremental basic behavior | +| `serve.rs` | 22 | General serve tests including write API | + + +**Total: ~100 syncback-related tests** + +Test projects in `rojo-test/syncback-tests/` cover: + +- Reference properties (6 tests) +- Ignore rules (5 tests) +- Nested projects (2 tests) +- File format/middleware (5 tests) +- Edge cases/conflicts (4 tests) + +--- + +## Key Gaps + +1. **No dynamic build-syncback roundtrip**: All tests use static pre-built `.rbxl`/`.rbxm` files, not dynamically built ones +2. **No mutation stress tests**: No tests that dirty the filesystem and verify clean mode fixes it +3. **No "clean == fresh" equivalence proof**: No tests proving clean mode on dirty dir equals fresh syncback + +--- + +## Implementation Plan + +### 1. Helper Utilities: `tests/rojo_test/roundtrip_util.rs` + +```rust +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +use super::io_util::ROJO_PATH; + +/// Run `rojo build` and return (temp_dir_handle, output_path) +pub fn run_rojo_build(project_path: &Path, output_name: &str) -> (TempDir, PathBuf) { + let output_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let output_path = output_dir.path().join(output_name); + + let output = Command::new(ROJO_PATH) + .args(["build", project_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .output() + .expect("Failed to run rojo build"); + + if !output.status.success() { + panic!( + "rojo build failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + (output_dir, output_path) +} + +/// Run `rojo syncback` in clean mode (default) +pub fn run_rojo_syncback_clean(project_path: &Path, input_path: &Path) -> bool { + Command::new(ROJO_PATH) + .args([ + "syncback", + project_path.to_str().unwrap(), + "--input", + input_path.to_str().unwrap(), + ]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Compare two directories recursively, asserting they have identical content +pub fn assert_dirs_equal(dir_a: &Path, dir_b: &Path) { + let files_a = collect_files(dir_a); + let files_b = collect_files(dir_b); + + // Compare file sets + let set_a: HashSet<_> = files_a.keys().collect(); + let set_b: HashSet<_> = files_b.keys().collect(); + + let only_in_a: Vec<_> = set_a.difference(&set_b).collect(); + let only_in_b: Vec<_> = set_b.difference(&set_a).collect(); + + assert!( + only_in_a.is_empty() && only_in_b.is_empty(), + "Directory contents differ:\n Only in A: {:?}\n Only in B: {:?}", + only_in_a, only_in_b + ); + + // Compare file contents + for (rel_path, content_a) in &files_a { + let content_b = &files_b[rel_path]; + assert_eq!( + content_a, content_b, + "File contents differ for {:?}", rel_path + ); + } +} + +fn collect_files(dir: &Path) -> HashMap> { + let mut result = HashMap::new(); + collect_files_recursive(dir, dir, &mut result); + result +} + +fn collect_files_recursive(base: &Path, current: &Path, result: &mut HashMap>) { + for entry in fs::read_dir(current).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + let rel_path = path.strip_prefix(base).unwrap().to_path_buf(); + + if path.is_dir() { + collect_files_recursive(base, &path, result); + } else { + let content = fs::read(&path).unwrap(); + result.insert(rel_path, content); + } + } +} + +/// Mutation types for stress testing clean mode +pub enum Mutation { + /// Add an orphan file that doesn't exist in the rbxl + AddOrphanFile { relative_path: &'static str, content: &'static str }, + /// Delete a file that exists in the rbxl + DeleteFile { relative_path: &'static str }, + /// Rename a file (creates orphan + missing file) + RenameFile { from: &'static str, to: &'static str }, + /// Change file extension (e.g., .luau -> .modulescript) + ChangeExtension { from: &'static str, to: &'static str }, + /// Add an orphan directory with content + AddOrphanDirectory { relative_path: &'static str }, + /// Delete an entire directory + DeleteDirectory { relative_path: &'static str }, + /// Convert directory format to standalone file + ConvertDirToFile { dir: &'static str, file_content: &'static str }, + /// Convert standalone file to directory format + ConvertFileToDir { file: &'static str }, + /// Corrupt a .meta.json5 file + CorruptMetaFile { relative_path: &'static str }, + /// Modify file content to be wrong + ModifyFileContent { relative_path: &'static str, new_content: &'static str }, + /// Add a spurious .project.json5 file + AddNestedProjectFile { relative_path: &'static str }, + /// Create duplicate with different extension + DuplicateWithDifferentExtension { original: &'static str, duplicate_ext: &'static str }, +} + +pub fn apply_mutation(dir: &Path, mutation: &Mutation) { + match mutation { + Mutation::AddOrphanFile { relative_path, content } => { + let path = dir.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).ok(); + } + fs::write(&path, content).expect("Failed to write orphan file"); + } + Mutation::DeleteFile { relative_path } => { + let path = dir.join(relative_path); + fs::remove_file(&path).expect("Failed to delete file"); + } + Mutation::RenameFile { from, to } => { + let from_path = dir.join(from); + let to_path = dir.join(to); + fs::rename(&from_path, &to_path).expect("Failed to rename file"); + } + Mutation::ChangeExtension { from, to } => { + let from_path = dir.join(from); + let to_path = dir.join(to); + fs::rename(&from_path, &to_path).expect("Failed to change extension"); + } + Mutation::AddOrphanDirectory { relative_path } => { + let path = dir.join(relative_path); + fs::create_dir_all(&path).expect("Failed to create orphan directory"); + // Add a file so the directory isn't empty + fs::write(path.join("orphan_child.luau"), "-- orphan").ok(); + } + Mutation::DeleteDirectory { relative_path } => { + let path = dir.join(relative_path); + fs::remove_dir_all(&path).expect("Failed to delete directory"); + } + Mutation::ConvertDirToFile { dir: dir_path, file_content } => { + let path = dir.join(dir_path); + fs::remove_dir_all(&path).expect("Failed to remove directory"); + // Create standalone file with same base name + let file_path = path.with_extension("luau"); + fs::write(&file_path, file_content).expect("Failed to write file"); + } + Mutation::ConvertFileToDir { file } => { + let file_path = dir.join(file); + let content = fs::read_to_string(&file_path).unwrap_or_default(); + fs::remove_file(&file_path).expect("Failed to remove file"); + // Create directory with init file + let dir_path = file_path.with_extension(""); + fs::create_dir_all(&dir_path).expect("Failed to create directory"); + fs::write(dir_path.join("init.luau"), content).expect("Failed to write init"); + } + Mutation::CorruptMetaFile { relative_path } => { + let path = dir.join(relative_path); + fs::write(&path, "{ this is not valid json5 {{{{").expect("Failed to corrupt meta"); + } + Mutation::ModifyFileContent { relative_path, new_content } => { + let path = dir.join(relative_path); + fs::write(&path, new_content).expect("Failed to modify content"); + } + Mutation::AddNestedProjectFile { relative_path } => { + let path = dir.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).ok(); + } + fs::write(&path, r#"{ "name": "SpuriousProject", "tree": { "$className": "Folder" } }"#) + .expect("Failed to add nested project"); + } + Mutation::DuplicateWithDifferentExtension { original, duplicate_ext } => { + let orig_path = dir.join(original); + let content = fs::read(&orig_path).unwrap_or_default(); + let dup_path = orig_path.with_extension(duplicate_ext); + fs::write(&dup_path, content).expect("Failed to create duplicate"); + } + } +} +``` + +### 2. Roundtrip Tests: `tests/tests/syncback_roundtrip.rs` + +```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. + +use std::path::Path; +use tempfile::tempdir; + +use crate::rojo_test::io_util::{copy_recursive, BUILD_TESTS_PATH}; +use crate::rojo_test::roundtrip_util::{run_rojo_build, run_rojo_syncback_clean}; + +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 -> rbxl + let (_tmp1, original_rbxl) = run_rojo_build(&project_path, "original.rbxl"); + + // 2. Create fresh syncback target with just project file + let syncback_dir = tempdir().expect("Failed to create temp dir"); + copy_project_file(&project_path, syncback_dir.path()); + + // 3. Syncback from rbxl to fresh directory + assert!( + run_rojo_syncback_clean(syncback_dir.path(), &original_rbxl), + "Syncback should succeed" + ); + + // 4. Build from syncback result -> rbxl + let (_tmp2, roundtrip_rbxl) = run_rojo_build(syncback_dir.path(), "roundtrip.rbxl"); + + // 5. Compare both rbxl files (semantic comparison) + assert_rbx_equal(&original_rbxl, &roundtrip_rbxl); +} + +fn copy_project_file(src: &Path, dst: &Path) { + // Copy just the project file (default.project.json5) + for name in ["default.project.json5", "default.project.json"] { + let src_file = src.join(name); + if src_file.exists() { + fs::copy(&src_file, dst.join(name)).unwrap(); + return; + } + } + panic!("No project file found in {:?}", src); +} + +fn assert_rbx_equal(file_a: &Path, file_b: &Path) { + // Load both rbxl files and compare instance trees + let dom_a = rbx_binary::from_reader(fs::File::open(file_a).unwrap()).unwrap(); + let dom_b = rbx_binary::from_reader(fs::File::open(file_b).unwrap()).unwrap(); + + // Compare root children (skip DataModel itself) + compare_instances(&dom_a, dom_a.root_ref(), &dom_b, dom_b.root_ref()); +} + +// Test ALL build-test projects that should roundtrip cleanly +roundtrip_tests! { + attributes, + client_in_folder, + client_init, + deep_nesting, + init_meta_class_name, + init_meta_properties, + init_with_children, + json_model_in_folder, + json_model_legacy_name, + module_in_folder, + module_init, + project_composed_default, + project_composed_file, + project_root_name, + rbxm_in_folder, + rbxmx_in_folder, + server_in_folder, + server_init, + txt, + txt_in_folder, + sync_rule_alone, + sync_rule_complex, +} +``` + +### 3. Clean Mode Stress Tests: `tests/tests/clean_mode_stress.rs` + +```rust +//! Stress tests proving clean mode produces consistent results. +//! +//! Core invariant: syncing back to a DIRTY directory with clean mode +//! should produce IDENTICAL results to syncing back to an EMPTY directory. +//! +//! Tests apply various "mutations" to dirty up the filesystem, then verify +//! clean mode fixes everything. + +use std::path::Path; +use tempfile::tempdir; + +use crate::rojo_test::io_util::{copy_recursive, BUILD_TESTS_PATH}; +use crate::rojo_test::roundtrip_util::{ + apply_mutation, assert_dirs_equal, run_rojo_build, run_rojo_syncback_clean, Mutation, +}; + +/// Core test: dirty syncback should equal fresh syncback +fn clean_equals_fresh(test_name: &str, mutations: &[Mutation]) { + let _ = env_logger::try_init(); + + let project_path = Path::new(BUILD_TESTS_PATH).join(test_name); + + // 1. Build rbxl from original project + let (_tmp_rbxl, rbxl_path) = run_rojo_build(&project_path, "test.rbxl"); + + // 2. Syncback to DIR_A (fresh start) + let dir_a = tempdir().expect("Failed to create dir_a"); + copy_project_file(&project_path, dir_a.path()); + assert!(run_rojo_syncback_clean(dir_a.path(), &rbxl_path), "Initial syncback failed"); + + // 3. Apply mutations to DIR_A (dirty it up) + for mutation in mutations { + apply_mutation(dir_a.path(), mutation); + } + + // 4. Run clean syncback again on dirty DIR_A + assert!(run_rojo_syncback_clean(dir_a.path(), &rbxl_path), "Clean syncback on dirty dir failed"); + + // 5. Syncback to DIR_B (completely fresh) + let dir_b = tempdir().expect("Failed to create dir_b"); + copy_project_file(&project_path, dir_b.path()); + assert!(run_rojo_syncback_clean(dir_b.path(), &rbxl_path), "Fresh syncback failed"); + + // 6. CRITICAL ASSERTION: cleaned DIR_A == fresh DIR_B + assert_dirs_equal(dir_a.path(), dir_b.path()); +} + +// ========================================================================== +// MUTATION SCENARIO TESTS +// ========================================================================== + +#[test] +fn clean_removes_orphan_luau_files() { + clean_equals_fresh("deep_nesting", &[ + Mutation::AddOrphanFile { + relative_path: "src/orphan_script.luau", + content: "-- I shouldn't exist" + }, + Mutation::AddOrphanFile { + relative_path: "src/inner/another_orphan.luau", + content: "return nil" + }, + ]); +} + +#[test] +fn clean_removes_orphan_directories() { + clean_equals_fresh("deep_nesting", &[ + Mutation::AddOrphanDirectory { + relative_path: "src/fake_service" + }, + ]); +} + +#[test] +fn clean_restores_deleted_files() { + clean_equals_fresh("module_init", &[ + Mutation::DeleteFile { relative_path: "folder/init.luau" }, + ]); +} + +#[test] +fn clean_fixes_renamed_files() { + clean_equals_fresh("module_in_folder", &[ + Mutation::RenameFile { + from: "folder/aModule.luau", + to: "folder/wrongName.luau" + }, + ]); +} + +#[test] +fn clean_fixes_wrong_extensions() { + clean_equals_fresh("server_in_folder", &[ + Mutation::ChangeExtension { + from: "folder/serverScript.server.luau", + to: "folder/serverScript.modulescript" + }, + ]); +} + +#[test] +fn clean_restores_dir_from_file() { + clean_equals_fresh("init_with_children", &[ + Mutation::ConvertDirToFile { + dir: "src", + file_content: "-- was a directory" + }, + ]); +} + +#[test] +fn clean_restores_file_from_dir() { + clean_equals_fresh("module_in_folder", &[ + Mutation::ConvertFileToDir { + file: "folder/aModule.luau" + }, + ]); +} + +#[test] +fn clean_fixes_corrupt_meta_files() { + clean_equals_fresh("init_meta_properties", &[ + Mutation::CorruptMetaFile { + relative_path: "Lighting/init.meta.json5" + }, + ]); +} + +#[test] +fn clean_restores_modified_content() { + clean_equals_fresh("module_init", &[ + Mutation::ModifyFileContent { + relative_path: "folder/init.luau", + new_content: "-- totally wrong content\nreturn 'hacked'" + }, + ]); +} + +#[test] +fn clean_removes_spurious_project_files() { + clean_equals_fresh("deep_nesting", &[ + Mutation::AddNestedProjectFile { + relative_path: "src/fake.project.json5" + }, + ]); +} + +#[test] +fn clean_removes_duplicate_extensions() { + clean_equals_fresh("module_in_folder", &[ + Mutation::DuplicateWithDifferentExtension { + original: "folder/aModule.luau", + duplicate_ext: "lua" + }, + ]); +} + +#[test] +fn clean_handles_multiple_crazy_mutations() { + clean_equals_fresh("deep_nesting", &[ + Mutation::AddOrphanFile { relative_path: "orphan1.luau", content: "x" }, + Mutation::AddOrphanFile { relative_path: "src/orphan2.txt", content: "y" }, + Mutation::AddOrphanDirectory { relative_path: "fake_dir" }, + Mutation::ModifyFileContent { + relative_path: "src/inner/init.luau", + new_content: "-- wrong!" + }, + Mutation::AddNestedProjectFile { relative_path: "src/bad.project.json5" }, + ]); +} +``` + +### 4. Wire Up Modules + +`**tests/rojo_test/mod.rs`:** + +```rust +pub mod roundtrip_util; +``` + +`**tests/tests/mod.rs`:** + +```rust +mod syncback_roundtrip; +mod clean_mode_stress; +``` + +--- + +## Test Matrix + + +| Mutation Type | Test Name | Verifies | +| -------------------- | ---------------------------------------- | ------------------------ | +| Orphan .luau files | `clean_removes_orphan_luau_files` | Orphan file removal | +| Orphan directories | `clean_removes_orphan_directories` | Orphan directory removal | +| Deleted files | `clean_restores_deleted_files` | File restoration | +| Renamed files | `clean_fixes_renamed_files` | Name correction | +| Wrong extensions | `clean_fixes_wrong_extensions` | Extension normalization | +| Dir to File | `clean_restores_dir_from_file` | Format restoration | +| File to Dir | `clean_restores_file_from_dir` | Format restoration | +| Corrupt meta | `clean_fixes_corrupt_meta_files` | Meta regeneration | +| Modified content | `clean_restores_modified_content` | Content restoration | +| Spurious projects | `clean_removes_spurious_project_files` | Nested project cleanup | +| Duplicate extensions | `clean_removes_duplicate_extensions` | Duplicate removal | +| Combined chaos | `clean_handles_multiple_crazy_mutations` | Stress test | + + +--- + +## Files to Create/Modify + + +| File | Action | +| ----------------------------------- | ------------------------------------ | +| `tests/rojo_test/roundtrip_util.rs` | Create - helper utilities | +| `tests/tests/syncback_roundtrip.rs` | Create - roundtrip tests | +| `tests/tests/clean_mode_stress.rs` | Create - mutation stress tests | +| `tests/rojo_test/mod.rs` | Modify - add `roundtrip_util` module | +| `tests/tests/mod.rs` | Modify - add new test modules | + + +--- + +## Success Criteria + +1. All 20+ build-test projects can roundtrip successfully (build -> syncback -> rebuild -> compare) +2. All 12 mutation scenarios pass (clean == fresh invariant holds) +3. Tests run in CI without flakiness +4. Total added test runtime < 60 seconds + diff --git a/rojo-test/serve-tests/syncback_format_transitions/src/DirLocalScriptWithChildren/init.client.luau b/rojo-test/serve-tests/syncback_format_transitions/src/DirLocalScriptWithChildren/init.local.luau similarity index 100% rename from rojo-test/serve-tests/syncback_format_transitions/src/DirLocalScriptWithChildren/init.client.luau rename to rojo-test/serve-tests/syncback_format_transitions/src/DirLocalScriptWithChildren/init.local.luau diff --git a/src/web/api.rs b/src/web/api.rs index c1ba5b4e..c73a3675 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -621,12 +621,48 @@ impl ApiService { let children_dir = if existing_path.is_dir() { existing_path.to_path_buf() } else { - // For standalone scripts, children would need directory conversion - // For now, use the parent directory as the base - existing_path + // Standalone scripts cannot have children in Rojo's file format. + // We need to convert from standalone (e.g., MyScript.server.luau) + // to directory format (e.g., MyScript/init.server.luau). + let script_name = added.name.as_str(); + let parent_dir = existing_path .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| existing_path.to_path_buf()) + .unwrap_or(existing_path); + let new_dir = parent_dir.join(script_name); + + // Create the directory + fs::create_dir_all(&new_dir).with_context(|| { + format!("Failed to create directory for script with children: {}", new_dir.display()) + })?; + + // Determine the init file name based on class + let init_name = match class_name.as_str() { + "ModuleScript" => "init.luau", + "Script" => "init.server.luau", + "LocalScript" => "init.local.luau", + _ => "init.luau", + }; + + // Move the script content to init file + let init_path = new_dir.join(init_name); + fs::write(&init_path, source.as_bytes()).with_context(|| { + format!("Failed to write init file: {}", init_path.display()) + })?; + + // Remove the old standalone file + if existing_path.exists() && existing_path != init_path { + fs::remove_file(existing_path).with_context(|| { + format!("Failed to remove old standalone script: {}", existing_path.display()) + })?; + } + + log::info!( + "Syncback: Converted standalone {} to directory format at {}", + class_name, + new_dir.display() + ); + + new_dir }; // Filter duplicate children diff --git a/tests/tests/syncback_format_transitions.rs b/tests/tests/syncback_format_transitions.rs index 344822bf..c267eedf 100644 --- a/tests/tests/syncback_format_transitions.rs +++ b/tests/tests/syncback_format_transitions.rs @@ -294,9 +294,10 @@ fn dir_model_sync_removing_children_preserves_directory() { // ============================================================================= /// Test: Standalone ModuleScript receives sync WITH children -/// Expected: Standalone format preserved (no directory created) +/// Expected: Standalone converted to directory format when children are added +/// Standalone scripts cannot have children in Rojo's file format. #[test] -fn standalone_module_sync_adding_children_preserves_standalone() { +fn standalone_module_sync_adding_children_converts_to_directory() { run_serve_test("syncback_format_transitions", |session, _redactions| { let info = session.get_api_rojo().unwrap(); let root_id = info.root_instance_id; @@ -332,21 +333,24 @@ fn standalone_module_sync_adding_children_preserves_standalone() { ); send_write_request(&session, &info.session_id, rs_id, added); - // Standalone file should still exist - assert_file_exists(&standalone_file, "Standalone file should be preserved"); + // Standalone file should be removed (converted to directory) + assert_not_exists(&standalone_file, "Standalone file should be removed after conversion"); - // No directory should be created (format preserved) - // Note: This is the conservative approach - format transitions only via CLI syncback - assert_not_exists( - &dir_path, - "Directory should NOT be created for existing standalone file", - ); + // Directory should be created with init file + assert_directory_exists(&dir_path, "Directory should be created for script with children"); + let init_file = dir_path.join("init.luau"); + assert_file_exists(&init_file, "init.luau should exist"); + + // Child should exist in the directory + let child_file = dir_path.join("NewChild.luau"); + assert_file_exists(&child_file, "Child module should be created"); }); } -/// Test: Standalone Script receives sync WITH children +/// Test: Standalone Script receives sync WITH children - converts to directory format +/// Standalone scripts cannot have children in Rojo's file format. #[test] -fn standalone_script_sync_adding_children_preserves_standalone() { +fn standalone_script_sync_adding_children_converts_to_directory() { run_serve_test("syncback_format_transitions", |session, _redactions| { let info = session.get_api_rojo().unwrap(); let root_id = info.root_instance_id; @@ -382,20 +386,25 @@ fn standalone_script_sync_adding_children_preserves_standalone() { ); send_write_request(&session, &info.session_id, rs_id, added); - // Standalone file should still exist - assert_file_exists(&standalone_file, "Standalone file should be preserved"); + // Standalone file should be removed (converted to directory) + assert_not_exists(&standalone_file, "Standalone file should be removed after conversion"); - // No directory should be created - assert_not_exists( - &dir_path, - "Directory should NOT be created for existing standalone file", - ); + // Directory should be created with init file + assert_directory_exists(&dir_path, "Directory should be created for script with children"); + let init_file = dir_path.join("init.server.luau"); + assert_file_exists(&init_file, "init.server.luau should exist"); + + // Child should exist in the directory + let child_file = dir_path.join("Handler.luau"); + assert_file_exists(&child_file, "Child module should be created"); }); } -/// Test: Standalone LocalScript receives sync WITH children +/// Test: Standalone LocalScript receives sync WITH children - converts to directory format +/// When a standalone script receives children, it must be converted to directory format +/// because standalone scripts cannot have children in Rojo's file format. #[test] -fn standalone_localscript_sync_adding_children_preserves_standalone() { +fn standalone_localscript_sync_adding_children_converts_to_directory() { run_serve_test("syncback_format_transitions", |session, _redactions| { let info = session.get_api_rojo().unwrap(); let root_id = info.root_instance_id; @@ -431,14 +440,17 @@ fn standalone_localscript_sync_adding_children_preserves_standalone() { ); send_write_request(&session, &info.session_id, rs_id, added); - // Standalone file should still exist - assert_file_exists(&standalone_file, "Standalone file should be preserved"); + // Standalone file should be removed (converted to directory) + assert_not_exists(&standalone_file, "Standalone file should be removed after conversion"); - // No directory should be created - assert_not_exists( - &dir_path, - "Directory should NOT be created for existing standalone file", - ); + // Directory should be created with init file + assert_directory_exists(&dir_path, "Directory should be created for script with children"); + let init_file = dir_path.join("init.local.luau"); + assert_file_exists(&init_file, "init.local.luau should exist"); + + // Child should exist in the directory + let child_file = dir_path.join("UIComponent.luau"); + assert_file_exists(&child_file, "Child module should be created"); }); } @@ -1019,9 +1031,11 @@ fn scripts_only_mode_script_preserves_directory() { }); } -/// Test: scriptsOnlyMode - Standalone module preserves format +/// Test: scriptsOnlyMode - Standalone module converts to directory when children added +/// Even in scriptsOnlyMode, standalone scripts must convert to directory format +/// when children are added, because standalone scripts cannot have children. #[test] -fn scripts_only_mode_standalone_module_preserved() { +fn scripts_only_mode_standalone_module_converts_to_directory() { run_serve_test("syncback_scripts_only", |session, _redactions| { let info = session.get_api_rojo().unwrap(); let root_id = info.root_instance_id; @@ -1057,11 +1071,13 @@ fn scripts_only_mode_standalone_module_preserved() { ); send_write_request(&session, &info.session_id, sss_id, added); - // Standalone should be preserved - assert_file_exists(&standalone, "Standalone preserved"); + // Standalone should be removed (converted to directory) + assert_not_exists(&standalone, "Standalone removed after children sync"); - // No directory should be created (even with children) - assert_not_exists(&dir_path, "Directory should NOT be created"); + // Directory should be created + assert_directory_exists(&dir_path, "Directory created for script with children"); + let init_file = dir_path.join("init.luau"); + assert_file_exists(&init_file, "init.luau should exist"); }); } @@ -2017,6 +2033,7 @@ fn sync_name_with_dots() { } /// Test: Sync standalone then immediately sync directory version +/// When children are added, standalone must convert to directory format. #[test] fn sync_standalone_then_directory_format() { run_serve_test("syncback_format_transitions", |session, _redactions| { @@ -2033,10 +2050,11 @@ fn sync_standalone_then_directory_format() { let src_path = session.path().join("src"); let standalone = src_path.join("StandaloneModule.luau"); + let dir_path = src_path.join("StandaloneModule"); assert_file_exists(&standalone, "Standalone exists initially"); - // First sync - no children (standalone format) + // First sync - no children (standalone format preserved) let added1 = make_added_instance( rs_id, "StandaloneModule", @@ -2046,7 +2064,10 @@ fn sync_standalone_then_directory_format() { ); send_write_request(&session, &info.session_id, rs_id, added1); - // Second sync - with children (would be directory if new) + // Standalone should still exist after sync without children + assert_file_exists(&standalone, "Standalone preserved after sync without children"); + + // Second sync - with children (converts to directory) let added2 = make_added_instance( rs_id, "StandaloneModule", @@ -2061,8 +2082,13 @@ fn sync_standalone_then_directory_format() { ); send_write_request(&session, &info.session_id, rs_id, added2); - // Standalone should still exist (format preserved) - assert_file_exists(&standalone, "Standalone preserved after children sync"); + // Standalone should be removed (converted to directory) + assert_not_exists(&standalone, "Standalone removed after children sync"); + + // Directory should now exist + assert_directory_exists(&dir_path, "Directory created after children sync"); + let init_file = dir_path.join("init.luau"); + assert_file_exists(&init_file, "init.luau should exist"); }); }